Shopware

Shopware mit leerem Doctrine Panel im Symfony Profiler

Man leidet an einem Shopware Projekt und möchte mal eine Übersicht der Queries, aber der Doctrine-Query-Tab im Symfony-Profiler ist leer.

Zwei naheliegende Verdächtige (HTTP-Full-Page-Cache, dann der Objekt-/Route-Cache) waren beide falsch: Ein Test mit request-lokalem Array-Cache lieferte weiter 0, also landeten die Queries gar nicht erst im Collector. Ursache war eine fehlende optionale Dev-Dependency. Das Framework nutzt gar kein DoctrineBundle, sondern hängt sein eigenes Query-Profiling nur dann an die DB-Connection, wenn das doctrine-bridge-Paket installiert ist, abgefangen über ein isInstalled('symfony/doctrine-bridge')-Gate im Kernel-Bootstrap von Shopware. Und was soll ich sagen, an der Stelle hat man bei der ursprünglichen Einrichtung des Projekts gespart. Und wer doctrine-bridge sagt, muss auch sql-formatter sagen.

vendor/shopware/core/Framework/Adapter/Kernel/KernelFactory.php:51-53: (SW 6.7.3.1)

if ((\PHP_SAPI !== 'cli' || in_array('--profile', $_SERVER['argv'] ?? []))
    && $environment !== 'prod' && InstalledVersions::isInstalled('symfony/doctrine-bridge')) {
    $middlewares = [new ProfilingMiddleware()];
}

In diesem Fall hat mir die Installation der beiden Pakete mein heiß ersehntes Doctrine-Panel beschert.

ddev composer require --dev symfony/doctrine-bridge doctrine/sql-formatter

symfony/doctrine-bridge evtl. mit einem Version Constraint passend zur Symfony Version.

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.

Der Flow, der da ist und trotzdem nichts tut

Shopware-6-Flows, die per SQL-Migration angelegt werden (auch von Core-Migrationen!), sind zunächst funktionslos: Die payload-Spalte ist NULL, und der FlowLoader überspringt solche Flows kommentarlos. Kompiliert wird das Payload erst asynchron vom Flow-Indexer über die Message-Queue.

Auf Systemen ohne laufenden Worker (typisch: lokale Dev-Umgebungen) feuert ein Event, der Flow ist im Admin sichtbar und aktiv, es gibt null Fehlermeldungen, und trotzdem passiert nichts.

Übertragbares Muster: Migration erzeugt Daten, die erst durch asynchrone Indexierung funktional werden; der Silent-Skip macht die Diagnose fies (Debugging-Weg: log_entry-Tabelle bestätigt Event, flow.payload IS NULL verrät die Ursache).

Force mailpit (DDEV Hooks)

Falls ich mal eine natürlich DSGVO-konform anonymisierte Shopware‑DB in ein DDEV‑Projekt importieren und sicher gehen möchte, dass ausgehende Mails in Mailpit landen und nicht in die freie Welt entlassen werden, habe ich mir zwei Hooks angelegt.

.ddev/config.yaml

hooks:
  post-start:
    - exec-host: .ddev/mail/force-mailpit.sh
  post-import-db:
    - exec-host: .ddev/mail/force-mailpit.sh

Würde das folgende Skript beim Start und nach dem Import-DB-Kommando aufrufen.

.ddev/mail/force-mailpit.sh

set -euo pipefail
ddev mysql db <<'SQL'
UPDATE system_config
SET configuration_value = '{"_value":""}'
WHERE configuration_key = 'core.mailerSettings.emailAgent';
SQL

Man kann überhaupt die witzigsten und praktischsten Sachen mit Hooks machen.

Höhere Produktivität durch LLM am Arsch

Das unterstreicht auch wieder meine Ansicht, dass LLM beim Entwickeln die Produktivität keinesfalls steigert: Vibecoding: nothing more than meowing nuns

Junge! Junge! Vergangene Woche hatte ich auch wieder so eine Sache, bei der mich drei verschiedene LLM in den Wahnsinn getrieben haben: Ich wollte einfach nur wissen, wie ich unter Shopware SEO-URLs für eine vierte Root-Navigation bekomme, wenn diese nicht als einer der drei Einstiegspunkte in einem SalesChannel hinterlegt ist.

