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.