3 Mail Queue
Ralf Warmuth edited this page 2026-01-09 22:32:38 +01:00
This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

Mail-Queue (Cron) Architektur & Funktionsweise

Diese Seite erklärt die Mail-Queue so, wie sie im aktuellen Code implementiert ist inkl. Token-Bucket (“Bucket-Dingens”), Retry/Locks und Betrieb.

Begriffe (Token-Bucket, Lock/Lease, Backoff, Idempotenz): siehe Glossar.

Warum gibt es die Mail-Queue?

Viele Hoster limitieren ausgehende E-Mails (z.B. 60 Mails/Stunde). Pro Registrierung gehen typischerweise 2 Mails raus (User + Orga). Wenn viele Anmeldungen in kurzer Zeit reinkommen, würde synchroner Versand:

  • Requests verlangsamen oder blockieren,
  • beim Überschreiten des Limits fehlschlagen,
  • zu Doppel-Sends/Chaos führen (Retries im HTTP-Kontext).

Ziel: Registrierung soll schnell und stabil bleiben Mails kommen dann asynchron nach.

Überblick: Was passiert wann?

Beim Registrierungs-POST (/registration/)

Nach erfolgreichem Speichern der Registrierung werden (je nach Konfiguration) Jobs in die Queue gelegt:

  • Job 1: mail_type = 'user'
  • Job 2: mail_type = 'organizer'

Wichtig: Die Registrierung soll nicht scheitern, wenn Queue oder Mailversand Probleme macht Fehler werden geloggt, aber der User bekommt den Erfolg.

Im Cron-Worker (CLI-only)

Ein Cronjob (z.B. minütlich) startet den Worker, der:

  • Rate-Limit anwendet (Token-Bucket),
  • Jobs atomar “claimt” (pending → sending),
  • Mails versendet,
  • bei Erfolg Jobs löscht,
  • bei Fehlern retrybar “failed” setzt (mit Backoff).

Datenmodell: mail_queue

Die Queue ist eine SQLite-Tabelle (Schema: zgb-backend/Repository/Schema/MailQueue/initMailQueueSchema.sql) mit Feldern:

  • registration_number (Referenz auf registrations.number)
  • mail_type (user | organizer)
  • status (pending | sending | failed)
  • attempts, next_attempt_at
  • locked_until (Lock/Lease für “sending”)
  • last_error

Idempotenz (keine Doppel-Jobs)

Es gibt einen Unique-Index auf (registration_number, mail_type).
Beim Enqueue wird INSERT OR IGNORE genutzt → wiederholtes Enqueue erzeugt keine Duplikate.

Retention (“Option A”)

Nach erfolgreichem Versand wird der Job gelöscht (keine “sent”-Historie in der DB).

Concurrency & Locks: “pending → sending”

Der Worker claimt Jobs in einer DB-Transaktion:

  1. Kandidaten selektieren (pending und next_attempt_at <= now, LIMIT n)
  2. Updaten: status='sending', locked_until=now+TTL, attempts=attempts+1
  3. Danach werden nur die wirklich geclaimten Jobs erneut gelesen und verarbeitet.

locked_until dient als Lease:

  • Wenn ein Worker abstürzt, bleiben Jobs nicht ewig “sending”.
  • recoverStuckSending(now) setzt Jobs zurück auf pending, wenn locked_until < now.

Retry/Backoff: Was passiert bei Fehlern?

Wenn ein Send fehlschlägt:

  • Job wird failed
  • last_error wird (gekürzt) gespeichert
  • next_attempt_at wird gesetzt (Backoff)

Backoff-Policy (wie im Code):

  • Attempt 1 → +60s
  • Attempt 2 → +300s
  • Attempt 3 → +900s
  • Attempt 4+ → +3600s

Ab ca. 10 Versuchen wird der Job “geparkt” (sehr weit in der Zukunft), bis ein Admin manuell retried.

Token-Bucket (“Bucket-Dingens”) einfach erklärt

Intuition

Stell dir einen Eimer (“Bucket”) vor, in dem Tokens liegen.
Ein Token erlaubt eine Mail.

  • Tokens werden kontinuierlich nachgefüllt: \text{ratePerSec} = \frac{\text{mailMaxPerHour}}{3600}
  • Der Eimer hat eine Obergrenze (Burst-Limit): cap
  • Der Worker darf pro Lauf nur so viele Mails senden, wie ganze Tokens vorhanden sind: allowed = floor(tokens)

Warum das gut ist

  • Du hältst das Stundenlimit zuverlässig ein.
  • Du vermeidest “Bursts”, die Provider mit rolling windows trotzdem als zu viel sehen könnten.
  • Der Versand wird gleichmäßig verteilt.

Konkretes Beispiel: 40 Mails/Stunde

  • ratePerSec = 40/3600 ≈ 0,0111 Tokens/Sekunde
  • Pro Minute entstehen ≈ 0,666 Tokens
  • Das heißt: Im Schnitt wird ungefähr alle 12 Minuten eine Mail gesendet (je nach angesammelten Tokens).

Burst-Limit (cap)

Im Code ist die Burst-Obergrenze bewusst konservativ: cap = min(mailMaxPerHour, 5).
Damit kann ein einzelner Cron-Lauf nicht “zu viel auf einmal” senden, selbst wenn Tokens länger angespart wurden.

Persistenz in der DB

Tokens werden in der config-Key/Value-Tabelle persistiert:

  • mailTokens
  • mailLastRefill

Der Worker schreibt Tokens vor dem Versand zurück, um “Double-Spend” bei Crashs zu reduzieren.

Admin-UI (Mail-Queue Tab)

Auf /backend/web/admin.php gibt es einen “Mail-Queue” Tab mit:

  • mailQueueEnabled (an/aus)
  • mailMaxPerHour (160)
  • mailRedirectToSchumbi (Testmodus)
  • Statusanzeige: pending/sending/failed + Registrierungen gesamt
  • Button: “Failed Jobs erneut versuchen” (setzt failed → pending)

Live-Status wird per Polling über admin_queue_status.php aktualisiert.

Testmodus: mailRedirectToSchumbi

Wenn mailRedirectToSchumbi=1, werden User-Mails (nicht Organizer) auf @schumbi.de umgeleitet aber nur außerhalb des echten Anmeldezeitraums.
In der Anmeldephase ist diese Umleitung serverseitig deaktiviert.

Betrieb (Ops)

Cron einrichten

Der Cron-Entry-Point ist CLI-only:

  • backend/cron/send_mail_queue.php

Beispiel (minütlich):

* * * * * /usr/bin/php /path/to/website/public/backend/cron/send_mail_queue.php

Logs

Der Cron schreibt ein JSON-zeilenbasiertes Log. Standardpfad hängt vom Host ab:

  • bevorzugt: $HOME/logs/mail-queue.log
  • fallback: public/data/mail-queue.log

Override per Env:

  • MAIL_QUEUE_LOG=/pfad/zur/logdatei

Was tun bei “Mails kommen nicht”?

  • Queue-Tab prüfen: pending/failed?
  • Cron läuft? (Hoster-Cron/Logs)
  • mailMaxPerHour sinnvoll gesetzt?
  • Server mail() / sendmail/postfix prüfen

Hinweis zum Löschen von Registrierungen

Wenn Registrierungen gelöscht werden, können Queue-Jobs “waisen”.
Der Worker löscht Jobs automatisch, wenn die referenzierte Registrierung nicht mehr existiert.