Neben der nicht programmatischen Antwort, einfach einen weiteren – unbenutzten – SalesChannel anzulegen und die Navigation dort einzutragen, kamen natürlich auch diverse Vorschläge, das Ganze mit ein wenig Code zu lösen.

Ganz entzückend, wenn der Agent dabei auf Events und Interfaces zugreift, die nie und zu keiner Version jemals existiert haben. Nach der frustrierten Anmerkung, dass es das nicht gibt, kamen im besten Fall wieder Lösungsansätze mit halluzinierten Komponenten heraus. Sehr knuffig fand ich auch den Hinweis, dass ich das einfach mit dem Command sales-channel:add-category machen könnte. Unnötig zu erwähnen, dass es das Command nicht gibt. Oder?

Wie auch immer. In den gut anderthalb Stunden hat mir kein LLM eine brauchbare Lösung anbieten können. Und ich musste doch wieder selbst denken. Also dank LLMs wieder Zeit in den Wind gerotzt, die ich hätte sinnvoller nutzen können.

Überschreiben existierender Shopware Storefront Javascript Plugins

Trotz der Info Each JavaScript plugin can only be overridden once. If two Shopware plugins try to override the same plugin, only the last one of them will actually work. (Override Existing Javascript) hat mich irgendein Gedanke geritten, das könnte mehrfach gemacht werden.

Und so wollte ich einfach das OffCanvasCartPlugin aus der Storefront ein klein wenig erweitern und die Eingabe einer Postleitzahl im Offcanvas Cart ermöglichen - manchmal zur Lieferkostenberechnung unabdingbar – und dabei die Infrastruktur zum Updaten von Feldern nutzen, die das Plugin schon mitbringt. Also einfach kurz das src/plugin/offcanvas-cart/offcanvas-cart.plugin extended, meine paar Methoden hinzugefügt und fertig. Dachte ich.

PluginManager.override('OffCanvasCart', 
    ZipExtensionOffcanvasCartPlugin, '[data-offcanvas-cart]'
);

Ich war wohl nicht der Einzige, der das dachte. Beispielsweise im SwagAmazonPay Plugin wird das Offcanvas Dingelchen auch schon überschrieben. Und gemäß dem Motto "Die Letzten werden die Ersten sein" gewinnt natürlich das Plugin, das die letzte Überschreibung durchführt. Das war nicht meins.

Letztlich bleibt einem nichts anderes übrig, die eigene Erweiterung als neues Plugin zu registrieren. Allerdings muss man dann auch darauf achten, indem selbst registrierte Plugins nicht noch einmal alle Events erstellt werden, sodass der Offcanvas zweimal geöffnet wird etc. In diesem speziellen Fall mit dem Offcanvas Plugin war das einfach, indem ich die init() überschrieben habe, ohne einen Aufruf von super.init()

export default class ZipExtensionOffcanvasCartPlugin
        extends OffCanvasCartPlugin {
    init() {
        this.client = new HttpClient()
        this._registerZipTriggerEvents()
    }

    _registerZipTriggerEvents() {
        this.$emitter.subscribe('offCanvasOpened', () => {
            const forms = DomAccess.querySelectorAll(
                document, '.js-offcanvas-cart-add-zip', false
            )
            if (forms) {
                Iterator.iterate(forms, 
                    form => form.addEventListener(
                        'submit', this._onAddZip.bind(this))
                    )
            }
        });
    }

    _onAddZip(event) {
        event.preventDefault()
        const form = event.target
        const selector = '.js-offcanvas-cart-zip'

        this.$emitter.publish('onAddZip')

        super._fireRequest(form, selector)
    }
}

Die Zip in die ShippingLocation über einen SalesChannelContextFactoryDecorator in den SalesChannelContexts zu bekommen und der vernünftige Umgang damit im Checkout war dann im Vergleich zu dem Javascript-Theater der wesentlich entspanntere Teil des Ganzen. Und ist vielleicht später mal einen Post wert.

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.