075 —Magento
Magento bestelmails stoppen: één rij in queue_message_status
Een Nederlands bureau belt om 23:41: hun Magento-shop verstuurde drie dagen geen bevestiging. Het spoor leidt naar één rij in queue_message_status.
De Loom komt binnen op dinsdag om 23:41. Een Nederlands bureau waar we mee werken heeft hun klant op de speaker, een kleine Magento 2-shop die misschien 80 bestellingen per dag doet. De klant merkte het omdat een koper belde: waar blijft mijn bevestiging? Daarna checkte ze haar eigen testbestelling van die ochtend. Ook niets in haar inbox. Vervolgens trok ze drie dagen aan bestellingen uit de admin en legde ze tegen de maillogs. Drie dagen aan bestellingen. Nul verstuurde mails.
Er was niets veranderd aan de shop. Geen deploy, geen module-update, geen werk aan de infrastructuur. Bestellingen liepen prima door Stripe, voorraad werd afgeboekt, sales_order-rijen werden geschreven, en de klantkant zag er volledig normaal uit. Het enige symptoom was de afwezigheid van een mail. Marketing verstuurde campagnes zonder gedoe via dezelfde SMTP-relay, dus het netwerk was aantoonbaar gezond. Het transactionele kanaal was het stille kanaal.
Deze post is de walkthrough van hoe we de Magento-mailstoring herleidden tot één rij in queue_message_status, en waarom de fix negentig seconden kostte zodra we wisten waar we moesten kijken. Als je Magento 2 in productie draait, ga je dit faalmechanisme vroeg of laat tegenkomen, en er is geen admin-scherm dat je waarschuwt.
Het eerste signaal in de database
Bestelbevestigingen in Magento 2 worden niet inline verstuurd. Ze gaan op een queue. Sinds 2.3 is asynchrone mail de default, wat betekent dat er bij een bestelling een message op de queue komt en een consumer-proces die later oppikt. Die ontkoppeling is goed voor checkout-latency en slecht voor zichtbaarheid. Een vastgelopen consumer faalt stil. De bestelling klopt. De queue is het kapotte deel. De admin loopt netjes door naar 'Complete' en de shopeigenaar heeft geen reden om iets te vermoeden.
Het eerste wat we deden was de database openen en de meest voor de hand liggende vraag stellen.
SELECT topic_name, COUNT(*) AS pending
FROM queue_message qm
JOIN queue_message_status qms ON qms.message_id = qm.id
WHERE qms.status IN (2, 4, 7)
GROUP BY topic_name
ORDER BY pending DESC;
Status 7 is NEW, 2 is IN_PROGRESS, 4 is RETRY_REQUIRED. De bovenste rij gaf 2.341 pending messages voor topic_name = sales.email.order. Drie dagen aan bestelbevestigingen die in een tabel zaten te wachten op een consumer die nooit kwam. De topics daarachter (invoice, shipment, creditmemo) stapelden zich om dezelfde reden op.
De cron-keten nalopen
In Magento 2 wordt de consumer voor dat topic gestart door de cronjob consumers_runner binnen de consumers-groep. De keten ziet er zo uit:
- De system cron draait elke minuut en roept
bin/magento cron:runaan. - Die dispatcht de consumers-groep.
- consumers_runner start de geconfigureerde consumers, waaronder sales.email.order.
- De consumer leest uit queue_message_status, markeert rijen als IN_PROGRESS, verstuurt de mail, en markeert ze COMPLETE.
We checkten de system crontab. Prima. We checkten cron_schedule.
SELECT job_code, status, scheduled_at, executed_at, finished_at
FROM cron_schedule
WHERE job_code = 'consumers_runner'
ORDER BY scheduled_at DESC
LIMIT 10;
Elke rij had status = success. De cron draaide. De consumer werd gestart. Toch werd er niets verwerkt. Dat is de klassieke vorm van dit incident: elke health check hogerop in de stack zegt groen, omdat elke health check hogerop in de stack de verkeerde vraag stelt.
Toen keken we naar de proceslijst.
ps -ef | grep queue:consumers | grep -v grep
Er draaiden drie sales.email.order-processen. Volgens ps liepen ze al tussen de vier en zeventig uur. Geen van allen deed iets. Ze waren niet gecrasht. Ze gebruikten geen CPU. Ze zaten daar, geblokkeerd op iets onzichtbaars.
In queue_message_status
Hier werd het interessant. We vroegen de tabel om zijn oudste in-progress-rijen te laten zien.
SELECT id, message_id, status, updated_at, number_of_trials
FROM queue_message_status
WHERE status = 2
ORDER BY updated_at ASC
LIMIT 5;
De oudste rij was drie dagen en vier uur oud. Status 2 (IN_PROGRESS), number_of_trials = 0. Hij was opgepikt door een consumer die vervolgens was verdwenen zonder hem ooit op COMPLETE of ERROR te zetten. De volgende consumer-instance vond hem al op IN_PROGRESS staan, respecteerde dat, en liep eraan voorbij. De daarna ook. En de daarna ook. De queue draait FIFO binnen een topic, dus elke latere bevestiging stapelde zich op achter één spookrij.
De database queue driver van Magento gebruikt SELECT ... FOR UPDATE om een message te claimen en IN_PROGRESS weg te schrijven. Als de worker doodgaat tussen claim en complete, blijft die rij liggen tot iets hem reset. Er is geen ingebouwde reaper. Het framework gaat ervan uit dat consumers hun werk afmaken of netjes crashen, en geen van beide is gegarandeerd als het onderliggende probleem bijvoorbeeld een OOM-kill van de host is of een SIGTERM van een deploy-script.
In dit geval had de host drie nachten eerder PHP-FPM-children OOM-killed tijdens een backup-window dat de machine kort over zijn geheugenplafond duwde. De cron-gespawnde consumer hoorde toevallig bij de slachtoffers. Zijn geclaimde rij in queue_message_status werd nooit vrijgegeven. Vanaf dat moment hoopte elke bevestigingsmail zich daarachter op. De Adobe Commerce-docs over managing message queues beschrijven de consumer-lifecycle, maar ze behandelen dit faalmechanisme niet echt. Dat leer je door erin te lopen.
De rij losmaken zonder drie dagen aan dubbels te versturen
De fix is twee SQL-statements en een process-kill, in die volgorde.
pkill -f 'queue:consumers:start sales.email.order'
UPDATE queue_message_status
SET status = 7
WHERE status = 2
AND updated_at < NOW() - INTERVAL 30 MINUTE;
Status 7 zet de rij terug in de NEW-pool zodat elke consumer hem kan claimen. De ondergrens van 30 minuten beschermt je tegen het overrijden van een rij die een gezonde consumer halverwege heeft, wat ertoe doet als de cron in het gat tussen je pkill en je UPDATE een consumer heeft herstart.
De dubbele-mail-zorg gold ook aan de andere kant. Drie dagen aan onverstuurde bevestigingen betekent 2.341 mails die straks ineens binnen rollen, sommige in inboxen die inmiddels boos zijn afgehaakt. De keuze van het bureau was hier juist: versturen. Een bevestiging die laat aankomt is vervelend. Een bevestiging die nooit aankomt ziet eruit als fraude. We hebben wel twee klanten gemarkeerd die intussen een chargeback hadden geopend, hun specifieke queue_message-rijen verwijderd, en de rest doorgelaten.
DELETE qms FROM queue_message_status qms
JOIN queue_message qm ON qm.id = qms.message_id
WHERE qm.topic_name = 'sales.email.order'
AND qm.body LIKE '%"order_id":12847%';
Daarna herstartten we de consumer netjes met een max-messages-plafond, zodat de inhaalslag zelf niet weer een rij kon laten vastlopen.
bin/magento queue:consumers:start sales.email.order --max-messages=500
Zes minuten later was de tabel leeg en stonden de maillogs vol. Het bureau stuurde een kort bericht naar de klant met uitleg over de vertraging. De shopeigenaar deed zes verzendkosten-restituties. Goedkoper dan het alternatief.
De impact van één vastzittende rij
Het interessante is niet dat dit gebeurde. Het interessante is dat er niets op alertte. De admin van Magento zelf laat de bestelling als compleet zien. De monitoring van de shop keek naar HTTP 200s en database-CPU. Sentry keek naar PHP-exceptions. Geen van die lagen kan 'een mail die zou moeten bestaan, bestaat niet' zien. Bestelbevestigingen zijn een afwezigheid, en afwezigheden gooien geen errors.
De verdediging is één cronjob die elke vijftien minuten draait, dezelfde vraag stelt die wij om 23:41 stelden, en gilt als het antwoord verkeerd is:
SELECT COUNT(*) AS stuck
FROM queue_message_status
WHERE status = 2
AND updated_at < NOW() - INTERVAL 30 MINUTE;
Als die count twee opeenvolgende checks niet-nul is, zit er iets vast. Pipe hem naar Slack, Pushover, wat je ook leest. Een tweede query die het waard is om te draaien, is de backlog NEW-messages ouder dan vijf minuten per topic, want die vangt het omgekeerde geval: een consumer die helemaal niet start, meestal omdat cron_consumers_runner ontbreekt in app/etc/env.php na een config-rebuild.
De consumer hardenen voor de volgende keer
Drie dingen die het waard zijn om te doen voordat je het ticket sluit.
Eén: zet max_messages en de consumer-lijst expliciet in app/etc/env.php. Een consumer die om de N messages netjes afsluit, is een consumer die zijn lock vrijgeeft bij de volgende slechte rij in plaats van hem voor altijd vast te houden. De consumers-referentie behandelt de keys; de config die hier werkte was:
'queue' => [
'consumers_wait_for_messages' => 0,
'only_spawn_when_message_available' => 1,
],
'cron_consumers_runner' => [
'cron_run' => true,
'max_messages' => 200,
'consumers' => [
'sales.email.order',
'sales.email.order.invoice',
'sales.email.order.creditmemo',
'sales.email.order.shipment',
],
],
Twee: voeg de stuck-row-query hierboven toe aan welke cron-monitor je ook gebruikt. Heb je er geen, dan is een PHP-script van vijf regels in /var/scripts dat naar een webhook post genoeg. De query is goedkoop, de tabel is klein, het alert is ondubbelzinnig.
Drie, en dit slaan de meeste teams over: schrijf op hoe het herstel eruitziet. Het bureau waar we mee werkten had geen runbook voor 'queue vastgelopen'. Toen het gebeurde, gokten ze veertig minuten op MTA-logs voordat ze de database openden. Een README van vier regels in de repo is het verschil tussen een fix van zes minuten en een incident van vier uur.
Toen we Pier bouwden, liepen we precies tegen deze vorm van probleem aan op elke verouderde site die we aanraakten. De storing zit in een rij in de database, de diagnose moet om 23:41 gebeuren, en de engineer die de codebase écht kent ligt te slapen. De chat-first MySQL editor die we shippen zorgt dat 'laat me vastzittende rijen in queue_message_status zien ouder dan dertig minuten' een zin is die je kunt typen in plaats van een query die je moet schrijven, en de versiegeschiedenis betekent dat de UPDATE die ze losmaakt één klik terugdraaien is als je je ondergrens verkeerd inschat.
Het kleinste wat je vandaag kunt doen: open een database-console op elke Magento 2-shop die je beheert, draai die ene COUNT-query tegen queue_message_status, en schrijf de uitkomst ergens op. Als hij iets anders dan nul is, heb je je volgende incident gevonden voordat het jou vond.
— Vragen —
Hoe weet ik of mijn Magento 2-bestelmails in de queue staan maar niet worden verstuurd?
Draai SELECT COUNT(*) FROM queue_message_status WHERE status = 2 AND updated_at < NOW() - INTERVAL 30 MINUTE. Niet-nul betekent dat een consumer een rij heeft geclaimd en nooit heeft afgemaakt.
Is het veilig om UPDATE op queue_message_status te draaien op een live shop?
Ja, maar kill eerst de consumer-processen met pkill, draai de UPDATE met een updated_at-ondergrens van minstens 30 minuten, en herstart daarna de consumer. De pkill overslaan riskeert dubbele mails.
Wat zorgt er meestal voor dat een Magento-consumer halverwege een claim doodgaat?
OOM-kills tijdens backup-windows, SIGTERM van deploy-scripts, en niet-gevangen PHP fatal errors tijdens email-rendering. max_messages op cron_consumers_runner zetten beperkt de impact per voorval.