Redis-Queue, die den zweiten Lauf überlebt
Der Worker kann nach dem Provider-Call und vor dem Commit sterben. Redis liefert den Job erneut. Der zweite Lauf muss leer sein.
Eine Abbuchung ging zweimal raus, nachdem ich den Queue-Container mitten im Job getötet hatte. Die Queue in diesem Stack ist Redis. Lieferung mindestens einmal. Ein php-fpm-Worker im Queue-Container kann sterben, nachdem Mail oder Webhook unser Netz verlassen haben und bevor die lokale Transaktion committed. Redis liefert den Job erneut. Ist der Handler darauf nicht gebaut, sendest du doppelt.
Der Provider hatte schon 200 geliefert. MySQL hatte die Referenz nicht gespeichert. Nutzer sahen zwei Abbuchungen. Der Host sah zwei Jobs mit derselben Payload und ohne Unique-Key.
Allein ShouldBeUnique traue ich nicht. Es deckt Überlappung, nicht den Retry nach einem Crash. Der Zaun ist eine Tabelle mit Unique-Key. Der Key ist Order-ID plus Schrittname, nie eine Zufalls-UUID. Insert vor dem Side-Effect. Trifft das Insert den Unique-Index, ist die Arbeit erledigt und der Job kehrt zurück.
Den Zaun nah halten
Der lokale Write sitzt in einer Transaktion. Der externe Call nach dem Belegen des Keys und vor der Fertig-Marke. Die Provider-Antwort-ID liegt neben dem Key. Ein Replay nutzt sie, statt erneut abzubuchen.
Retries sind begrenzt. Drei Versuche mit Backoff, dann failed_jobs und ein Alert. Das Job-Timeout ist kürzer als die Redis-Reservation. Der Backoff kürzer als die Queue-TTL. Eine fehlgeschlagene Payload muss nach dem Fix per Hand wiederholbar sein.
Ich habe es mit SIGKILL nach HTTP 200 und vor dem Commit reproduziert. Der zweite Worker traf unique, las die gespeicherte Provider-ID und ging. Ohne gespeicherte ID hätte er trotz Unique-Key erneut abgebucht, weil der erste Lauf die Referenz nie schrieb. Claim, Call, ID speichern, dann complete. Ack oder Löschen des Redis-Jobs zuletzt.
Was ich nicht auf die Queue lege
Login, CSRF und der User-Endpunkt bleiben synchron. Eine Queue, die vor dem HTML fertig sein muss, ist ein langsamer Request mit extra Failure Modes. Kontakt-Mail kann warten. Ein Cache-Rebuild nach dem Speichern einer Notiz kann warten. Eine Zahlung ohne Zaun kann nicht warten.
Der Queue-Container ist ein zweiter PHP-Prozess in Docker. Er teilt Redis mit Cache und Sessions. Session-Keys und Job-Payloads liegen in anderen Prefixes, damit das Evicten eines großen Listen-Caches keinen reservierten Job kippt. Dieses Prefix ist Teil des Designs, kein Nachtrag im Worker-Log.
Horizon-Throughput zeigte die Doppelabbuchung nicht. Die Zahlungstabelle schon. Ich zähle Zeilen pro Order-ID plus Schritt und Provider-Callbacks. Stimmen die Zahlen nicht, fehlt der Zaun oder die ID wurde nicht gespeichert.
Was ich daraus mitnehme
Ich messe Abbuchungen und Zaun-Zeilen pro Order-Schritt, nicht Queue-Throughput. At-least-once-Redis fährt den Handler zweimal. Der zweite Lauf muss leer sein.
ShouldBeUnique und einer UUID-Job-ID traue ich nicht. Unique auf Order-ID plus Schritt, Insert vor dem HTTP-Call, Provider-ID speichern.
Die Regel, die ich behalte: Timeout kürzer als die Redis-Reservation, drei Versuche dann failed_jobs, Prefixes getrennt vom Cache. Login bleibt von der Queue.
