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.

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

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.

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 Terminated nicht 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 Terminated testet 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.

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

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.

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

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.

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:

WatcherEin Terminated, auf das der Handler nicht reagiert
Actor.onReceive ohne Branch dafürNichts passiert; der Actor läuft weiter.
match(message) … .exhaustive() ohne Arm dafürts-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 antwortetNichts passiert.
Ein typed Behavior ohne Signal-Handler, dessen Receive Behaviors.unhandled antwortetDas 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.

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.

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.