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.
“Like any other message” covers ordering, and only ordering. A death
is announced once — the framework has no way to send it again — so
the notification is exempt from everything that discards user messages
on the way in: a mailbox bound of any
policy, and a throttle({ onExcess: 'drop' }). Bounding a watcher
costs it backlog, never the deaths it is watching for. And if the
notification genuinely cannot be delivered — the watcher itself has
already stopped, or its mailbox refuses the message — it becomes a
dead letter, so the loss is visible
rather than silent.
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.
Only the runtime can announce a death
Section titled “Only the runtime can announce a death”Terminated is an ordinary exported class, so nothing stops you
constructing one — but constructing one is not the same as causing one.
A Terminated the runtime did not emit is refused: the receiving
cell consumes it, sends it to
dead letters with the sender attached, and
leaves the watch registration exactly as it was.
The reason is that the notification carries a ref and nothing else, so “that actor is dead” is a claim rather than a proof — and honouring an unfounded one costs the watcher twice. It acts on a death that has not happened, and the registration goes with it, so the genuine notification arrives later against a watch that is no longer there and is dropped as unwatched. One fabricated message would blind a watcher to that subject for the rest of its life.
Two consequences worth knowing:
- Do not forward a
Terminatedto another watcher. It will not count for them — and it never bought anything: an actor that watches the subject already gets its own notification, and one that does not was never meant to hear about it. Send a domain message of your own instead. - A hand-built
Terminateddoes not exercise death watch in a test. Stop the actor for real; the notification that follows is the one your handler will see in production.
watchWith substitutions are covered by the same rule. The substituted
message lives in the watcher’s own cell and is released by the same
gate, so a fabricated signal cannot quietly consume it and downgrade the
next real death to a Terminated the watcher has no arm for.
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. Your domain message inherits the exemption the
Terminated it replaces has — a bound or a throttle cannot discard it
either, because it is still the announcement of a death that happens
once.
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.
“When a signal handler is registered” is per behavior, not per actor.
A signal handler belongs to the receiveWithSignal that declared it,
so a state that is a plain Behaviors.receive has none — and a plain
watch’s death arrives there as a Terminated message, exactly as
it does for an actor that never registered a handler at all. If a
later state still wants the death as a signal, it declares its own
onSignal; if it wants it as a message, watchWith says so
explicitly and holds in every state.
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”Ignoring a Terminated
Section titled “Ignoring a Terminated”A handler that takes the notification and does nothing with it is a
legitimate end state: the actor keeps running, nothing is logged,
nothing fails. The framework could not react even if it wanted to —
onReceive returns void, so the cell that dispatched the death has
no way to tell an ignored Terminated from a handled one.
“Ignored” means the handler ran and did nothing, though. A death the handler never reaches is a different story, and the APIs answer it differently:
| Watcher | A Terminated the handler does not act on |
|---|---|
Actor.onReceive with no branch for it | Nothing happens; the actor keeps running. |
match(message) … .exhaustive() with no arm for it | ts-pattern throws. That is an ordinary failure, so it goes through supervision — under the default strategy the watcher restarts. |
Behaviors.receiveWithSignal whose signal handler answers Behaviors.unhandled | Nothing happens. |
A typed behavior with no signal handler, whose receive answers Behaviors.unhandled | The Terminated goes to dead letters. |
The second row is the one that catches people out: .exhaustive() is
the idiom this page uses everywhere, and it turns “this death does not
concern me” into a restart loop unless an arm covers it. Add the arm,
or don’t watch in the first place.
DeathPactError is yours to throw
Section titled “DeathPactError is yours to throw”Some watchers genuinely cannot carry on — a session whose connection
died, a saga step whose worker vanished. DeathPactError is exported
for exactly that, and the runtime never raises it: there is no
automatic death pact in actor-ts. You throw it yourself, from your own
handler, and it travels through supervision like any other failure:
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 carries the path of the actor whose death broke the pact,
so a watcher that depends on several actors can tell from the error
alone which dependency it lost.
Leaving the throw to you is the point rather than a gap. A death pact
is a policy about this watcher — two actors watching the same worker
may well want different answers — and the framework cannot pick one for
them: onReceive gives it no signal to distinguish a handler that
ignored the death from one that dealt with it quietly.
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.
