Coordinated Shutdown
system.terminate() stoppt das Actor-System, aber eine
Produktions-App hat normalerweise vor dem Stopp noch Arbeit:
laufende HTTP-Requests drainen, dem Cluster sagen, dass du gehst,
ein Journal flushen, Broker-Verbindungen schließen. Und diese
Schritte haben eine Reihenfolge — verlasse den Cluster bevor du
die Sharding-Region stoppst; stoppe den HTTP-Server bevor die
Actors, die Requests behandeln.
Coordinated Shutdown ist das DSL dafür. Du registrierst Tasks
gegen benannte Phasen; das Framework führt sie in
Abhängigkeitsreihenfolge aus, eine Phase nach der nächsten, jede mit
einem Timeout-Cap. Ein Aufruf von run() aus einem beliebigen
Trigger (SIGTERM, K8s-PreStop-Hook, ein Admin-Endpoint) führt die
ganze Pipeline einmal aus.
Das minimale Beispiel
Abschnitt betitelt „Das minimale Beispiel“import { ActorSystem, CoordinatedShutdownId, Phases, type Reason,} from 'actor-ts';
const system = ActorSystem.create('my-app');const cs = system.extension(CoordinatedShutdownId);
cs.addTask(Phases.ServiceUnbind, 'close-http', async (reason) => { await httpServer.close();});
cs.addTask(Phases.ServiceRequestsDone, 'drain-in-flight', async () => { await waitForInFlightRequests(/* bis zu 10s */);});
cs.installProcessHooks(); // SIGTERM/SIGINT → cs.run(ProcessTerminateReason)Drei Dinge passieren, wenn SIGTERM ankommt:
- Die Runtime ruft
cs.run(new ProcessTerminateReason('SIGTERM')). - Die Phasen laufen in kanonischer Reihenfolge. Innerhalb jeder Phase laufen alle registrierten Tasks parallel; die Phase wartet auf alle (oder auf ihre Timeouts).
- Die Pipeline endet mit dem eingebauten
actor-system-terminate-Task, dersystem.terminate()für dich aufruft.
Alles dazwischen — HTTP-Unbinding, Cluster-Leave, Journal-Flush — ist das, was du hinzugefügt hast.
Die 12 kanonischen Phasen
Abschnitt betitelt „Die 12 kanonischen Phasen“In Ausführungsreihenfolge gelistet:
| # | Phasen-Name | Typische Tasks |
|---|---|---|
| 1 | before-service-unbind | Letzte Ankündigungen, bevor der Server keine Verbindungen mehr akzeptiert. |
| 2 | service-unbind | HTTP-Server / gRPC-Server / WebSocket-Listener stoppen, neue Verbindungen anzunehmen. |
| 3 | service-requests-done | Auf das Ende laufender Requests warten; den Rest abbrechen. |
| 4 | service-stop | Client-Verbindungen schließen, Sockets freigeben. |
| 5 | before-cluster-shutdown | Optionale Pre-Cluster-Leave-Hooks. |
| 6 | cluster-sharding-shutdown-region | Der Sharding-Region sagen, Entitäten zu übergeben. |
| 7 | cluster-leave | Ein Cluster.leave() ausgeben — Leaving-Status gossipen. |
| 8 | cluster-exiting | Warten, bis der Cluster den Leave bestätigt. |
| 9 | cluster-exiting-done | Cluster-Übergang als abgeschlossen bestätigen. |
| 10 | cluster-shutdown | Cluster-Transports abbauen. |
| 11 | before-actor-system-terminate | Letzte Chance für App-Level-Cleanup (Journals flushen, Broker schließen). |
| 12 | actor-system-terminate | Der eingebaute system.terminate()-Task. |
Du musst nicht jede Phase verwenden. Leere Phasen sind No-Ops; nur Phasen mit registrierten Tasks tun etwas. In einer Single-Node-App ohne Cluster sehen nur die Phasen 1-4 und 11-12 Tasks.
Die Phases-Konstante exportiert die kanonischen Namen — bevorzuge
sie gegenüber String-Literalen für Autocomplete:
import { Phases } from 'actor-ts';
cs.addTask(Phases.ServiceUnbind, ...); // ✓ typisiertcs.addTask('service-unbind', ...); // ✗ Stringly-typed, kein Auto-CompleteEigene Phasen hinzufügen
Abschnitt betitelt „Eigene Phasen hinzufügen“Für app-spezifische Arbeit, die nicht in eine kanonische Phase passt, deklariere deine eigene:
cs.addPhase({ name: 'flush-metrics', timeoutMs: 3_000, dependsOn: [Phases.BeforeActorSystemTerminate], recover: true,});
cs.addTask('flush-metrics', 'push-prometheus', async () => { await metricsRegistry.flush();});Das dependsOn-Feld macht die Ordnung DAG-förmig statt linear —
deine Phase läuft nach before-actor-system-terminate, aber vor
actor-system-terminate (weil letzteres ersteres in seiner eigenen
impliziten Kette hat).
Das Framework macht einen topologischen Sort, Zyklen scheitern also
laut zur Registrierungszeit (Error: cycle in phase dependencies).
Task-Semantik
Abschnitt betitelt „Task-Semantik“Jeder Task ist eine Funktion von Reason zu void | Promise<void>:
type ShutdownTask = (reason: Reason) => Promise<void> | void;Der reason lässt einen Task auf den Grund des
Shutdown-Triggers branchen:
cs.addTask(Phases.ClusterLeave, 'gossip-leave', async (reason) => { if (reason instanceof ClusterDowningReason) { // Wir wurden gedownt — kein Leave gossipen. return; } await cluster.leave();});Eingebaute Reason-Klassen:
| Klasse | Wann |
|---|---|
ProcessTerminateReason(signal) | SIGTERM/SIGINT via installProcessHooks. |
ActorSystemTerminateReason | User hat system.terminate() direkt aufgerufen. |
ClusterLeavingReason | Cluster hat einen Graceful-Leave initiiert. |
ClusterDowningReason | Cluster hat diesen Node herausgezwungen. |
UnknownReason | Trigger nicht spezifiziert. |
Du kannst Reason für app-spezifische Trigger subklassen
(AdminEndpointReason, HotReloadReason, etc.).
Parallelität innerhalb einer Phase
Abschnitt betitelt „Parallelität innerhalb einer Phase“Alle Tasks in einer Phase laufen gleichzeitig — sie werden
zusammen gestartet, und die Phase wartet auf den letzten (oder
seinen Timeout). Wenn du innerhalb einer Phase
Ordnungs-Anforderungen hast (Task B muss auf Task A warten), packe
sie in unterschiedliche Phasen mit einem dependsOn.
Timeouts
Abschnitt betitelt „Timeouts“Jede Phase hat ein timeoutMs (Default 5 s); jeder Task wird in
einen Timeout-Race gewickelt. Ein Task, der nicht rechtzeitig
fertig wird, wird geloggt und entweder:
- Wiederhergestellt (die Phase fährt fort,
recover: true— der Default). Die nächste Phase startet. - Hält die Pipeline an (
recover: false). Nachfolgende Phasen werden nicht ausgeführt; der Shutdown stoppt mitten im Flug.
Überschreibe pro Phase:
cs.setPhaseTimeout(Phases.ServiceRequestsDone, 30_000); // 30s Drain-BudgetOder definiere deine eigene mit gewünschtem timeoutMs / recover:
cs.addPhase({ name: 'aggressive-cleanup', timeoutMs: 1_000, // strikter Cap dependsOn: [Phases.BeforeActorSystemTerminate], recover: false, // Fehler → Halt});SIGTERM / SIGINT-Hooks
Abschnitt betitelt „SIGTERM / SIGINT-Hooks“cs.installProcessHooks();// Oder: cs.installProcessHooks(['SIGTERM', 'SIGINT', 'SIGUSR2']);Das hängt Handler an, die cs.run(new ProcessTerminateReason(signal))
aufrufen. Zweimal aufzurufen ist harmlos (idempotent). Tests
überspringen die Hooks meist; Produktion verdrahtet sie immer.
removeProcessHooks() macht sie rückgängig — nützlich für Tests,
die ein System instanziieren, laufen lassen und in einem einzelnen
Prozess wieder abbauen.
K8s-PreStop-Integration
Abschnitt betitelt „K8s-PreStop-Integration“In Kubernetes ist die Pod-Shutdown-Sequenz:
1. K8s sendet SIGTERM und beendet die Grace-Period-Uhr des Pods.2. K8s ruft auch den PreStop-Hook (falls konfiguriert), läuft parallel.3. Nach max(Graceful-Shutdown, Grace-Period) sendet K8s SIGKILL.Das Standard-Rezept:
// Bei SIGTERM Coordinated Shutdown laufen lassen:cs.installProcessHooks();
// PreStop-Hook-Script (in deinem Container-Image):// #!/bin/sh// sleep 10 # gib Upstream-LBs Zeit, diesen Pod zu drainen// exit 0Das sleep in PreStop gibt dem Load Balancer Zeit, diesen Pod aus
der Rotation zu nehmen, bevor das Actor-System anfängt
herunterzufahren — laufende HTTP-Requests sehen also nicht “ich
draine, geh weg.”
Siehe Operations — Kubernetes für das vollständige Deployment-Manifest.
Multi-Trigger-Sicherheit
Abschnitt betitelt „Multi-Trigger-Sicherheit“cs.run() ist idempotent — es mehrmals aufzurufen, gibt
dasselbe laufende Promise zurück. Drei unabhängige Trigger
(SIGTERM, ein Admin-Endpoint und ein Cluster-Downing), die alle
run aufrufen, lassen die Pipeline nicht erneut laufen. Der erste
Aufruf startet sie; folgende Aufrufe warten auf dieselbe
Vollendung.
Das ist wichtig, weil du in Produktion oft mehrere Shutdown-Pfade hast:
// SIGTERM-Pfad:cs.installProcessHooks();
// Admin-Endpoint-Pfad:app.post('/shutdown', async (req, res) => { await cs.run(new AdminEndpointReason()); res.send('ok');});
// Cluster-Downing-Pfad wird automatisch von der Cluster-Extension verdrahtet.Alle drei führen am Ende dieselbe Shutdown-Sequenz einmal aus.
Was nach cs.run()-Abschluss läuft
Abschnitt betitelt „Was nach cs.run()-Abschluss läuft“Bis das Promise resolved:
- Jeder Task in jeder Phase ist entweder erfolgreich oder zeitabgelaufen.
- Der eingebaute
actor-system-terminate-Task hatsystem.terminate()aufgerufen, was jeden Actor gestoppt und den Dispatcher und Scheduler geschlossen hat. - Der Prozess ist frei zu beenden (
process.exit(0)). Nichts mehr zu tun.
Eine übliche Hülle einer Produktions-Main:
async function main() { const system = ActorSystem.create('my-app'); const cs = system.extension(CoordinatedShutdownId);
// Tasks registrieren...
cs.installProcessHooks();
// Blockieren, bis Shutdown vollendet (z.B. via SIGTERM). await new Promise(() => {}); // resolved nie; die Hooks treiben den Shutdown}
main().catch((err) => { console.error(err); process.exit(1);});Wenn SIGTERM ankommt, feuert der Hook cs.run(...), die Pipeline
läuft, das System terminiert, und Node beendet sauber, weil keine
Handles mehr die Loop am Leben halten.
Häufige Fallstricke
Abschnitt betitelt „Häufige Fallstricke“Wie es weitergeht
Abschnitt betitelt „Wie es weitergeht“- Actor-System —
das
terminate(), das am Ende der Pipeline läuft. - Cluster-Überblick — die
Cluster-Phasen (
cluster-leave,cluster-exiting, …) verdrahten sich automatisch, wenn die Cluster-Extension aktiv ist. - Kubernetes-Deployment — das vollständige PreStop + SIGTERM + Grace-Period-Rezept.
- Persistenz — Migration — Rolling Shutdown für Journal-Migrationen.
Die CoordinatedShutdown-API-Referenz
deckt addTask, addPhase, run und den vollständigen
Phasen-Konstanten-Set ab.