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