콘텐츠로 이동
한국어

Death watch

이 콘텐츠는 아직 번역되지 않았습니다.

Supervision catches failure — what to do when a child actor’s onReceive throws. Death watch catches termination — knowing that some other actor has stopped, whatever the reason (clean stop, crash beyond restart limits, parent terminated).

The two mechanisms are different on purpose: a child failing is its parent’s problem to handle; a sibling stopping is a notification event that any other actor can subscribe to.

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 logs "watched actor stopped: ..."

Three things going on:

  1. context.watch(ref) registers the watcher’s interest. No message goes to the watched actor — it doesn’t know it’s being observed.
  2. When the watched actor terminates (for any reason), the framework delivers a Terminated message to every watcher.
  3. The watcher’s onReceive handles Terminated like any other message. Because it arrives via the mailbox, ordering with user messages is well-defined: any tells sent before the watched actor stopped are processed in order, then the Terminated follows.
class Terminated {
constructor(
public readonly actor: ActorRef,
public readonly existenceConfirmed: boolean = true,
public readonly addressTerminated: boolean = false,
) {}
}

Three fields:

FieldMeaning
actorThe ref that stopped. Same instance you passed to watch.
existenceConfirmedWhether the framework saw this actor exist before its termination. Currently always true — every Terminated the framework delivers uses the constructor default.
addressTerminatedReserved for the cluster case (an entire node going unreachable, not just this actor). Currently always false — the framework does not yet set it.

Watching an actor that’s already stopped delivers Terminated immediately — your watcher always gets a notification, no matter when you registered. That notification carries the default existenceConfirmed = true like every other; the current implementation never sets it false, so don’t branch on it as an error signal.

The addressTerminated flag is reserved for cluster setups where an entire node — not just one actor — goes away. It is currently always false: the framework does not yet raise node-level Terminated notifications. See Cluster for the membership story.

watch always delivers the same thing. That is enough when one death matters, but a watcher that observes several kinds of actor — a pool of workers, a database connection, a cluster peer — gets one message type for all of them and has to work out from Terminated.actor which relationship just ended. The signal also has to live in the watcher’s message union, where it says nothing about what the actor is for.

watchWith moves that decision to registration time. You say what a death means when you start watching, and the watcher receives a message of its own protocol:

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 does not appear in PoolMessage at all. The union stays a description of what this actor does, match(...).exhaustive() still covers it, and the two deaths that mean completely different things are handled in two different places.

CallEffect
watchWith(ref, message)On ref’s termination, message is delivered to this actor instead of Terminated(ref).
watchWith(ref, other) againReplaces the message — last call wins.
watch(ref) after a watchWith(ref, …)Drops the custom message; the death delivers Terminated(ref) again.
unwatch(ref)Removes the registration, whichever of the two made it.

Everything else is unchanged: the message arrives through the mailbox with the same ordering guarantees, watching an already-stopped ref delivers immediately, and a watcher that stops has its registrations cleaned up for it.

The registration is consumed by the death it describes. A name that is re-spawned is a different actor as far as death watch is concerned — the bookkeeping is keyed by incarnation, not by path — so a replacement child needs its own watchWith, which is why hire() above registers every time it spawns. That is deliberate: it is what stops a pending notification for the previous incarnation from being delivered against its successor.

watchWith records message in the watcher. The watched actor is not involved — it still does not know it is being observed, and the message is neither sent to it nor routed through it. So there is no serializer to register and nothing new on the wire: the substitution happens in the watcher’s own cell, at the moment the death is dispatched to its handler.

TypedActorContext has the same method:

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 deliberately bypasses onSignal. A plain watch delivers a { kind: 'terminated' } Signal, which Behaviors.receiveWithSignal routes to its signal handler; a watchWith message is a value of T and goes to the ordinary receive handler even when a signal handler is registered. Registering one does not quietly reroute the message you asked for.

context.unwatch(ref);

Stop receiving termination notifications for this ref — whether the registration came from watch or from watchWith. Idempotent — calling unwatch on a ref you weren’t watching is a no-op.

When an actor stops, its watch registrations are cleaned up automatically; you don’t have to unwatch everything before stopping. Use unwatch only when an actor needs to change its interest mid-flight (“I no longer care about this child”).

A worker that has no purpose without a particular dependency should stop itself when that dependency does:

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;
}
// ... handle Message
}
}

The supervision tree handles failures inside the actor; death watch handles “the actor I depend on disappeared for some other reason” (parent stop, manual stop from outside, cluster eviction).

A manager actor that spawns N children and wants to react when all of them have stopped:

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();
}
}
// ... handle Command
}
}

This is a common shape for graceful shutdown: a coordinator watches the actors it’s responsible for, and only exits once every one of them is gone. The Coordinated shutdown DSL formalizes this pattern at the system level.

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 works the same way across nodes — the framework propagates termination notifications through the cluster transport when the watched actor stops. Node-level unreachability does not yet deliver Terminated (the addressTerminated flag is always false). See Refs across nodes for the wire protocol that makes this work.

The two mechanisms cover different cases:

  • Use supervision when the actor handling failures is the parent of the actor that fails. Restart/Resume/Stop/Escalate per error class is what supervision is for.
  • Use death watch when the observer is not the parent — a sibling that needs to know when a dependency goes away, a cross-tree manager watching workers it spawned, an HTTP handler noticing that the backend actor died.

Many actors use both: supervise their own children, and watch the actors they depend on (which are someone else’s children).

  • Supervision — the parent-handles-child-failure mechanism. Death watch is the observer-handles-actor-stop mechanism — distinct, often used together.
  • Poison pill and Kill — two ways to terminate an actor. Poison pill stops it (a Terminated follows); Kill raises an error through supervision, so under the default restart strategy the actor restarts and no Terminated fires unless the strategy resolves to Stop.
  • Coordinated shutdown — uses watch internally to wait for whole subsystems to drain.
  • Refs across nodes — how Terminated propagates when the watched actor lives on a different node.

The ActorContext.watch and Terminated API references cover the full signatures.