Durable Delays: Zeit als verteilter Zustand
SchemaBridge Team · 2026-01-10 · Time, Scheduling, Orchestration
Wartezeiten von 30 Tagen ohne Speicherlecks. Warum `sleep()` in verteilten Systemen ein Anti-Pattern ist.
Die sleep()-Lüge: Warum Zeit das schwierigste verteilte Primitiv ist
Jede Programmiersprache hat einen Befehl zum Warten. In Python ist es time.sleep(). In Node.js ist es setTimeout(). In Java ist es Thread.sleep(). Diese Befehle sind einfach und intuitiv – und für alles, was über ein paar Sekunden hinausgeht, in einem produktiven verteilten System völlig nutzlos.
Das bescheidene sleep() ist eine Lüge, weil es eine stabile Umgebung voraussetzt. Es geht davon aus, dass die Maschine, auf der der Code läuft, am Leben bleibt, dass der Prozess nicht von einem Load Balancer beendet wird und dass das Betriebssystem den Speicher nicht zurückfordert. In einer modernen Cloud-Umgebung sind diese Annahmen falsch. Wenn Sie in einem gewöhnlichen Kubernetes-Pod sleep(24 60 60) (einen Tag) ausführen, wird dieser Pod sehr wahrscheinlich rotiert, neu eingeplant oder neu deployt, bevor der Tag vorbei ist.
Wenn der Prozess stirbt, stirbt Ihr „sleep“ mit ihm. Ihre Geschäftslogik verschwindet im Nichts. So entsteht „vergessliche Software“ – Systeme, die Testphasen von Kunden aus den Augen verlieren, Abo-Verlängerungen verpassen und wichtige Follow-up-E-Mails nicht versenden.
Die Hierarchie der Zeit: Von Sekunden bis Monaten
Um Zeit korrekt zu verwalten, müssen wir sie zunächst kategorisieren. Nicht alle Verzögerungen sind gleich:
1. Transiente Verzögerungen (Millisekunden bis Sekunden): Meist Netzwerk-Backoffs oder Wartezeiten auf eine schnelle Datenbanksperre. Hier ist sleep() manchmal akzeptabel, weil das Risiko eines Absturzes in einem 50-ms-Fenster gering ist.
2. Kurze Verzögerungen (Minuten): Hier beginnt sleep() zu versagen. Sie blockieren minutenlang einen Worker-Thread oder die Ressourcen eines Containers, ohne etwas zu tun. Das verschwendet Geld und gefährdet den Connection-Pool.
3. Durable Delays (Stunden bis Monate): Das ist das Reich der Geschäftslebenszyklen. Eine 14-tägige kostenlose Testphase, ein Zahlungsziel von 30 Tagen oder ein sechsmonatiger Wartungsplan. Diese können nicht im Code leben; sie müssen in der Infrastruktur leben.
Die Architektur schlafender Workflows
Bei SchemaBridge behandeln wir Zeit als dauerhaften Zustand. Wenn Ihr Workflow einen DELAY-Vertex erreicht, blockiert er keinen Thread. Der Schritt wird als SUSPENDED geparkt, die Absicht aufzuwachen wird in die Datenbank geschrieben, und der Worker ist sofort frei für andere Arbeit.
Das DB-Polling-Modell (geringe Präzision, hohe Dauerhaftigkeit)
Der klassische Eigenbau-Ansatz speichert die „Weckzeit“ in einer Datenbanktabelle, und ein Hintergrund-Worker (der „Poller“) fragt die Tabelle alle paar Sekunden ab: SELECT * FROM timers WHERE wake_up < NOW(). Das ist dauerhaft, weil ein DB-Eintrag Neustarts überlebt – aber Sie bezahlen dafür mit einem stetigen Strom leerer Abfragen und einer Präzisionsuntergrenze, die Ihrem Polling-Intervall entspricht.
Der SchemaBridge-Ansatz
SchemaBridge behält die Dauerhaftigkeit des Datenbankeintrags bei und verzichtet auf das feste Polling. Jeder Weckzeitpunkt wird als absolute execute_at-Zeile in einer PostgreSQL-Tabelle scheduled_tasks persistiert (hinter unserem ScheduledTaskRepository). Der Clock-Worker schläft bis zum frühesten ausstehenden execute_at und wird durch einen PostgreSQL-LISTEN/NOTIFY-Push vorzeitig geweckt, sobald eine Aufgabe ausführbar wird. Sind Aufgaben fällig, beansprucht er sie in einer einzigen Anweisung mit FOR UPDATE SKIP LOCKED, sodass mehrere Clock-Worker-Replikas den Rückstand untereinander aufteilen können, ohne zu kollidieren. Eine großzügige Leerlauf-Obergrenze existiert nur als Absicherung für den Fall, dass eine Benachrichtigung verloren geht.
-- Vereinfachte Form des Claims: fällige Zeilen, älteste zuerst, über Replikas verteilt
UPDATE scheduled_tasks SET status = 'PROCESSING'
WHERE task_id IN (
SELECT task_id FROM scheduled_tasks
WHERE status = 'PENDING' AND execute_at <= :now
ORDER BY execute_at
FOR UPDATE SKIP LOCKED
LIMIT 100
)
RETURNING task_id;
Abbruch und vorzeitiger Ausstieg: Die Zukunft ändern
Ein Durable Delay ist nur die halbe Geschichte. Wenn sich ein Nutzer in einem Schritt „3 Tage auf Zahlung warten“ befindet und nach 2 Stunden bezahlt – was soll dann passieren?
Erstens: Abbruch. Das Stoppen eines Runs in SchemaBridge bricht die Ausführung ab und entschärft anschließend die ausstehenden Delay- und Approval-Timer dieses Runs, sodass eine gestoppte Journey nicht Tage später aufwacht und weiterläuft. Die Reihenfolge ist bewusst gewählt – erst abbrechen, dann entschärfen –, sodass im schlimmsten Fall ein Timer in einen bereits abgebrochenen Run feuert, wo Schutzprüfungen auf Endzustände ihn zu einem harmlosen No-op machen.
Zweitens: vorzeitiges Aufwachen. SchemaBridge hat kein eingebautes Primitiv „wecke diesen Delay, wenn ein externes Ereignis eintrifft“. Ein DELAY-Vertex wartet die konfigurierte Dauer ab. Es gibt zwei ehrliche Wege, einen vorzeitigen Ausstieg zu modellieren:
- Erneut prüfen beim Aufwachen: Wenn der Delay endet, fragt ein Gateway-Schritt den aktuellen Zustand ab (z. B. „Wurde diese Rechnung bezahlt?“), und eine bedingte Kante leitet auf den „bereits erledigt“-Pfad oder den Follow-up-Pfad. Der Delay muss nie unterbrochen werden; die Entscheidung wird anhand frischer Daten getroffen.
- Am Ereignis aufteilen: Modellieren Sie das Ereignis selbst (den Zahlungs-Webhook) als eigenen Einstiegspunkt oder Workflow und stoppen Sie den wartenden Run, wenn es eintrifft.
Keines der beiden Muster verlangt von der Engine, einen Timer gegen ein Signal antreten zu lassen – genau die Art von Race Condition, die Sie wegdesignen statt ihr vertrauen wollen.
Clock Drift und Präzision
In jedem verteilten System sind Uhren nie perfekt synchron. Das ist das Phänomen des Clock Drift. Wenn die Uhr einer Maschine 50 ms vor der einer anderen geht, kann sich ein „1 Sekunde warten“ unterschiedlich verhalten, je nachdem, wo die Aufgabe aufgegriffen wird.
SchemaBridge hält das Problem klein, indem der Weckzeitpunkt ein absoluter Zeitstempel an genau einer Stelle ist: die Spalte execute_at in PostgreSQL. Beim Scharfschalten eines Delays wird now + delaySeconds einmal berechnet und gespeichert; danach hält kein Worker einen Countdown im Speicher, es gibt also keinen Timer pro Worker, der driften oder bei einem Neustart verloren gehen könnte. Gewöhnliche Uhrabweichungen zwischen Maschinen gibt es weiterhin, doch für geschäftliche Verzögerungen im Bereich von Stunden oder Tagen sind sie für das Ergebnis irrelevant.
Illustratives Szenario: Automatisierung einer 30-tägigen Drip-Kampagne
Betrachten Sie eine Marketing-Automation-Plattform, die mit der Logik ihrer „Welcome Journey“ kämpft.
Die Herausforderung
Eine Journey umfasst:
- E-Mail 1 senden (Tag 1).
- 3 Tage warten.
- Wenn der Nutzer nicht geklickt hat, E-Mail 2 senden.
- 7 Tage warten.
- Wenn der Nutzer kein Upgrade durchgeführt hat, einen Promo-Code senden.
Auf einer Eigenlösung aus Cron-Jobs und einer Postgres-Tabelle sind solche Journeys fragil: Während eines Datenbank-Wartungsfensters oder eines Deployments kann ein Teil der Nutzer in einem Wartezustand „hängen bleiben“ und nie die nächste E-Mail erhalten.
Der SchemaBridge-Weg
Stellen Sie sich dieselben Journeys als SchemaBridge-Workflows neu gebaut vor.
1. Visuelle Delays: Sie ziehen einen DELAY-Vertex in den Graphen und setzen delaySeconds auf 259200 (3 Tage) und 604800 (7 Tage). Der Builder zeigt diese als kompakte Dauern wie „3d“ an.
2. Dauerhafte Fortsetzung: Während Deployments bleiben die wartenden Runs einfach in der Datenbank geparkt. Wenn der Clock-Worker zurückkommt, beansprucht er die Timer, die während der Ausfallzeit fällig wurden, die ältesten zuerst, und setzt sie fort.
3. Erneut prüfen beim Aufwachen: Nach jedem Delay fragt ein Gateway-Schritt den E-Mail- oder Billing-Anbieter, ob der Nutzer geklickt oder ein Upgrade durchgeführt hat, und eine bedingte Kante wählt den nächsten Schritt.
Der erwartete Nutzen
- Zuverlässigkeit: Eine pausierte Journey überlebt Neustarts und Deployments, sodass die Fehlerklasse „im Wartezustand hängen geblieben“ weitgehend verschwinden sollte.
- Entwicklerfreude: Die eigene Cron-Logik und der „Job Poller“-Service werden nicht mehr benötigt.
- Ein Vorbehalt, den Sie einplanen sollten: Ein Schritt, der nach einem langen Delay fortfährt und
steps.<alias>.outputaus einem früheren Schritt liest, ist darauf angewiesen, dass diese Ausgabe noch innerhalb des Aufbewahrungszeitraums Ihres Plans liegt. Bei Journeys, die Monate umfassen, tragen Sie die benötigten Werte im Payload weiter, statt auf frühere Schritte zurückzugreifen.
Vergleich: Bestehende Scheduling-Strategien
| Merkmal | setTimeout() | Cron-Jobs / Quartz | SchemaBridge Durable Delays |
| :--- | :--- | :--- | :--- |
| Persistenz | Keine (flüchtig) | Manuell (DB-gestützt) | Nativ (PostgreSQL scheduled_tasks) |
| Skalierbarkeit | Gering (an den RAM gebunden) | Mittel (DB-Engpass) | Hoch (SKIP LOCKED über Replikas) |
| Abbruch | Komplex (manuelles Handle) | Manuelle DB-Bereinigung | Das Stoppen eines Runs entschärft seine Timer |
| Sichtbarkeit | Keine | SQL-Abfragen | Geparkte (suspendierte) Schritte in der Run-Ansicht sichtbar |
| Auflösung | Millisekunden | Sekunden/Minuten | Per Push geweckt (LISTEN/NOTIFY), kein festes Polling |
Experten-Checkliste für langlaufende Geschäftslogik
Wenn Sie ein System entwerfen, das länger als 5 Minuten wartet, folgen Sie diesen Heuristiken:
1. Stoppen Sie den Thread: Blockieren Sie niemals einen Worker während des Wartens. Ihr Worker sollte zustandslos und jederzeit bereit sein, beendet zu werden.
2. Externalisieren Sie die Uhr: Nutzen Sie einen zentralen, dauerhaften Speicher für den Weckzeitpunkt, nicht den In-Memory-Timer der lokalen Maschine.
3. Planen Sie vorzeitige Ergebnisse ein: Gehen Sie immer davon aus, dass der Nutzer die erwartete Aktion vor Ablauf des Delays ausführen könnte. Prüfen Sie den Zustand am Ende der Wartezeit erneut, statt anzunehmen, dass sich nichts geändert hat.
4. Überwachen Sie den Warteraum: Beobachten Sie, wie viele Runs Schritte haben, die auf einem Timer geparkt sind. Das ist ein wichtiger Indikator für die Gesundheit Ihres Geschäfts.
5. Bevorzugen Sie relative Delays: Drücken Sie eine Wartezeit als Dauer ab dem vorherigen Schritt aus (delaySeconds) statt als festes Kalenderdatum, damit der relative Abstand zwischen den Schritten gleich bleibt, wenn ein früherer Schritt verspätet läuft.
Fazit: Zeit ist ein Infrastrukturproblem
Software, die vergisst, ist Software, die versagt. Indem wir Zeit als erstklassigen, dauerhaften Zustand behandeln, schlagen wir die Brücke zwischen Echtzeit-Ereignissen und menschlichem Verhalten mit hoher Latenz. Mit SchemaBridge übersteht eine Journey, die Tage oder Wochen umfasst, die Neustarts und Deployments, die einen schlafenden Thread töten würden – vorausgesetzt, Sie planen die vorzeitigen Ausstiege ein und tragen die Daten weiter, die spätere Schritte benötigen.
Die Fähigkeit, das Timing Ihrer Interaktionen zuverlässig zu steuern, ist ein echter Wettbewerbsvorteil. Wir liefern die dauerhafte Uhr; Sie liefern die Journey.
Teil unserer Serie „Building the Bridge“. Siehe auch: Die Observability-Lücke zum Debuggen langlaufender, mehrstufiger Runs, ohne sich in den Logs zu verlieren.