Kontext
In der vorherigen Story ging es um die Queue-Schicht. Diese hier beschreibt den kompletten asynchronen Ablauf in einem anspruchsvolleren Szenario: nicht „sofort verarbeiten“, sondern Hunderte Sendungen über die Geschäftszeiten verteilen, manchmal über mehrere Tage, jeden Zustand im Lebenszyklus verfolgen und den Fortschritt live in der Oberfläche zeigen.
Das Beispiel ist echt: zeitgesteuerte Outreach-E-Mail-Kampagnen mit Live-Tracking der Zustellung auf der produktiven Plattform, an der ich bei Formilo arbeite (Amplify Gen 2, AppSync, Lambda, DynamoDB). Der Inhalt der E-Mails entsteht früher im Ablauf; hier geht es darum, ihn pünktlich zuzustellen und zu wissen, was passiert ist.
Problem
Eine Kampagne muss:
- jede E-Mail zu einem bestimmten Zeitpunkt in der Zeitzone der Kampagne senden,
- die Sendungen über die Geschäftszeiten verteilen: eine Anzahl E-Mails pro Tag (standardmäßig 20), an ausgewählten Wochentagen, innerhalb von Zeitfenstern,
- jede E-Mail einzeln verfolgen, damit ein Fehler den Rest nicht blockiert,
- die Lücken zwischen zwei Systemen überstehen, die unabhängig voneinander ausfallen können: Datenbank und Scheduler.
Die naheliegende Alternative ist ein dauerhaft laufender „Kampagnen-Runner“ oder ein Cronjob, der die Tabelle nach fälligen Einträgen durchsucht. Ich wollte keinen Ausführungszustand im Speicher oder in einem Prozess, der tagelang am Leben bleiben muss.
Lösung
Eine E-Mail = ein dauerhafter Task
Wenn ein Nutzer eine Versand-Queue anlegt, erzeugt das Backend für jeden Empfänger einen EmailSendTask-Eintrag in DynamoDB. Er enthält einen Snapshot des Empfängers (E-Mail, Betreff), den geplanten Sendezeitpunkt und einen expliziten Zustand im Lebenszyklus:
Pending → Running → Finished | Failed | Cancelled
Die Datenbank ist die Quelle der Wahrheit. Oberfläche, Scheduler und Worker arbeiten alle gegen denselben Vertrag.
EventBridge Scheduler als Ausführungsschicht
Jeder Task bekommt seinen eigenen einmaligen Zeitplan. Zum geplanten Zeitpunkt ruft EventBridge Scheduler eine Versand-Lambda mit der Task-ID auf. Vereinfacht:
await scheduler.send(
new CreateScheduleCommand({
Name: scheduleNameFor(task.id),
GroupName: EMAIL_SCHEDULE_GROUP, // eine eigene Gruppe
ScheduleExpression: `at(${sendAt})`, // z. B. at(2026-05-28T10:15:00)
ScheduleExpressionTimezone: campaign.timezone ?? "Europe/Berlin",
FlexibleTimeWindow: { Mode: "OFF" },
ActionAfterCompletion: "DELETE",
ClientToken: task.schedulerRequestToken,
Target: {
Arn: SEND_FUNCTION_ARN,
RoleArn: SCHEDULER_ROLE_ARN, // darf nur die Versand-Lambda aufrufen
Input: JSON.stringify({ taskId: task.id }),
RetryPolicy: { MaximumRetryAttempts: 2 },
DeadLetterConfig: { Arn: SCHEDULER_DLQ_ARN }, // mit Alarm
},
}),
);
Einige Details sind wichtiger, als sie aussehen:
- Der Name des Zeitplans leitet sich aus der Task-ID ab. Jeder Task hat genau einen Zeitplan, und er ist leicht zu finden.
ClientTokenmacht das Anlegen idempotent. Es ist eine zufällige UUID, die am Task alsschedulerRequestTokengespeichert wird. Legt der Reconciler einen Zeitplan neu an, verwendet er dasselbe Token.ActionAfterCompletion: DELETEräumt jeden Zeitplan nach der Ausführung ab, es sammelt sich also nichts an.- Fehlgeschlagene Aufrufe werden zweimal wiederholt und landen danach in einer Dead-Letter-Queue mit Alarm, statt zu verschwinden.
Anlegen, ohne an Rate Limits zu stoßen
Eine eigene Mutation legt die Queue an (mehr dazu in der nächsten Story). EventBridge Scheduler reagiert empfindlich auf Rate Limits, deshalb stellt die Provisioning-Lambda höchstens 10 Scheduler-API-Aufrufe gleichzeitig. Bei großen Kampagnen hört sie 90 Sekunden vor ihrem eigenen Timeout auf, speichert einen Checkpoint und ruft sich asynchron selbst auf, um dort weiterzumachen.
Der Versand-Worker schützt Endzustände
- Ist der Task bereits
FinishedoderCancelled, überspringt er ihn. Er wirft dabei keinen Fehler: Das würde nur dazu führen, dass der Scheduler bis in die DLQ wiederholt. - Vor dem Versand übernimmt er die Zeile mit einem bedingten Schreibzugriff auf
Running. - Danach schreibt er
FinishedmitsentAtoderFailedmit dem Fehler.
Ein Reconciler, weil verteilte Systeme auseinanderlaufen
Einen DynamoDB-Eintrag und einen Zeitplan anzulegen sind zwei getrennte Operationen. Irgendwann treten Randfälle auf: Die Task-Zeile existiert, aber das Anlegen des Zeitplans ist fehlgeschlagen; eine Lambda ist abgestürzt, nachdem sie den Task auf Running gesetzt hat; ein vorübergehender AWS-Fehler hat die Ausführung unterbrochen.
Deshalb läuft alle 30 Minuten ein Reconciler. Er legt fehlende Zeitpläne für Pending-Tasks neu an und holt hängende Running-Tasks zurück, die nie einen Endzustand erreicht haben. Diesen Teil lassen viele Demos weg, und genau er macht das System betreibbar.
Live-Oberfläche: Subscriptions zuerst, Polling als Fallback
Im Frontend lädt React Query die Tasks einer Kampagne seitenweise, und Amplify observeQuery abonniert Änderungen an EmailSendTask, gefiltert nach campaignId. Die Versand-Queue aktualisiert sich live, während die Worker Statuswechsel schreiben: Pending → Running → Finished. Kommen Subscriptions unter Last verzögert an, bleibt ein leichtes Nachladen im Hintergrund aktiv, solange noch Tasks laufen.
Echtzeit, wenn es funktioniert. Robust, wenn nicht.
Abwägungen
- Ein Zeitplan pro E-Mail bedeutet viele API-Aufrufe. Deshalb ist das Anlegen begrenzt und fortsetzbar statt einer einzigen Schleife.
- Zwei Quellen können sich widersprechen. Task-Tabelle und Scheduler können auseinanderlaufen; der Reconciler existiert genau deshalb und ist kein nachträglicher Einfall.
- Retries sind begrenzt. Nach zwei Wiederholungen landet ein fehlgeschlagener Aufruf in der DLQ. Jemand muss sich das ansehen, und der Alarm sorgt dafür.
- Subscriptions allein reichen nicht. Das Nachladen im Hintergrund kostet ein paar Anfragen, dafür bleibt die Oberfläche nie auf einem veralteten Stand stehen.
Ergebnis
- Kein dauerhaft laufender Kampagnenprozess: Jede Sendung ist ein geplanter Lambda-Aufruf.
- Die Sendungen verteilen sich nach E-Mails pro Tag, Wochentagen und Zeitfenstern über die Geschäftszeiten, in der Zeitzone der Kampagne.
- Jede Ausführung ist isoliert; ein Fehler blockiert den Rest nicht, und Zeitpläne löschen sich nach der Ausführung selbst.
- Fehlende Zeitpläne und hängende
Running-Tasks werden alle 30 Minuten repariert.
Das Grundgerüst ist dasselbe wie bei einfacheren asynchronen Systemen: Task-Eintrag → Ausführungsschicht → Status-Updates → Live-Oberfläche. Geändert hat sich nur der Ausführungsmechanismus: keine DynamoDB Streams, keine SQS-Consumer, sondern EventBridge Scheduler mit einem geplanten Aufruf pro Sendung. Sind das Task-Modell und sein Lebenszyklus stabil, lässt sich die Ausführungsschicht austauschen, ohne Produktlogik oder Frontend-Zustand neu zu schreiben.