Skip to content

[Feature] Stateless Workers (per-node pool of identical actors) #170

Description

@pathosDev

Size / Priority

Rationale

Pure stateless transformations (request validation, JSON parsing, image-thumbnail generation, batch-message decoding) benefit from N parallel instances per node rather than the actor-model's "one logical actor per identity":

  • No state to coordinate → no need for serial dispatch.
  • Higher CPU utilisation on multi-core nodes — N instances can run on N threads in parallel where the runtime supports it.
  • Easier load balancing — round-robin across local pool, no cross-node hop.

Orleans's [StatelessWorker] attribute marks a grain class as stateless; the runtime maintains a per-silo pool of identical activations (default size = CPU count) and round-robins calls across the pool. From the caller's POV, it's still "GetGrain(any-key)" — but the call doesn't establish a per-key identity; it picks any pool member.

actor-ts doesn't have this pattern. Closest equivalents:

  • Router.roundRobin(N, props) — but the router is one logical actor with a queue; not parallel dispatch.
  • Cluster sharding with a tiny shard set — but sharding implies persistent identity.

Stateless workers fill the gap.

Reference: what Orleans does

[StatelessWorker(maxLocalWorkers: 8)]
public class ImageThumbnailGrain : Grain, IImageThumbnailGrain
{
    public Task<byte[]> Generate(byte[] image) { /* ... */ }
}

// Caller — key is irrelevant; just picks any pool member
var grain = grainFactory.GetGrain<IImageThumbnailGrain>(Guid.NewGuid());
var thumbnail = await grain.Generate(imageBytes);

Design sketch — actor-ts equivalent

// src/stateless/StatelessWorkerPool.ts (new)

export interface StatelessWorkerOptions {
  /** Max instances per node.  Defaults to navigator.hardwareConcurrency || 4. */
  readonly maxLocalWorkers?: number;
  /** Routing within the pool: 'round-robin' | 'smallest-mailbox' | 'random'. */
  readonly routing?: 'round-robin' | 'smallest-mailbox' | 'random';
}

/**
 * Per-node pool of identical actor instances, round-robined per message.
 *
 *   const pool = system.statelessWorker(
 *     'image-thumbnail',
 *     Props.create(() => new ImageThumbnailActor()),
 *     { maxLocalWorkers: 8 },
 *   );
 *   pool.tell({ kind: 'generate', image: bytes });
 *   const reply = await ask(pool, { kind: 'generate', image: bytes });
 */
export function statelessWorkerPool<TMsg>(
  system: ActorSystem,
  name: string,
  props: Props<TMsg>,
  options?: StatelessWorkerOptions,
): ActorRef<TMsg>;

Implementation: the returned ActorRef is a small dispatcher actor that:

  1. On preStart: spawns maxLocalWorkers children with the user's Props.
  2. On message: picks a worker per routing strategy; forwards.
  3. On worker death: respawn to maintain pool size (defaultBackoffSupervisor).
class StatelessWorkerDispatcher<TMsg> extends Actor<TMsg> {
  private workers: ActorRef<TMsg>[] = [];
  private counter = 0;
  constructor(private readonly userProps: Props<TMsg>, private readonly opts: Required<StatelessWorkerOptions>) { super(); }

  override async preStart(): Promise<void> {
    for (let i = 0; i < this.opts.maxLocalWorkers; i++) {
      const w = this.context.actorOf(this.userProps, `w-${i}`);
      this.context.watch(w);
      this.workers.push(w);
    }
  }

  override async onReceive(msg: TMsg): Promise<void> {
    const target = this.pickWorker();
    target.tell(msg, this.sender.toNullable());
  }

  private pickWorker(): ActorRef<TMsg> {
    if (this.opts.routing === 'round-robin') {
      return this.workers[this.counter++ % this.workers.length]!;
    }
    if (this.opts.routing === 'smallest-mailbox') {
      // reuse #154's smallestMailboxStrategy logic
    }
    return this.workers[Math.floor(Math.random() * this.workers.length)]!;
  }

  // Death-handling: respawn to maintain pool size
  protected onTerminated(child: ActorRef): void {
    const idx = this.workers.indexOf(child as ActorRef<TMsg>);
    if (idx >= 0) {
      const replacement = this.context.actorOf(this.userProps, `w-${idx}-r${Date.now()}`);
      this.context.watch(replacement);
      this.workers[idx] = replacement;
    }
  }
}

Integration with existing actor-ts subsystems

  • Router.ts: similar shape to the existing routers. StatelessWorker is essentially "round-robin router with no shared queue + per-node placement". Could be a router strategy. Cleaner as a separate concept (signals "stateless" to readers).
  • #169 PlacementStrategy: synergies — StatelessWorkerPlacement could be one of the strategies. Recommend: implement [Feature] Stateless Workers (per-node pool of identical actors) #170 as an independent extension; document the relationship.
  • BackoffSupervisor: pool replenishment uses backoff to avoid restart-storm if a worker keeps failing.
  • #154 SmallestMailboxRouter: reuse the routing strategy.

Out of scope / non-goals

  • Cross-node load-balancing inside the pool — Orleans pools are per-silo; the caller picks a silo first (placement), then a worker in that silo. We do the same: caller→node hop is separate; within-node fan-out is the pool's job.
  • Auto-scaling pool size — fixed at construction. Auto-scaling could be a future enhancement ([Feature] ML auto-tuning of cluster parameters #213 mailbox-depth auto-scaling).
  • Stateful pool members — explicitly stateless; users that need state should use sharded entities.

Open design questions

  1. Per-node vs cluster-wide pool: Orleans is per-silo. Ours should be per-node (matches the "stateless" promise — no cross-node coordination). Recommend per-node.
  2. maxLocalWorkers default: Orleans defaults to silo's CPU count. We can use navigator.hardwareConcurrency || 4. Recommend that.
  3. Death + replacement: replacement worker has same logical name? Sketch uses suffix-incrementing names. Either works; sketch's approach avoids name collision races.
  4. Routing-strategy default: round-robin is simpler; smallest-mailbox is more even. Recommend round-robin (no peek cost per message); smallest-mailbox opt-in.
  5. Relationship to typed Behaviors: provide both OO Props and typed Behavior<T> variants? Recommend OO first; typed is sugar.

Test plan

  1. Pool size respectedmaxLocalWorkers: 4; pool has 4 children.
  2. Round-robin distribution — 100 messages → each worker gets ~25.
  3. Smallest-mailbox routing — uneven load; lighter workers get more.
  4. Random — distribution roughly uniform over many calls.
  5. Worker death + respawn — kill one worker; pool maintains size 4.
  6. Backoff on repeated crash — worker crashes 10× rapidly; backoff slows respawn.
  7. High throughput — 100K msg/sec through the pool; no head-of-line blocking.
  8. Reply routing — ask the pool; reply comes from the worker that handled it.
  9. Pool cleanup on dispatcher stop — stop the dispatcher; all workers terminate.
  10. Metrics — per-pool messages counter, per-worker mailbox-depth gauge.

Acceptance criteria

  • system.statelessWorkerPool(name, props, options?) exported.
  • Default maxLocalWorkers = hardwareConcurrency || 4.
  • Three routing strategies (round-robin/smallest-mailbox/random).
  • Backoff-supervised worker replacement.
  • Metrics (pool size gauge, throughput counter, per-worker depth gauge).
  • Documentation: "Stateless workers vs Routers vs Sharded entities" decision matrix.
  • Test suite covers all 10 cases.
  • CHANGELOG entry under "New: Stateless workers (per-node pool of identical actors)".

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