Skip to content

[Feature] DeathWatch.watchWith — custom termination message #159

Description

@pathosDev

Size / Priority

Rationale

Today's context.watch(ref) delivers a Terminated(ref) message to the watcher when ref stops. That's fine for "did THAT one die?" but doesn't compose with the watcher's normal message protocol — the watcher's onReceive must learn about Terminated as a special signal type, and if the watcher watches many actors of different roles ("workers" vs "DB connection" vs "cluster peer"), the Terminated.actor reference is the only way to disambiguate.

Akka's watchWith(ref, customMsg) lets the caller specify any message to deliver on termination. The watcher's protocol stays clean:

// without watchWith — message type union must include Terminated
case Terminated(child) =>
  workers.find(_.ref == child) match { ... }
  dbConnections.find(_.ref == child) match { ... }

// with watchWith — termination is a domain event
context.watchWith(worker, WorkerDied(worker.name))
context.watchWith(dbConn, DbConnectionLost(dbConn.endpoint))
// receive:
case WorkerDied(name) => respawnWorker(name)
case DbConnectionLost(ep) => reconnect(ep)

The implementation is small — context.watch already exists (src/internal/ActorCell.ts:158-166). watchWith is an overload that records the custom message and delivers it instead of Terminated on the same death signal.

Reference: what Akka does

class Supervisor extends Actor {
  case class WorkerDied(name: String)

  context.watchWith(worker, WorkerDied("alice"))

  def receive = {
    case WorkerDied(name) => respawn(name)
  }
}

watchWithwatch overlay:

  • watchWith(ref, msg): on ref termination, deliver msg to self.
  • watch(ref): on ref termination, deliver Terminated(ref) to self.
  • Replacing watchWith with a different message for the same ref is allowed (last call wins).
  • unwatch(ref) removes any watch (with or without custom msg).

Design sketch — actor-ts equivalent

// src/ActorContext.ts (interface) — add overload

export interface ActorContext<TMsg = unknown> {
  // ... existing ...

  /** Watch `ref`; on termination, deliver `Terminated(ref)` to this actor (existing). */
  watch(ref: ActorRef): ActorRef;

  /**
   * Watch `ref`; on termination, deliver `customMsg` to this actor instead of
   * `Terminated(ref)`.  Useful for domain-typed termination signals.
   *
   * Replaces any previous watch (with or without custom msg) of the same ref.
   */
  watchWith<M extends TMsg>(ref: ActorRef, customMsg: M): ActorRef;

  /** Stop watching `ref` (whether watch or watchWith was used). */
  unwatch(ref: ActorRef): ActorRef;
}
// src/internal/ActorCell.ts — extend the watching machinery

class ActorCell<TMsg> {
  // Existing field: this._watching: Map<string, ActorRef>
  // NEW field: per-ref custom termination message
  private _watchWithMessages = new Map<string, TMsg>();

  watch(ref: ActorRef): ActorRef {
    const key = ref.path.toString();
    if (this._watching.has(key)) {
      // Was watchWith → reset to plain watch
      this._watchWithMessages.delete(key);
      return ref;
    }
    this._watching.set(key, ref);
    if (ref instanceof LocalActorRef) {
      ref.getCell()._addWatcher(this.self);
    }
    return ref;
  }

  watchWith<M extends TMsg>(ref: ActorRef, customMsg: M): ActorRef {
    const key = ref.path.toString();
    this._watchWithMessages.set(key, customMsg);
    if (!this._watching.has(key)) {
      this._watching.set(key, ref);
      if (ref instanceof LocalActorRef) {
        ref.getCell()._addWatcher(this.self);
      }
    }
    return ref;
  }

  unwatch(ref: ActorRef): ActorRef {
    const key = ref.path.toString();
    this._watchWithMessages.delete(key);
    if (!this._watching.delete(key)) return ref;
    if (ref instanceof LocalActorRef) {
      ref.getCell()._removeWatcher(this.self);
    }
    return ref;
  }

