Death Watch
Supervision fängt Fehler ab — was zu tun ist, wenn das onReceive
eines Child-Actors einen Throw wirft. Death Watch fängt
Termination ab — zu wissen, dass irgendein anderer Actor gestoppt
hat, aus welchem Grund auch immer (sauberer Stop, Crash jenseits der
Restart-Limits, Parent terminiert).
Die beiden Mechanismen sind absichtlich verschieden: Ein fehlschlagendes Kind ist das Problem seines Parents; ein stoppendes Geschwister ist ein Notification-Event, das jeder andere Actor abonnieren kann.
Ein minimales Beispiel
Abschnitt betitelt „Ein minimales Beispiel“import { match, P } from 'ts-pattern';import { Actor, ActorSystem, Terminated, type ActorRef } from 'actor-ts';
type WatchCommand = { kind: 'watch'; ref: ActorRef };type WatcherMessage = Terminated | WatchCommand;
class Watcher extends Actor<WatcherMessage> { override onReceive(message: WatcherMessage): void { match(message) .with(P.instanceOf(Terminated), (t) => this.onTerminated(t)) .with({ kind: 'watch' }, (c) => this.onWatch(c)) .exhaustive(); }
private onTerminated(signal: Terminated): void { this.log.info(`watched actor stopped: ${signal.actor.path}`); }
private onWatch(command: WatchCommand): void { this.context.watch(command.ref); }}
const system = ActorSystem.create('demo');
const observed = system.spawnAnonymous(SomeActor);const watcher = system.spawnAnonymous(Watcher);
watcher.tell({ kind: 'watch', ref: observed });observed.stop(); // → Watcher loggt "watched actor stopped: ..."Drei Dinge passieren:
context.watch(ref)registriert das Interesse des Watchers. Keine Nachricht geht an den beobachteten Actor — er weiß nicht, dass er beobachtet wird.- Wenn der beobachtete Actor terminiert (aus welchem Grund auch
immer), liefert das Framework jedem Watcher eine
Terminated-Nachricht. - Das
onReceivedes Watchers behandeltTerminatedwie jede andere Nachricht. Weil sie über die Mailbox ankommt, ist die Ordnung gegenüber User-Nachrichten wohldefiniert: alletells, die vor dem Stop des beobachteten Actors gesendet wurden, werden in Reihenfolge verarbeitet, danach folgt dasTerminated.
„Wie jede andere Nachricht” bezieht sich auf die Reihenfolge — und nur
darauf. Ein Tod wird einmal angekündigt, das Framework kann ihn
nicht erneut senden. Deshalb ist die Benachrichtigung von allem
ausgenommen, was User-Nachrichten beim Einreihen verwirft: von einer
Mailbox-Schranke jeder Policy und von
einem throttle({ onExcess: 'drop' }). Einen Watcher zu beschränken
kostet ihn Backlog, niemals die Tode, auf die er wartet. Und wenn die
Benachrichtigung wirklich nicht zugestellt werden kann — der Watcher
ist selbst schon gestoppt, oder seine Mailbox verweigert die Nachricht
— wird sie zum Dead Letter, damit der
Verlust sichtbar statt stillschweigend ist.
Die Terminated-Nachricht
Abschnitt betitelt „Die Terminated-Nachricht“class Terminated { constructor( public readonly actor: ActorRef, public readonly existenceConfirmed: boolean = true, public readonly addressTerminated: boolean = false, ) {}}Drei Felder:
| Feld | Bedeutung |
|---|---|
actor | Die Ref, die gestoppt hat. Dieselbe Instanz, die du an watch übergeben hast. |
existenceConfirmed | Ob das Framework diesen Actor vor seiner Termination existieren sah. Aktuell immer true — jedes vom Framework gelieferte Terminated verwendet den Konstruktor-Default. |
addressTerminated | Reserviert für den Cluster-Fall (ein gesamter Node wird unerreichbar, nicht nur dieser Actor). Aktuell immer false — das Framework setzt es noch nicht. |
Einen bereits gestoppten Actor zu beobachten, liefert Terminated
sofort — dein Watcher bekommt immer eine Notification, egal wann
du registriert hast. Diese Notification trägt wie jede andere den
Default existenceConfirmed = true; die aktuelle Implementierung
setzt es nie auf false, also verlasse dich nicht darauf als
Fehlersignal.
Das addressTerminated-Flag ist für Cluster-Setups reserviert, in
denen ein ganzer Node — nicht nur ein Actor — verschwindet. Es ist
aktuell immer false: das Framework löst noch keine Node-weiten
Terminated-Notifications aus. Siehe
Cluster für die Membership-Geschichte.
Nur die Runtime kann einen Tod verkünden
Abschnitt betitelt „Nur die Runtime kann einen Tod verkünden“Terminated ist eine ganz normale exportierte Klasse, dich hält also
nichts davon ab, selbst eine zu konstruieren — aber eine zu
konstruieren ist nicht dasselbe, wie einen Tod zu verursachen. Ein
Terminated, das die Runtime nicht ausgelöst hat, wird abgelehnt:
die empfangende Zelle verbraucht es, schickt es mit angehängtem Sender
an die Dead Letters und lässt die
Watch-Registrierung genau so, wie sie war.
Der Grund: die Notification trägt eine Ref und sonst nichts, „dieser Actor ist tot” ist also eine Behauptung und kein Beweis — und eine unbegründete zu befolgen kostet den Watcher doppelt. Er handelt auf einen Tod hin, der nicht stattgefunden hat, und die Registrierung geht mit, sodass die echte Notification später auf eine Watch trifft, die es nicht mehr gibt, und als unbeobachtet verworfen wird. Eine einzige gefälschte Nachricht würde einen Watcher für den Rest seines Lebens blind für dieses Subjekt machen.
Zwei Konsequenzen, die man kennen sollte:
- Leite ein
Terminatednicht an einen anderen Watcher weiter. Es zählt für ihn nicht — und es hat nie etwas gebracht: ein Actor, der das Subjekt beobachtet, bekommt seine eigene Notification ohnehin, und einer, der es nicht tut, sollte nie davon erfahren. Schick ihm stattdessen eine eigene Domain-Nachricht. - Ein selbst gebautes
Terminatedtestet in einem Test kein Death Watch. Stoppe den Actor wirklich; die Notification, die dann folgt, ist die, die dein Handler in Produktion sieht.
watchWith-Ersetzungen fallen unter dieselbe Regel. Die ersetzte
Nachricht liegt in der Zelle des Watchers selbst und wird vom selben
Gate freigegeben, sodass ein gefälschtes Signal sie nicht still
verbrauchen und den nächsten echten Tod zu einem Terminated
herabstufen kann, für das der Watcher keinen Zweig hat.
Eigene Termination-Nachrichten — watchWith
Abschnitt betitelt „Eigene Termination-Nachrichten — watchWith“watch liefert immer dasselbe. Das reicht, wenn genau ein Tod
zählt, aber ein Watcher, der mehrere Arten von Actor beobachtet —
einen Pool von Workern, eine Datenbankverbindung, einen Cluster-Peer —
bekommt für alle denselben Nachrichtentyp und muss aus
Terminated.actor herleiten, welche Beziehung gerade geendet hat.
Außerdem muss das Signal in der Nachrichten-Union des Watchers stehen,
wo es nichts darüber aussagt, wofür der Actor da ist.
watchWith verlegt diese Entscheidung auf den Zeitpunkt der
Registrierung. Du sagst beim Beginn des Beobachtens, was ein Tod
bedeutet, und der Watcher bekommt eine Nachricht aus seinem eigenen
Protokoll:
import { match } from 'ts-pattern';import { Actor, type ActorRef } from 'actor-ts';
type StartCommand = { kind: 'start' };type WorkerLostMessage = { kind: 'workerLost'; name: string };type DatabaseLostMessage = { kind: 'databaseLost' };type PoolMessage = StartCommand | WorkerLostMessage | DatabaseLostMessage;
class Pool extends Actor<PoolMessage> { constructor(private readonly database: ActorRef) { super(); }
override preStart(): void { this.context.watchWith(this.database, { kind: 'databaseLost' }); }
override onReceive(message: PoolMessage): void { match(message) .with({ kind: 'start' }, () => this.onStart()) .with({ kind: 'workerLost' }, (m) => this.onWorkerLost(m)) .with({ kind: 'databaseLost' }, () => this.onDatabaseLost()) .exhaustive(); }
private onStart(): void { for (let i = 0; i < 4; i++) this.hire(`worker-${i}`); }
private onWorkerLost(message: WorkerLostMessage): void { this.log.warn(`${message.name} died — respawning`); this.hire(message.name); }
private onDatabaseLost(): void { this.log.warn('database gone — winding down'); this.context.stopSelf(); }
private hire(name: string): void { const worker = this.context.spawn(Worker, name); this.context.watchWith(worker, { kind: 'workerLost', name }); }}Terminated taucht in PoolMessage überhaupt nicht auf. Die Union
bleibt eine Beschreibung dessen, was dieser Actor tut,
match(...).exhaustive() deckt sie weiterhin ab, und die beiden
Todesfälle, die völlig Verschiedenes bedeuten, werden an zwei
verschiedenen Stellen behandelt.
Die Regeln
Abschnitt betitelt „Die Regeln“| Aufruf | Wirkung |
|---|---|
watchWith(ref, message) | Bei der Termination von ref wird message an diesen Actor geliefert statt Terminated(ref). |
Erneutes watchWith(ref, other) | Ersetzt die Nachricht — der letzte Aufruf gewinnt. |
watch(ref) nach einem watchWith(ref, …) | Verwirft die eigene Nachricht; der Tod liefert wieder Terminated(ref). |
unwatch(ref) | Entfernt die Registrierung, egal welcher der beiden sie angelegt hat. |
Alles andere bleibt unverändert: die Nachricht kommt über die Mailbox
mit denselben Ordnungsgarantien an, eine bereits gestoppte Ref zu
beobachten liefert sofort, und ein Watcher, der stoppt, bekommt seine
Registrierungen automatisch aufgeräumt. Deine Domain-Nachricht erbt
die Ausnahme des Terminated, das sie ersetzt — auch sie kann von
einer Schranke oder einem Throttle nicht verworfen werden, denn sie ist
weiterhin die Ankündigung eines Todes, der einmal passiert.
Eine Registrierung, ein Tod
Abschnitt betitelt „Eine Registrierung, ein Tod“Die Registrierung wird von dem Tod verbraucht, den sie beschreibt.
Ein neu gespawnter Name ist für Death Watch ein anderer Actor — der
Schlüssel der Buchführung ist die Inkarnation, nicht der Pfad — also
braucht ein Ersatz-Kind sein eigenes watchWith, weshalb hire()
oben bei jedem Spawn erneut registriert. Das ist Absicht: genau das
verhindert, dass eine noch offene Notification der vorherigen
Inkarnation gegen ihren Nachfolger zugestellt wird.
Die Nachricht reist nicht
Abschnitt betitelt „Die Nachricht reist nicht“watchWith merkt sich message im Watcher. Der beobachtete
Actor ist nicht beteiligt — er weiß weiterhin nicht, dass er
beobachtet wird, und die Nachricht wird weder an ihn gesendet noch
über ihn geleitet. Es gibt also keinen Serializer zu registrieren und
nichts Neues auf der Leitung: die Ersetzung passiert in der Zelle des
Watchers selbst, in dem Moment, in dem der Tod an seinen Handler
zugestellt wird.
Mit typed Behaviors
Abschnitt betitelt „Mit typed Behaviors“TypedActorContext hat dieselbe Methode:
const pool = Behaviors.setup<PoolMessage>((context) => { const worker = context.spawn(workerBehavior, 'worker-0'); context.watchWith(worker, { kind: 'workerLost', name: 'worker-0' }); return Behaviors.receiveMessage<PoolMessage>((message) => { // ... 'workerLost' arrives here, like any other message return Behaviors.same; });});watchWith umgeht onSignal bewusst. Ein einfaches watch liefert
ein { kind: 'terminated' }-Signal, das
Behaviors.receiveWithSignal an seinen Signal-Handler routet; eine
watchWith-Nachricht ist ein Wert von T und geht an den normalen
Receive-Handler, auch wenn ein Signal-Handler registriert ist. Einen
zu registrieren leitet die Nachricht, die du angefordert hast, nicht
klammheimlich um.
“Wenn ein Signal-Handler registriert ist” gilt pro Behavior, nicht pro
Actor. Ein Signal-Handler gehört zu dem receiveWithSignal, das ihn
deklariert hat — ein Zustand, der ein einfaches Behaviors.receive
ist, hat also keinen, und der Tod aus einem einfachen watch erreicht
ihn als Terminated-Nachricht, genau wie bei einem Actor, der
überhaupt nie einen Handler registriert hat. Will ein späterer Zustand
den Tod weiterhin als Signal, deklariert er sein eigenes onSignal;
will er ihn als Nachricht, sagt watchWith das explizit und gilt in
jedem Zustand.
unwatch
Abschnitt betitelt „unwatch“context.unwatch(ref);Höre auf, Termination-Notifications für diese Ref zu empfangen — egal
ob die Registrierung von watch oder von watchWith stammt.
Idempotent — unwatch auf einer Ref aufzurufen, die du nicht
beobachtet hast, ist ein No-Op.
Wenn ein Actor stoppt, werden seine Watch-Registrierungen automatisch
aufgeräumt; du musst nicht alles unwatchen, bevor du stoppst.
Verwende unwatch nur, wenn ein Actor sein Interesse mid-flight
ändern muss (“dieses Kind interessiert mich nicht mehr”).
Häufige Muster
Abschnitt betitelt „Häufige Muster“Auto-Stop bei Verlust einer Abhängigkeit
Abschnitt betitelt „Auto-Stop bei Verlust einer Abhängigkeit“Ein Worker, der ohne eine bestimmte Abhängigkeit keinen Zweck hat, sollte sich selbst stoppen, wenn diese Abhängigkeit es tut:
class Worker extends Actor<Message | Terminated> { constructor(private readonly db: ActorRef) { super(); } override preStart(): void { this.context.watch(this.db); } override onReceive(message: Message | Terminated): void { if (message instanceof Terminated && message.actor === this.db) { this.log.warn('DB stopped — winding down'); this.context.stopSelf(); return; } // ... handhabe Message }}Der Supervisionsbaum handhabt Fehler innerhalb des Actors; Death Watch handhabt “der Actor, von dem ich abhänge, ist aus irgendeinem anderen Grund verschwunden” (Parent-Stop, manueller Stop von außen, Cluster-Eviction).
Cleanup-Koordination
Abschnitt betitelt „Cleanup-Koordination“Ein Manager-Actor, der N Kinder spawnt und reagieren will, wenn alle gestoppt haben:
class Manager extends Actor<Command | Terminated> { private alive = new Set<string>();
override preStart(): void { for (let i = 0; i < 4; i++) { const child = this.context.spawn(Worker, `worker-${i}`); this.alive.add(child.path.name); this.context.watch(child); } }
override onReceive(message: Command | Terminated): void { if (message instanceof Terminated) { this.alive.delete(message.actor.path.name); if (this.alive.size === 0) { this.log.info('all workers stopped — manager exiting'); this.context.stopSelf(); } } // ... handhabe Command }}Das ist eine häufige Form für sauberen Shutdown: ein Koordinator beobachtet die Actors, für die er verantwortlich ist, und beendet sich erst, wenn jeder einzelne weg ist. Die Coordinated Shutdown-DSL formalisiert dieses Pattern auf System-Ebene.
Watchen über den Cluster hinweg
Abschnitt betitelt „Watchen über den Cluster hinweg“const remoteEntity = await this.system.actorSelection( 'actor-ts://my-app@10.0.0.5:2552/system/cluster/sharding/region-Entity/entity-12345',).resolveOne(1_000);
this.context.watch(remoteEntity);Watch funktioniert über Nodes hinweg auf dieselbe Weise — das
Framework propagiert Termination-Notifications über den
Cluster-Transport, wenn der beobachtete Actor stoppt. Node-weite
Unerreichbarkeit liefert noch kein Terminated (das
addressTerminated-Flag ist immer false). Siehe
Refs zwischen Nodes für das
Wire-Protokoll, das das möglich macht.
Was es NICHT ersetzt
Abschnitt betitelt „Was es NICHT ersetzt“Ein Terminated ignorieren
Abschnitt betitelt „Ein Terminated ignorieren“Ein Handler, der die Notification entgegennimmt und nichts damit tut,
ist ein legitimer Endzustand: der Actor läuft weiter, nichts wird
geloggt, nichts schlägt fehl. Das Framework könnte gar nicht
reagieren, selbst wenn es wollte — onReceive gibt void zurück, die
Zelle, die den Tod dispatcht hat, kann ein ignoriertes Terminated
also nicht von einem behandelten unterscheiden.
“Ignoriert” heißt allerdings der Handler lief und tat nichts. Ein Tod, den der Handler nie erreicht, ist eine andere Geschichte — und die APIs beantworten sie unterschiedlich:
| Watcher | Ein Terminated, auf das der Handler nicht reagiert |
|---|---|
Actor.onReceive ohne Branch dafür | Nichts passiert; der Actor läuft weiter. |
match(message) … .exhaustive() ohne Arm dafür | ts-pattern wirft einen Fehler. Das ist ein gewöhnlicher Fehler, geht also durch die Supervision — unter der Default-Strategie restartet der Watcher. |
Behaviors.receiveWithSignal, dessen Signal-Handler Behaviors.unhandled antwortet | Nichts passiert. |
Ein typed Behavior ohne Signal-Handler, dessen Receive Behaviors.unhandled antwortet | Das Terminated geht zu Dead Letters. |
Die zweite Zeile ist die, über die man stolpert: .exhaustive() ist
das Idiom, das diese Seite überall verwendet, und es macht aus “dieser
Tod betrifft mich nicht” eine Restart-Schleife, solange kein Arm ihn
abdeckt. Ergänze den Arm — oder watche gar nicht erst.
DeathPactError ist deiner zum Werfen
Abschnitt betitelt „DeathPactError ist deiner zum Werfen“Manche Watcher können wirklich nicht weitermachen — eine Session, deren
Connection gestorben ist, ein Saga-Schritt, dessen Worker verschwunden
ist. Genau dafür ist DeathPactError exportiert, und die Runtime
wirft ihn nie selbst: einen automatischen Death Pact gibt es in
actor-ts nicht. Du wirfst ihn selbst, aus deinem eigenen Handler, und
er läuft durch die Supervision wie jeder andere Fehler:
import { match, P } from 'ts-pattern';import { Actor, ActorOptions, DeathPactError, decideBy, Directive, OneForOneStrategy, Terminated, type ActorRef,} from 'actor-ts';
type QueryCommand = { kind: 'query'; sql: string };type SessionMessage = QueryCommand | Terminated;
class Session extends Actor<SessionMessage> { constructor(private readonly connection: ActorRef) { super(); }
override preStart(): void { this.context.watch(this.connection); }
override onReceive(message: SessionMessage): void { match(message) .with(P.instanceOf(Terminated), (t) => this.onTerminated(t)) .with({ kind: 'query' }, (c) => this.onQuery(c)) .exhaustive(); }
/** No connection, no session — break the pact and let the parent decide. */ private onTerminated(signal: Terminated): void { throw new DeathPactError(signal.actor.path.toString()); }
private onQuery(command: QueryCommand): void { // ... run it against the connection }}
// A restart is the wrong answer for a broken pact: the dependency is gone,// so a fresh instance fails the same way. Say so, or the default strategy// will restart it ten times a minute.const pactStrategy = new OneForOneStrategy( decideBy([{ match: DeathPactError, then: Directive.Stop }]),);const sessionOptions = ActorOptions.create().withSupervisorStrategy(pactStrategy);
const session = system.spawn(() => new Session(connection), 'session-42', sessionOptions);actorPath trägt den Pfad des Actors, dessen Tod den Pakt gebrochen
hat — ein Watcher, der von mehreren Actors abhängt, kann also allein am
Fehler ablesen, welche Abhängigkeit er verloren hat.
Dass der Wurf bei dir liegt, ist der Punkt und keine Lücke. Ein Death
Pact ist eine Policy über diesen Watcher — zwei Actors, die denselben
Worker beobachten, wollen durchaus unterschiedliche Antworten — und das
Framework kann sie ihnen nicht abnehmen: onReceive gibt ihm kein
Signal, mit dem es einen Handler, der den Tod ignoriert hat, von einem
unterscheiden könnte, der ihn still erledigt hat.
Watch + Supervision — wann welches verwenden
Abschnitt betitelt „Watch + Supervision — wann welches verwenden“Die beiden Mechanismen decken verschiedene Fälle ab:
- Verwende Supervision, wenn der Actor, der Fehler behandelt, der Parent des fehlschlagenden Actors ist. Restart/Resume/Stop/Escalate pro Fehlerklasse ist, wofür Supervision da ist.
- Verwende Death Watch, wenn der Beobachter nicht der Parent ist — ein Geschwister, das wissen muss, wann eine Abhängigkeit verschwindet, ein Cross-Tree-Manager, der von ihm gespawnte Workers beobachtet, ein HTTP-Handler, der bemerkt, dass der Backend-Actor gestorben ist.
Viele Actors verwenden beides: supervisen ihre eigenen Kinder und beobachten die Actors, von denen sie abhängen (die jemand anderes Kinder sind).
Wie es weitergeht
Abschnitt betitelt „Wie es weitergeht“- Supervision — der Parent-handhabt-Kind-Fehler-Mechanismus. Death Watch ist der Beobachter-handhabt-Actor-Stop-Mechanismus — verschieden, oft zusammen verwendet.
- Poison Pill und Kill —
zwei Wege, einen Actor zu terminieren. Poison Pill stoppt ihn (ein
Terminatedfolgt);Killwirft einen Fehler durch die Supervision, sodass der Actor unter der Default-Restart-Strategie neu startet und keinTerminatedfeuert, außer die Strategie löst zu Stop auf. - Coordinated Shutdown — verwendet Watch intern, um auf das Drainen ganzer Subsysteme zu warten.
- Refs zwischen Nodes —
wie
Terminatedpropagiert, wenn der beobachtete Actor auf einem anderen Node lebt.
Die ActorContext.watch- und
Terminated-API-Referenzen decken die
vollen Signaturen ab.
