Idempotente Queue-Jobs: warum ein einziges Boolean-Feld dir Doppel-Mails erspart
Queue-Jobs werden wiederholt, das ist Feature, nicht Bug. Wer aber bei jedem Retry eine Alarm-Mail rausjagt, ärgert seine Kund:innen schneller, als der Outage vorbei ist. Ein kleines Pattern, das das verhindert.
Queue-Jobs in Laravel sind dafür gebaut, mehrfach zu laufen. Genau das macht sie robust – und gleichzeitig ist es die Quelle für eine spezielle Klasse Ärger: Nebeneffekte, die mehrfach passieren.
In einem Monitoring-Dienst wie Portchecks ist die offensichtlichste Variante: derselbe Alert geht zweimal raus, weil der Mail-Versand beim ersten Versuch ein Timeout hatte.
Was schiefgeht, wenn man es naiv baut
Ein typischer „läuft schon"-Code:
public function handle(): void
{
$result = $this->prober->probe($this->check);
$this->check->results()->create([...]);
if ($result->status !== $this->check->target_status) {
Mail::to($recipients)->send(new PortCheckAlertMail($this->check, $result));
}
}
Solange Mail nie failt, prima. Sobald aber der SMTP-Server kurz zickt, wirft Mail eine Exception, der Job geht in den Retry – und beim nächsten Durchlauf wird die Probe nochmal gemacht, das Ergebnis nochmal geschrieben, die Mail nochmal versucht. Wenn sie diesmal klappt, hast du:
- zwei Result-Zeilen für denselben Check,
- die Empfänger:innen bekommen die gleiche Alarm-Mail zweimal kurz hintereinander.
Das Pattern: ein Flag, hinter dem Versand gesetzt
In Portchecks hat jede port_check_results-Zeile ein alert_sent-Boolean. Der Job prüft am Ende:
public function handle(): void
{
$result = $this->prober->probe($this->check);
$row = $this->check->results()->create([
'status' => $result->status,
'matched_target' => $result->status === $this->check->target_status,
'latency_ms' => $result->latencyMs,
'error' => $result->error,
'alert_sent' => false,
'checked_at' => now(),
]);
if ($row->matched_target && ! $this->initial) {
return;
}
Mail::to($recipients)->send(new PortCheckAlertMail($this->check, $row));
$row->update(['alert_sent' => true]);
}
Drei Dinge sind hier wichtig:
- Reihenfolge: erst die Mail, dann das Flag. Wenn der Mail-Versand failt, bleibt
alert_sent = false. Der Retry kann es nochmal versuchen. - Granularität: das Flag hängt am Result, nicht am Check. Jeder Probe-Durchlauf hat sein eigenes „ist die Alarm-Mail rausgegangen?"-Bit.
- Idempotenz an der relevanten Stelle: nicht im Result-Insert (das darf bei Retry ruhig nochmal passieren – oder du verhinderst es mit
firstOrCreate), sondern beim eigentlich teuren Nebeneffekt, dem Mailversand.
Wann brauchst du eine zweite Schutzschicht?
Wenn deine Job-Implementierung wirklich kostspielige Side-Effects hat (Stripe-Charges, externe Webhooks an Drittsysteme), ist ein einzelnes Datenbank-Flag zu wenig. Dann lohnt sich ein zusätzlicher idempotency key, der vor dem Side-Effect verhindert, dass zwei parallele Worker dieselbe Aktion auslösen.
Bei Mail ist das aus meiner Erfahrung overkill. Mail-Versand ist meistens schnell genug, dass parallele Retries selten kollidieren – und wenn doch, ist eine Doppel-Mail ärgerlich, aber nicht teuer. Das alert_sent-Boolean reicht.
Was es dir bringt
Du musst nicht mehr darüber nachdenken, was passiert, wenn ein Job im fünften Versuch endlich durchgeht. Du kannst Retries hochsetzen, ohne dir den Posteingang deiner Kund:innen zu ruinieren. Und du machst dir das Leben in Tests einfacher: Statt komplexer Mocks reicht ein Assertion auf das Flag.
In Portchecks ist das das wichtigste Drei-Zeilen-Stück Code. Es macht aus einem theoretisch gefährlichen Mail-Versand einen Vorgang, dem du ohne Bauchschmerz drei Retries gönnst.