Zum Inhalt springen
Deutsch

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.

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:

  1. context.watch(ref) registriert das Interesse des Watchers. Keine Nachricht geht an den beobachteten Actor — er weiß nicht, dass er beobachtet wird.
  2. Wenn der beobachtete Actor terminiert (aus welchem Grund auch immer), liefert das Framework jedem Watcher eine Terminated-Nachricht.
  3. Das onReceive des Watchers behandelt Terminated wie jede andere Nachricht. Weil sie über die Mailbox ankommt, ist die Ordnung gegenüber User-Nachrichten wohldefiniert: alle tells, die vor dem Stop des beobachteten Actors gesendet wurden, werden in Reihenfolge verarbeitet, danach folgt das Terminated.
class Terminated {
constructor(
public readonly actor: ActorRef,
public readonly existenceConfirmed: boolean = true,
public readonly addressTerminated: boolean = false,
) {}
}

Drei Felder:

FeldBedeutung
actorDie Ref, die gestoppt hat. Dieselbe Instanz, die du an watch übergeben hast.
existenceConfirmedOb das Framework diesen Actor vor seiner Termination existieren sah. Aktuell immer true — jedes vom Framework gelieferte Terminated verwendet den Konstruktor-Default.
addressTerminatedReserviert 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.

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.

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

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.

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.

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.

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

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

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.

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.

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

  • 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 Terminated folgt); Kill wirft einen Fehler durch die Supervision, sodass der Actor unter der Default-Restart-Strategie neu startet und kein Terminated feuert, 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 Terminated propagiert, wenn der beobachtete Actor auf einem anderen Node lebt.

Die ActorContext.watch- und Terminated-API-Referenzen decken die vollen Signaturen ab.