Symfony Messenger Worker hängt? 3 stille Production-Bugs

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:

  1. Prüfe, ob die Tabelle existiert. Starte php bin/console messenger:setup-transports und prüfe mit SHOW TABLES LIKE 'messenger_messages'. Verlass dich nicht auf auto_setup — sei explizit.

  2. Transport-Optionen müssen zur Datenbank passen. use_notify funktioniert nur mit PostgreSQL. Wenn du die Datenbank migriert hast, überprüfe deine messenger.yaml.

  3. 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.

  4. Mit Verbose-Output testen. messenger:consume --limit=1 -vvv sollte zeigen, dass die Message empfangen und verarbeitet wird. Wenn keine Ausgabe kommt, liegt das Problem vor deinem Handler.

  5. Timestamps vergleichen. Nach dem Dispatch einer Test-Message sofort available_at mit SELECT 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.