Zum Inhalt springen
Deutsch

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.

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:

  1. Die Runtime ruft cs.run(new ProcessTerminateReason('SIGTERM')).
  2. Die Phasen laufen in kanonischer Reihenfolge. Innerhalb jeder Phase laufen alle registrierten Tasks parallel; die Phase wartet auf alle (oder auf ihre Timeouts).
  3. Die Pipeline endet mit dem eingebauten actor-system-terminate-Task, der system.terminate() für dich aufruft.

Alles dazwischen — HTTP-Unbinding, Cluster-Leave, Journal-Flush — ist das, was du hinzugefügt hast.

In Ausführungsreihenfolge gelistet:

#Phasen-NameTypische Tasks
1before-service-unbindLetzte Ankündigungen, bevor der Server keine Verbindungen mehr akzeptiert.
2service-unbindHTTP-Server / gRPC-Server / WebSocket-Listener stoppen, neue Verbindungen anzunehmen.
3service-requests-doneAuf das Ende laufender Requests warten; den Rest abbrechen.
4service-stopClient-Verbindungen schließen, Sockets freigeben.
5before-cluster-shutdownOptionale Pre-Cluster-Leave-Hooks.
6cluster-sharding-shutdown-regionDer Sharding-Region sagen, Entitäten zu übergeben.
7cluster-leaveEin Cluster.leave() ausgeben — Leaving-Status gossipen.
8cluster-exitingWarten, bis der Cluster den Leave bestätigt.
9cluster-exiting-doneCluster-Übergang als abgeschlossen bestätigen.
10cluster-shutdownCluster-Transports abbauen.
11before-actor-system-terminateLetzte Chance für App-Level-Cleanup (Journals flushen, Broker schließen).
12actor-system-terminateDer 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, ...); // ✓ typisiert
cs.addTask('service-unbind', ...); // ✗ Stringly-typed, kein Auto-Complete

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).

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:

KlasseWann
ProcessTerminateReason(signal)SIGTERM/SIGINT via installProcessHooks.
ActorSystemTerminateReasonUser hat system.terminate() direkt aufgerufen.
ClusterLeavingReasonCluster hat einen Graceful-Leave initiiert.
ClusterDowningReasonCluster hat diesen Node herausgezwungen.
UnknownReasonTrigger nicht spezifiziert.

Du kannst Reason für app-spezifische Trigger subklassen (AdminEndpointReason, HotReloadReason, etc.).

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.

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-Budget

Oder 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
});
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.

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 0

Das 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.

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.

Bis das Promise resolved:

  • Jeder Task in jeder Phase ist entweder erfolgreich oder zeitabgelaufen.
  • Der eingebaute actor-system-terminate-Task hat system.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.

Die CoordinatedShutdown-API-Referenz deckt addTask, addPhase, run und den vollständigen Phasen-Konstanten-Set ab.