  // In the termination notification path (where Terminated is currently delivered):
  private notifyWatchers(deadRefKey: string, deadRef: ActorRef): void {
    for (const [watcher, watchedRefs] of allWatchers) {
      if (!watchedRefs.has(deadRefKey)) continue;
      const customMsg = watcher._watchWithMessages.get(deadRefKey);
      const msg = customMsg ?? new Terminated(deadRef);
      watcher.self.tell(msg as never);
    }
  }
}

Integration with existing actor-ts subsystems

  • ActorCell.watch / unwatch: extend, don't replace.
  • Terminated message: unchanged; watch() continues to deliver it.
  • Death notification path (_addWatcher / _removeWatcher machinery): unchanged externally; the notification call site picks customMsg over Terminated.
  • Typed Behaviors (TypedActorContext): same overload added.
  • Cluster remote watching: today remote watch delivers Terminated; watchWith over the wire is more complex (the custom message must be serialisable). Recommend phase-1: local watchWith only; remote watchWith errors loudly with a clear message ("custom termination messages over remote watch require an actor-ts serializer registration"). Phase 2: wire serialisation if demand.

Out of scope / non-goals

  • Multiple custom messages per ref: Akka allows only one; we follow same.
  • Remote watch with custom message in phase 1: defer.
  • Conditional message (e.g. different msg per termination reason): out of scope; just one message.

Open design questions

  1. Type-safety: watchWith<M extends TMsg>(ref, customMsg: M) enforces the custom message is in the watcher's message type. For typed Behaviors this is natural; for untyped Actor same. Confirm the generic constraint resolves correctly in inference.
  2. Replacing watch with watchWith (and vice versa): sketch says "last call wins". Akka same. Document.
  3. Message identity over multiple termination events: a ref that's restarted (different incarnation, same path) — does the custom message fire on every termination or only once? Recommend: once per watch — calling watchWith again after restart re-arms.
  4. Remote watch: Akka supports remote watchWith via serialisation. Phase 1 limitation here is intentional (less than 5% of users hit this). Document.

Test plan

  1. Local watchWithcontext.watchWith(workerRef, new WorkerDied('alice')); stop worker; watcher receives WorkerDied('alice'), NOT Terminated.
  2. Replace watch with watchWithcontext.watch(ref) then context.watchWith(ref, msg); on death, receives custom msg.
  3. Replace watchWith with watchcontext.watchWith(ref, msg) then context.watch(ref); on death, receives Terminated(ref).
  4. Multiple watchWith of different refs — watch worker-A with WorkerDied('a') + worker-B with WorkerDied('b'); deaths produce distinct domain messages.
  5. Unwatch removes bothunwatch(ref) after either watch or watchWith; on death, no message.
  6. Re-arm after death — watchWith ref; death; watcher receives msg; watcher restarts ref (different incarnation, same path); watchWith again; death again; receives msg again.
  7. Type-checked custom msg — TS compile test: watchWith rejects a message type not assignable to the watcher's TMsg.
  8. Remote watchWith errorwatchWith(remoteRef, customMsg) throws or warns clearly (phase-1 limit).
  9. Typed Behaviorsctx.watchWith from inside a typed behavior; receives custom msg in receive handler.
  10. Regression — existing watch / Terminated semantics unchanged.

Acceptance criteria

  • ActorContext.watchWith<M>(ref, customMsg) added (overload).
  • TypedActorContext.watchWith added.
  • ActorCell._watchWithMessages map + notification-path branching.
  • Remote watchWith errors clearly in phase 1 (deferred to phase 2 separate ticket).
  • Documentation: "DeathWatch with custom messages" with re-spawn-worker example.
  • Test suite covers all 10 cases.
  • CHANGELOG entry under "DeathWatch: watchWith(ref, customMsg) for domain-typed termination".

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or requestpriority: lowNice-to-have / niche / demand-driven

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions