The Actor Model
in reviewThis lesson is complete and being validated before release - details may still change.
in reviewThis lesson is complete and being validated before release - details may still change.
actor-system.ts
import { Mulberry32 } from "../random";import type { Tracer } from "../trace";/** A message an actor sends when a rule fires. */export interface Outgoing { /** An actor's name, or "sender" for whoever sent the message being handled. */ to: string; type: string; /** The handled message's value, the actor's own state after the update, or a constant. */ value: "value" | "state" | number; /** Send it this many times (default once). */ count?: number; /** Send only while the state is below this. */ onlyIfStateBelow?: number;}/** What an actor does with one kind of message: change its state, then send. */export interface ActorRule { on: string; effect: "add" | "subtract" | "none"; /** For a subtract that would go below zero: reply with this type instead of applying it. */ bounce?: string; send?: readonly Outgoing[];}export interface ActorSpec { name: string; state: number; rules: readonly ActorRule[];}export interface ActorMessage { id: number; from: string; to: string; type: string; value: number;}export const DELIVERY_POLICIES = ["round-robin", "random"] as const;export type DeliveryPolicy = (typeof DELIVERY_POLICIES)[number];export const MAX_ACTORS = 4;export const MAX_MESSAGES = 48;export interface ActorReport { sent: number; delivered: number; dropped: number; bounced: number; states: Record<string, number>;}/** * An actor owns its state and nobody else can touch it. The only way * to affect an actor is to send it a message, which waits in its * mailbox until the actor takes it, one at a time, and reacts by * changing its own state and sending messages of its own. Two workers * incrementing a counter cannot race, because the counter is the only * thing that ever changes the count, and it handles one increment * before the next. There are no locks because there is nothing shared * to lock. What the model gives up is a global moment: while a message * is in flight, money withdrawn from one account has not yet arrived at * the other, and no one can see both at once. * * Time: O(messages). Space: O(actors + messages in flight). */export class ActorSystem { private readonly actors: Map<string, ActorSpec>; private readonly states = new Map<string, number>(); private readonly mailboxes = new Map<string, ActorMessage[]>(); private readonly random: Mulberry32; private readonly names: string[]; private last = -1; private nextId = 0; private delivered = 0; private dropped = 0; private bounced = 0; constructor( actors: readonly ActorSpec[], private readonly policy: DeliveryPolicy, seed = 1, private readonly tracer?: Tracer, ) { if (actors.length < 1 || actors.length > MAX_ACTORS) throw new RangeError(`actors must be in [1, ${MAX_ACTORS}]`); if (!DELIVERY_POLICIES.includes(policy)) throw new RangeError(`unknown policy ${String(policy)}`); this.actors = new Map(actors.map((actor) => [actor.name, actor])); if (this.actors.size !== actors.length) throw new RangeError("actor names must be unique"); this.names = actors.map((actor) => actor.name); for (const actor of actors) { this.states.set(actor.name, actor.state); this.mailboxes.set(actor.name, []); for (const rule of actor.rules) for (const out of rule.send ?? []) if (out.to !== "sender" && !this.actors.has(out.to)) throw new RangeError(`${actor.name} sends to unknown ${out.to}`); for (const matchingRule of actor.rules) for (const outgoingMessage of matchingRule.send ?? []) if (outgoingMessage.to !== "sender" && !this.actors.has(outgoingMessage.to)) throw new RangeError(`${actor.name} sends to unknown ${out.to}`); } this.random = new Mulberry32(seed); } /** Every actor's state as it stands. */ snapshot(): Record<string, number> { return Object.fromEntries(this.states); } /** The messages waiting for an actor, oldest first. */ mailbox(name: string): readonly ActorMessage[] { return [...(this.mailboxes.get(name) ?? [])]; } /** Puts a message in the receiver's mailbox; the one place a message is made. */ send(from: string, to: string, type: string, value: number): ActorMessage { const mailbox = this.mailboxes.get(to); if (!mailbox) throw new RangeError(`no actor named ${to}`); if (this.nextId >= MAX_MESSAGES) throw new RangeError(`more than ${MAX_MESSAGES} messages; the actors are not settling`); const message: ActorMessage = { id: this.nextId++, from, to, type, value }; mailbox.push(message); // ▸ a message goes into the receiver's mailbox; the sender does not wait this.tracer?.emit("send", { id: message.id, from, to, type, value, queued: mailbox.length, inFlight: this.inFlight() }); return message; } /** Delivers the opening messages, then one message at a time until every mailbox is empty. */ run(initial: readonly { from: string; to: string; type: string; value: number }[]): ActorReport { for (const message of initial) this.send(message.from, message.to, message.type, message.value); while (this.busy().length > 0) this.deliver(this.pick()); return { sent: this.nextId, delivered: this.delivered, dropped: this.dropped, bounced: this.bounced, states: this.snapshot() }; } /** Takes the oldest message from an actor's mailbox and lets its rule react. */ deliver(name: string): void { const actor = this.actors.get(name); const message = this.mailboxes.get(name)?.shift(); if (!actor || !message) throw new RangeError(`${name} has no message to handle`); this.delivered++; const state = this.states.get(name)!; // ▸ an actor takes the oldest message from its mailbox and handles it alone this.tracer?.emit("receive", { id: message.id, actor: name, from: message.from, type: message.type, value: message.value, state, left: this.mailboxes.get(name)!.length, delivered: this.delivered }); const rule = actor.rules.find((candidate) => candidate.on === message.type); const matchingRule = actor.rules.find((candidate) => candidate.on === message.type); if (!rule) { if (!matchingRule) { this.dropped++; // ▸ no rule matches the message, so the actor ignores it this.tracer?.emit("drop", { id: message.id, actor: name, type: message.type, from: message.from, dropped: this.dropped }); return; } if (rule.effect === "subtract" && rule.bounce !== undefined && state < message.value) { if (matchingRule.effect === "subtract" && matchingRule.bounce !== undefined && state < message.value) { this.bounced++; this.send(name, message.from, rule.bounce, message.value); this.send(name, message.from, matchingRule.bounce, message.value); return; } if (rule.effect !== "none") { if (matchingRule.effect !== "none") { const after = rule.effect === "add" ? state + message.value : state - message.value; const after = matchingRule.effect === "add" ? state + message.value : state - message.value; this.states.set(name, after); // ▸ the actor changes its own state; nobody else ever touches it this.tracer?.emit("update", { actor: name, type: message.type, before: state, after, effect: rule.effect }); this.tracer?.emit("update", { actor: name, type: message.type, before: state, after, effect: matchingRule.effect }); } for (const out of rule.send ?? []) { for (const outgoingMessage of matchingRule.send ?? []) { const current = this.states.get(name)!; if (out.onlyIfStateBelow !== undefined && current >= out.onlyIfStateBelow) continue; if (outgoingMessage.onlyIfStateBelow !== undefined && current >= outgoingMessage.onlyIfStateBelow) continue; const value = out.value === "value" ? message.value : out.value === "state" ? current : out.value; const value = outgoingMessage.value === "value" ? message.value : outgoingMessage.value === "state" ? current : outgoingMessage.value; for (let i = 0; i < (out.count ?? 1); i++) this.send(name, out.to === "sender" ? message.from : out.to, out.type, value); for (let i = 0; i < (outgoingMessage.count ?? 1); i++) this.send(name, outgoingMessage.to === "sender" ? message.from : outgoingMessage.to, outgoingMessage.type, value); } } /** Messages sent and not yet handled. */ private inFlight(): number { return [...this.mailboxes.values()].reduce((sum, mailbox) => sum + mailbox.length, 0); } private busy(): number[] { return this.names.flatMap((name, i) => ((this.mailboxes.get(name)?.length ?? 0) > 0 ? [i] : [])); } /** The next actor with mail, by turns or by the seeded coin. */ private pick(): string { const candidates = this.busy(); const chosen = this.policy === "round-robin" ? (candidates.find((i) => i > this.last) ?? candidates[0]!) : candidates[this.random.index(candidates.length)]!; this.last = chosen; return this.names[chosen]!; }}Every mailbox is empty: 8 messages sent and 8 handled, 0 dropped. The states settle at main 0, w1 0, w2 0, counter 6.