Doctrine

MariaDBs impliziter Rollback frisst Doctrine-Savepoints

Eine Symfony-Message-Queue-Failed-Queue wächst über Tage auf tausende Einträge. Sämtliche Fehlermeldungen lauten SQLSTATE[42000] 1305: SAVEPOINT DOCTRINE_n does not exist oder There is no active transaction. Man sucht also nach einem Savepoint-Bug, nach kaputtem Transaction-Handling, nach einem Doctrine-Problem. Alles falsch: Der eigentliche Fehler ist ein InnoDB-Deadlock, und der steht in keiner einzigen Fehlermeldung.

  1. Doctrine DBAL bildet verschachtelte Transaktionen über Savepoints ab (SAVEPOINT DOCTRINE_2, DOCTRINE_3, ...). Das betrifft auch Systeme, die das Doctrine ORM gar nicht einsetzen: Shopware etwa ersetzt mit seinem eigenen DAL nur die ORM-Schicht, geschrieben wird trotzdem über die Doctrine\DBAL\Connection. Ein Repository-Write innerhalb einer äußeren Transaktion läuft dort im Savepoint.
  2. Erwischt ein Deadlock eines der Statements, rollt MariaDB/MySQL nicht nur das Statement, sondern die komplette Transaktion implizit zurück, und zwar inklusive aller Savepoints. Der Client erfährt davon nur durch den Deadlock-Error des einen Statements; die Verbindung selbst steht danach ohne Transaktion da.
  3. Doctrine weiß davon nichts und räumt regulär auf: ROLLBACK TO SAVEPOINT DOCTRINE_n. Den Savepoint gibt es nicht mehr, der Server antwortet mit 1305. Diese neue Exception ersetzt bzw. überlagert die ursprüngliche DeadlockException. Je nach Verschachtelungstiefe schlägt stattdessen später das äußere COMMIT/ROLLBACK mit There is no active transaction fehl.
  4. Im Log und in der Failed Queue landet damit nur das Aufräum-Symptom, nie die Ursache. Bei tiefer Verschachtelung kappt der Exception-Handler die Previous-Kette auch noch („Over 9 levels deep, aborting normalization“ bei Monolog), der Deadlock ist endgültig unsichtbar.

Die eigentliche Pointe ist die Folgewirkung: Retry-Mechanismen, die auf Doctrine\DBAL\Exception\RetryableException matchen, greifen nicht mehr, denn die Exception, die oben ankommt, ist eine schnöde DriverException, die nicht retrybar aussieht. Der Deadlock, der klassische Fall für „einfach nochmal versuchen“, wird durch sein eigenes Symptom vor der Retry-Logik versteckt. Shopware hat dafür in RetryableTransaction inzwischen einen Workaround, der per Regex auf SAVEPOINT ... does not exist matcht (siehe auch Doctrine-Issue dbal#6651), aber die Variante There is no active transaction rutscht auch daran vorbei.

Fix-Muster: Retry eine Ebene höher ansetzen als die Transaktion, die gesamte Exception-Kette (getPrevious) auf Deadlock-Signaturen prüfen statt nur auf Exception-Typen, und vor dem Retry die Connection hart zurücksetzen. Bei DBAL 4 reicht $connection->close(): Das setzt auch den internen Transaction-Nesting-Level zurück, reconnected wird lazy beim nächsten Query.

Debugging-Heuristik: tausende Savepoint-Fehler oder „no active transaction“ in einem Zeitfenster mit parallelen Schreibprozessen (Importe, Indexer, mehrere Queue-Worker) sind fast nie ein Transaction-Handling-Bug, sondern fast immer ein maskierter Deadlock. Die Ursache habe ich in SHOW ENGINE INNODB STATUS (LATEST DETECTED DEADLOCK) gefunden, nicht im Stacktrace.

Mein Nachmittag.

Unknown database type enum requested

Bei der Installation einer Shopware App, die Entities (Resources/entities.xml) enthält, wird mir bei Shopware 6.4 mitunter ein lapidares Unknown database type enum requested, MariaDb1027Platform may not support it. auf der Konsole entgegen gehustet. Das tritt auf, wenn man MariaDB benutzt und Plugins installiert sind, die Datenbanktabellen erstellen, die Felder des Typs EUNUM haben. Shopware scheint sich hier wohl Doctrine zu bedienen, um Entities aus der XML Datei der App anzulegen.

Wenn es sich um eine fremdes Plugin mit den betroffenen Tabellen handelt, mache ich es mir idR. einfach und wandle die ENUM Spalten in ausreichend große VARCHAR Spalten um. Und dann klappt auch der Spaß mit der App Aktivierung.

Es ist per SQL Query relativ einfach die "schuldigen" Tabellen und Spalten zu finden:

select col.table_schema as database_name,
     col.table_name,
     col.ordinal_position as column_id,
     col.column_name,
     col.data_type,
     trim(leading 'enum' from col.column_type) as enum_values
from information_schema.columns col
join information_schema.tables tab 
    on tab.table_schema = col.table_schema
        and tab.table_name = col.table_name
        and tab.table_type = 'BASE TABLE'
where col.data_type in ('enum')
    and col.table_schema not in (
        'information_schema', 
        'sys',
        'performance_schema', 
        'mysql'
    )
    and col.table_schema = 'Meine_Shopware_Datenbank' 
order by col.table_schema,
    col.table_name,
    col.ordinal_position;

Meine_Shopware_Datenbank muss natürlich durch den entsprechenden Namen der Shopware DB ausgetauscht werden.