Sechzehn Minuten. Drei Bugs. Null Fehlermeldungen.
Oder: Wie Symfony Messenger uns versicherte, dass alles läuft — während absolut nichts passierte
Wir haben einen Symfony-Messenger-Worker auf Production deployed, den Start
beobachtet, das vertraute
[OK] Consuming messages from transport "bulk_operations" in den Logs gesehen —
und weitergemacht. Messages stauten sich. Der Worker tat nichts. Keine
Exceptions, keine Warnings, keine fehlgeschlagenen Jobs. Nur Stille.
Was folgte, war eine 16-minütige Debugging-Session, die drei voneinander unabhängige Bugs aufdeckte — jeder auf seine Art unsichtbar. Hier ist der Ablauf.
TL;DR: Drei Bugs, drei Silent Failures. (1) auto_setup=0 in der DSN sorgte
dafür, dass die messenger_messages-Tabelle nie erstellt wurde — der Worker
loggt trotzdem OK. (2) use_notify: true funktioniert nur mit PostgreSQL; MySQL
ignoriert es und fällt nicht auf Polling zurück. (3) PHP-FPM schrieb
Europe/Berlin-Timestamps, während PHP CLI sie als UTC las — jeder
Background-Job verzögerte sich um exakt eine Stunde.
Das Setup
Permalink to "Das Setup"Die Umgebung: Symfony 6.2, PHP 8.1, MySQL 8.0 auf einem Bare-Metal-Ubuntu-Server. Alle Erkenntnisse beziehen sich auf diesen Stack — das Verhalten kann sich mit anderen Symfony-Versionen oder Redis/AMQP-Transports unterscheiden. Der Background-Worker läuft als systemd-Service (der Standard-Mechanismus unter Linux für langlaufende Hintergrundprozesse):
# /etc/systemd/system/messenger.service
ExecStart=/usr/bin/php /var/www/app/bin/console messenger:consume bulk_operations --time-limit=3600 --memory-limit=128M
User=www-data
Der Transport nutzt Doctrine:
# config/packages/messenger.yaml
framework:
messenger:
transports:
bulk_operations:
dsn: "%env(MESSENGER_TRANSPORT_DSN)%"
options:
use_notify: true
check_delayed_interval: 60000
Und die DSN in .env:
MESSENGER_TRANSPORT_DSN=doctrine://default?auto_setup=0
Drei Config-Entscheidungen. Drei Bugs. Schauen wir sie uns in der Reihenfolge an, in der wir sie gefunden haben.
Warum schlägt Messenger ohne die ‘messenger_messages’-Tabelle still fehl?
Permalink to "Warum schlägt Messenger ohne die ‘messenger_messages’-Tabelle still fehl?"Die messenger_messages-Tabelle existierte nicht.
Die DSN hatte auto_setup=0, was Symfonys Doctrine-Transport anweist, die
Queue-Tabelle nicht automatisch anzulegen. Das ist ein vernünftiges
Production-Setting — du willst nicht, dass deine App Datenbanktabellen on the
fly erstellt. Aber es setzt voraus, dass du die Tabelle selbst angelegt hast.
Hatten wir nicht.
Das Problem: messenger_messages ist keine Doctrine-Entity. Es gibt keine
Migration dafür, keine Entity-Klasse, nichts in src/Entity/. Es ist eine
interne Transport-Tabelle, die Symfony außerhalb deines ORM-Layers verwaltet.
Wenn du den Transport sie nicht erstellen lässt und es auch nicht manuell
machst, existiert sie schlicht nicht.
Die Reaktion des Workers auf eine fehlende Tabelle?
[OK] Consuming messages from transport "bulk_operations". Kein Error. Kein
Warning. Nur ein grünes Häkchen und unendliche Geduld.
Der Fix waren zwei Zeilen:
# auto_setup=0 aus der DSN entfernen
# MESSENGER_TRANSPORT_DSN=doctrine://default?auto_setup=0
MESSENGER_TRANSPORT_DSN=doctrine://default
# Tabelle sofort erstellen
php bin/console messenger:setup-transports
Das Kommando bestätigte, dass sowohl bulk_operations als auch failed
eingerichtet wurden. Prüfen mit SHOW TABLES LIKE 'messenger_messages' — du
solltest eine Zeile sehen. Tabelle erstellt, erster Bug erledigt. Wir schickten
drei Test-Messages über die Admin-UI.
Nichts passierte.
Was passiert, wenn ‘use_notify: true’ mit MySQL konfiguriert ist?
Permalink to "Was passiert, wenn ‘use_notify: true’ mit MySQL konfiguriert ist?"Die Option use_notify: true aktiviert PostgreSQLs LISTEN/NOTIFY-Mechanismus —
ein Push-basiertes System, bei dem die Datenbank den Worker benachrichtigt, wenn
neue Messages ankommen. Effizient und eliminiert Polling.
Unsere Production-Datenbank ist MySQL.
MySQL unterstützt kein LISTEN/NOTIFY. Wenn du use_notify: true auf einem
MySQL-Transport setzt, wirft Symfony keinen Error, loggt kein Warning und fällt
nicht auf Polling zurück. Der Worker startet, meldet OK und wartet auf
Notifications, die nie kommen.
Das war ein Überbleibsel aus einer früheren Projektphase, als die Datenbank PostgreSQL war. Die Migration zu MySQL fand statt, die Transport-Config zog nicht mit.
Der Fix: den gesamten options-Block entfernen.
# config/packages/messenger.yaml
framework:
messenger:
transports:
bulk_operations:
dsn: "%env(MESSENGER_TRANSPORT_DSN)%"
Ohne use_notify fällt Symfony auf Standard-Database-Polling zurück. Cache
geleert, Worker neu gestartet, drei weitere Messages dispatched.
messenger:consume --limit=1 -vvv zeigte jetzt zumindest die Datenbankabfragen
des Consumers — Polling funktionierte also.
Immer noch nichts.
Wie verzögert ein PHP-Timezone-Mismatch Messenger-Jobs unbemerkt?
Permalink to "Wie verzögert ein PHP-Timezone-Mismatch Messenger-Jobs unbemerkt?"Dieser Bug dauerte am längsten und bescherte uns zwischendurch ein False Positive.
Wir starteten einen manuellen Consumer mit Verbose-Output:
sudo -u www-data timeout 10 php bin/console messenger:consume bulk_operations --limit=1 -vvv
Null Messages konsumiert. Keine Ausgabe — nicht mal “no messages found.” Wir prüften die Datenbank:
SELECT id, queue_name, available_at, delivered_at
FROM messenger_messages
WHERE delivered_at IS NULL;
Sechs Messages warteten dort, alle mit delivered_at = NULL. Der queue_name
war default — sah verdächtig aus, war aber korrekt (Symfonys
Doctrine-Transport matcht standardmäßig darauf). Was uns auffiel, war
available_at:
available_at: 2026-02-27 14:30:20
Serverzeit: 13:30 UTC. Die Messages hatten einen Timestamp eine Stunde in der
Zukunft.
Zwei PHPs, zwei Timezones
Permalink to "Zwei PHPs, zwei Timezones"PHP läuft auf diesem Server in zwei Modi mit zwei separaten Konfigurationen:
# PHP-FPM (verarbeitet Web-Requests)
# /etc/php/8.1/fpm/php.ini
date.timezone = Europe/Berlin
# PHP CLI (führt den Worker aus)
# /etc/php/8.1/cli/php.ini
;date.timezone =
# Auskommentiert → Default ist UTC
Wenn ein User eine Message über das Web-Interface dispatcht, stempelt PHP-FPM
available_at mit der aktuellen Zeit in Europe/Berlin (CET, UTC+1). Um 14:30
CET ist das 2026-02-27 14:30:20. MySQL speichert das in einer
datetime-Spalte — ohne Timezone-Information.
Der Worker läuft via PHP CLI in UTC. Er fragt ab:
SELECT * FROM messenger_messages
WHERE available_at <= NOW()
AND delivered_at IS NULL
ORDER BY available_at ASC
LIMIT 1;
NOW() gibt 13:30 zurück (UTC). Die Message sagt 14:30. Der Worker
überspringt sie. Jede Message wartet exakt eine Stunde, bevor sie “available”
wird.
Das False Positive
Permalink to "Das False Positive"Hier haben wir uns selbst ausgetrickst. Um die Theorie zu testen, korrigierten wir die Timestamps manuell:
UPDATE messenger_messages
SET available_at = DATE_SUB(available_at, INTERVAL 1 HOUR)
WHERE delivered_at IS NULL;
Zwischen unseren Testruns hatte der systemd-Worker im Hintergrund — der immer noch lief — die zeitkorrigierten Messages aufgegriffen. Die Messages verschwanden aus der Tabelle und wir dachten: “Läuft jetzt.”
Tat es nicht. Ein User dispatchte Minuten später einen neuen Bulk-Job. Selbes
Problem. Der available_at-Timestamp war wieder eine Stunde voraus. Unser
manuelles UPDATE hatte die Symptome für sechs Messages behoben, während die
Ursache weiter neue produzierte.
Der eigentliche Fix
Permalink to "Der eigentliche Fix"# Timezone einkommentieren und setzen
sudo sed -i 's/^;date.timezone =$/date.timezone = Europe\/Berlin/' /etc/php/8.1/cli/php.ini
# Worker neu starten (übernimmt die neue php.ini)
sudo systemctl restart messenger
Zwei wartende Messages wurden innerhalb von Sekunden konsumiert.
Du kannst die CLI-Timezone prüfen, ohne irgendetwas neuzustarten:
sudo -u www-data php -r "echo date_default_timezone_get();"
# Vorher: UTC
# Nachher: Europe/Berlin
Langfristig: Der robustere Fix ist, den gesamten Stack auf UTC zu
standardisieren — PHP-FPM, PHP CLI und die MySQL-Server-Timezone. Wenn alles UTC
spricht, verschwindet die FPM/CLI-Diskrepanz als Bug-Kategorie. Setz
date.timezone = UTC in beiden php.ini-Dateien und default-time-zone = UTC
in my.cnf, und diese Klasse von Bugs kann nicht mehr auftreten.
Das Muster: Stille als Fehlermodus
Permalink to "Das Muster: Stille als Fehlermodus"Alle drei Bugs teilen eine Eigenschaft: Symfony Messenger meldete Erfolg,
während nichts passierte. Die [OK]-Meldung des Workers ist eine
Startup-Bestätigung, kein Health-Check. Sie bedeutet “Ich höre zu”, nicht “Ich
kann etwas hören.” Dieses Muster ist nicht einzigartig für diese Komponente —
wir sind auf dieselbe Stille bei einem
Redis-Lock-Expiration-Bug im Crunz-Scheduler
gestoßen, einer komplett anderen Komponente mit demselben Fehlermodus: grüner
Output, keine Arbeit erledigt.
Wenn das dein erstes Production-Deployment ist, geh diese Checkliste durch:
-
Prüfe, ob die Tabelle existiert. Starte
php bin/console messenger:setup-transportsund prüfe mitSHOW TABLES LIKE 'messenger_messages'. Verlass dich nicht aufauto_setup— sei explizit. -
Transport-Optionen müssen zur Datenbank passen.
use_notifyfunktioniert nur mit PostgreSQL. Wenn du die Datenbank migriert hast, überprüfe deinemessenger.yaml. -
PHP-Timezones vereinheitlichen. Prüfe sowohl FPM- als auch CLI-Config. Es sind separate
php.ini-Dateien mit separaten Defaults. Ein Versatz von einer Stunde wirft keinen Error — er verzögert nur jede Message. -
Mit Verbose-Output testen.
messenger:consume --limit=1 -vvvsollte zeigen, dass die Message empfangen und verarbeitet wird. Wenn keine Ausgabe kommt, liegt das Problem vor deinem Handler. -
Timestamps vergleichen. Nach dem Dispatch einer Test-Message sofort
available_atmitSELECT NOW()vergleichen. Die Werte sollten wenige Sekunden auseinanderliegen.
Silent Failures im Symfony Messenger überwachen
Permalink to "Silent Failures im Symfony Messenger überwachen"Die [OK]-Startup-Meldung ist als Health-Signal nutzlos. Um eine festgefahrene
Queue tatsächlich zu erkennen, beobachte zwei Metriken:
Queue-Depth — wie viele Async-Jobs warten:
SELECT COUNT(*) AS queue_depth
FROM messenger_messages
WHERE delivered_at IS NULL;
Alter der ältesten wartenden Message — wie lange die Queue schon feststeckt:
SELECT MIN(available_at) AS oldest_pending
FROM messenger_messages
WHERE delivered_at IS NULL;
Alarmiere, wenn die Queue-Depth einen Schwellwert überschreitet oder
oldest_pending mehr als ein paar Minuten in der Vergangenheit liegt. Eine
Queue, die wächst aber nie schrumpft, bedeutet: der Worker ist kaputt, nicht
beschäftigt.
Symfonys Messenger hat Stille als Fehlermodus. Der Worker startet, loggt ein grünes Häkchen und wartet. Ob er auf eine nicht existierende Tabelle wartet, auf Notifications von einer Datenbank, die keine senden kann, oder auf Timestamps, die noch nicht angekommen sind — der Output ist identisch. Dein Monitoring fängt es nicht ab. Deine Logs zeigen es nicht. Das einzige Signal ist die Abwesenheit von erledigter Arbeit.
