You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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)]publicclassImageThumbnailGrain:Grain,IImageThumbnailGrain{publicTask<byte[]>Generate(byte[]image){/* ... */}}// Caller — key is irrelevant; just picks any pool membervargrain=grainFactory.GetGrain<IImageThumbnailGrain>(Guid.NewGuid());varthumbnail=awaitgrain.Generate(imageBytes);
Design sketch — actor-ts equivalent
// src/stateless/StatelessWorkerPool.ts (new)exportinterfaceStatelessWorkerOptions{/** Max instances per node. Defaults to navigator.hardwareConcurrency || 4. */readonlymaxLocalWorkers?: number;/** Routing within the pool: 'round-robin' | 'smallest-mailbox' | 'random'. */readonlyrouting?: '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 }); */exportfunctionstatelessWorkerPool<TMsg>(system: ActorSystem,name: string,props: Props<TMsg>,options?: StatelessWorkerOptions,): ActorRef<TMsg>;
Implementation: the returned ActorRef is a small dispatcher actor that:
On preStart: spawns maxLocalWorkers children with the user's Props.
On message: picks a worker per routing strategy; forwards.
On worker death: respawn to maintain pool size (defaultBackoffSupervisor).
classStatelessWorkerDispatcher<TMsg>extendsActor<TMsg>{privateworkers: ActorRef<TMsg>[]=[];privatecounter=0;constructor(privatereadonlyuserProps: Props<TMsg>,privatereadonlyopts: Required<StatelessWorkerOptions>){super();}overrideasyncpreStart(): Promise<void>{for(leti=0;i<this.opts.maxLocalWorkers;i++){constw=this.context.actorOf(this.userProps,`w-${i}`);this.context.watch(w);this.workers.push(w);}}overrideasynconReceive(msg: TMsg): Promise<void>{consttarget=this.pickWorker();target.tell(msg,this.sender.toNullable());}privatepickWorker(): ActorRef<TMsg>{if(this.opts.routing==='round-robin'){returnthis.workers[this.counter++%this.workers.length]!;}if(this.opts.routing==='smallest-mailbox'){// reuse #154's smallestMailboxStrategy logic}returnthis.workers[Math.floor(Math.random()*this.workers.length)]!;}// Death-handling: respawn to maintain pool sizeprotectedonTerminated(child: ActorRef): void{constidx=this.workers.indexOf(childasActorRef<TMsg>);if(idx>=0){constreplacement=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).
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.
Stateful pool members — explicitly stateless; users that need state should use sharded entities.
Open design questions
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.
maxLocalWorkers default: Orleans defaults to silo's CPU count. We can use navigator.hardwareConcurrency || 4. Recommend that.
Death + replacement: replacement worker has same logical name? Sketch uses suffix-incrementing names. Either works; sketch's approach avoids name collision races.
Routing-strategy default: round-robin is simpler; smallest-mailbox is more even. Recommend round-robin (no peek cost per message); smallest-mailbox opt-in.
Relationship to typed Behaviors: provide both OO Props and typed Behavior<T> variants? Recommend OO first; typed is sugar.
Test plan
Pool size respected — maxLocalWorkers: 4; pool has 4 children.
Round-robin distribution — 100 messages → each worker gets ~25.
Smallest-mailbox routing — uneven load; lighter workers get more.
Random — distribution roughly uniform over many calls.
Worker death + respawn — kill one worker; pool maintains size 4.
Size / Priority
[StatelessWorker].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":
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.Stateless workers fill the gap.
Reference: what Orleans does
Design sketch — actor-ts equivalent
Implementation: the returned
ActorRefis a small dispatcher actor that:preStart: spawnsmaxLocalWorkerschildren with the user'sProps.routingstrategy; forwards.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 —StatelessWorkerPlacementcould 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
Open design questions
maxLocalWorkersdefault: Orleans defaults to silo's CPU count. We can usenavigator.hardwareConcurrency || 4. Recommend that.Propsand typedBehavior<T>variants? Recommend OO first; typed is sugar.Test plan
maxLocalWorkers: 4; pool has 4 children.Acceptance criteria
system.statelessWorkerPool(name, props, options?)exported.maxLocalWorkers = hardwareConcurrency || 4.