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.
A minimal example
Section titled “A minimal example”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:
context.watch(ref)registers the watcher’s interest. No message goes to the watched actor — it doesn’t know it’s being observed.- When the watched actor terminates (for any reason), the
framework delivers a
Terminatedmessage to every watcher. - The watcher’s
onReceivehandlesTerminatedlike any other message. Because it arrives via the mailbox, ordering with user messages is well-defined: anytells sent before the watched actor stopped are processed in order, then theTerminatedfollows.
The Terminated message
Section titled “The Terminated message”class Terminated { constructor( public readonly actor: ActorRef, public readonly existenceConfirmed: boolean = true, public readonly addressTerminated: boolean = false, ) {}}Three fields:
| Field | Meaning |
|---|---|
actor | The ref that stopped. Same instance you passed to watch. |
existenceConfirmed | Whether the framework saw this actor exist before its termination. Currently always true — every Terminated the framework delivers uses the constructor default. |
addressTerminated | Reserved 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.
Custom termination messages — watchWith
Section titled “Custom termination messages — watchWith”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.
The rules
Section titled “The rules”| Call | Effect |
|---|---|
watchWith(ref, message) | On ref’s termination, message is delivered to this actor instead of Terminated(ref). |
watchWith(ref, other) again | Replaces 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.
One registration, one death
Section titled “One registration, one death”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.
The message never travels
Section titled “The message never travels”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.
With typed Behaviors
Section titled “With typed Behaviors”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.
unwatch
Section titled “unwatch”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”).
Common patterns
Section titled “Common patterns”Auto-stop on dependency loss
Section titled “Auto-stop on dependency loss”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).
Cleanup coordination
Section titled “Cleanup coordination”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.
Watching across the cluster
Section titled “Watching across the cluster”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.
What it does NOT replace
Section titled “What it does NOT replace”Watch + supervision — when to use which
Section titled “Watch + supervision — when to use which”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).
Where to next
Section titled “Where to next”- 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
Terminatedfollows);Killraises an error through supervision, so under the default restart strategy the actor restarts and noTerminatedfires unless the strategy resolves to Stop. - Coordinated shutdown — uses watch internally to wait for whole subsystems to drain.
- Refs across nodes —
how
Terminatedpropagates when the watched actor lives on a different node.
The ActorContext.watch
and Terminated API references
cover the full signatures.
