Skip to content

Latest commit

 

History

History
129 lines (96 loc) · 9.28 KB

actors.md

File metadata and controls

129 lines (96 loc) · 9.28 KB

Actors and systems

Direct actor

Machine.spawn allocates an unstarted actor. Start it and stop it, or attach it to an Effect scope.

const program = Effect.scoped(
  Machine.scoped(
    Effect.gen(function* () {
      const actor = yield* Machine.spawn(machine);
      yield* actor.start;
      yield* actor.send(Event.Start);
      return yield* actor.awaitOutput;
    }),
  ),
);

Use Machine.run for an autonomous actor. It starts the actor, waits for output, and stops the actor on success, failure, or interruption.

Actor system

Use ActorSystemService for named lookup and multi-actor coordination. system.spawn starts the actor.

const system = yield * ActorSystemService;
const actor = yield * system.spawn("checkout", checkoutMachine);
const maybeActor = yield * system.get("checkout");

Terminal actors leave the registry. A stopped actor keeps its final snapshot and lifecycle value through its existing ActorRef.

Use a typed key when lookup callers must keep the machine state, event, and output types.

const CheckoutActor = actorSystemKey<CheckoutState, CheckoutEvent>("checkout");

const actor = yield * system.spawn(CheckoutActor, checkoutMachine);
const maybeActor = yield * system.get(CheckoutActor);
const generations = system.watch(CheckoutActor);

watch starts with the current actor or Option.none(). It then emits only when that ID starts or stops. A later actor generation with the same key emits its new ActorRef.

Parent and child actors

Spawn a child from a state-owned Effect:

machine.spawn(State.Menu, ({ self }) =>
  self.spawn("menu", menuMachine).pipe(Effect.asVoid, Effect.orDie),
);

The child uses the same actor system. The child stops when the parent exits the owning state. A child from .background lives until the parent actor stops.

This pattern replaces a root router that invokes one screen actor for each route. See actor-system.ts.

Lazy actors owned by a parent state

Use ActorHost when consumers request a child but the parent state must own its lifetime. Construct the host in a scoped service layer. Keep session validation and authorization in that service.

const menuHost =
  yield *
  ActorHost.make({
    identity: (input: { sessionId: string }) => input.sessionId,
    spawn: (input) => Machine.spawn(menuMachine, { input }),
  });

parent.spawn(State.Menu, ({ state }) =>
  menuHost.host({ sessionId: state.sessionId }).pipe(Effect.asVoid, Effect.orDie),
);

const menu = yield * menuHost.acquire({ sessionId });

host registers one generation in the current state scope. The first matching acquire supplies the factory input. Identity values match with Object.is. Concurrent consumers share startup and its result. Consumer cancellation does not cancel startup or stop the actor. ActorHost starts the factory result, so both Machine.spawn and system.spawn work.

When the parent owns startup data, give spawn a typed second argument. Pass that data to host; consumers still provide only the request input.

const screenHost =
  yield *
  ActorHost.make({
    identity: (request: { sessionId: string }) => request.sessionId,
    spawn: (request, screen: { destination: string }) =>
      Machine.spawn(screenMachine, { input: { ...request, ...screen } }),
  });

parent.spawn(State.Screen, ({ state }) =>
  screenHost
    .host({ sessionId: state.sessionId }, { destination: state.destination })
    .pipe(Effect.asVoid, Effect.orDie),
);

const screen = yield * screenHost.acquire({ sessionId });

The second argument belongs to the registered generation. A consumer cannot replace it. Reentry supplies new host data. Host data is passed by reference, so use immutable values. Existing one-argument factories need no host data.

The factory uses the services captured when the host was made. Its Scope and ActorScope belong to the hosting generation. State exit closes the actor. Closing the host service also closes the current generation and ends consumers waiting for a future generation.

Consumers receive ActorHostClosedError before actor cleanup starts. An acquisition belongs to the generation it observed. Acquire again after reentry to get the new actor. The registered host wait is interrupted when its generation closes. Calls made after the host service closes fail with ActorHostClosedError. An overlapping host fails with ActorHostOccupiedError, including while the previous actor is still being cleaned up.

Factory failures stay in the typed error channel and remain shared until the hosting scope closes. Handle expected factory failures in the parent's spawn handler with a state transition or event. The example uses Effect.orDie because it treats the remaining host errors as wiring failures. A defect stops the parent; it cannot reenter to retry. Both host error classes are exported from the package root.

ActorRef selection

  • send queues an event and returns.
  • call waits for event processing and returns ProcessEventResult.
  • ask returns the schema-checked reply for an Event.reply event.
  • A transition can return Machine.deferReply(nextState) when state-owned work must produce the reply later. The work calls self.reply(value) once it has the value. The value is decoded with the event's reply schema before the pending ask is settled. A decode mismatch is a defect and also settles the caller with that defect. If state exit interrupts decoding, the pending caller is settled with the same interruption and the actor can continue. A second self.reply returns false.
  • waitFor waits for a state constructor or predicate. Its predicate sees the current snapshot and then every later state write, each once, in write order, and one call at a time. The wait returns the first match, and the predicate is not called after it matches. A predicate that keeps state (counts calls, remembers an earlier state) therefore gets the same answer as a replay of the written states. A predicate that sends an event does not see the resulting states inside its own call: it sees them in later calls.
  • awaitFinal returns the retained final state.
  • sendAndWait starts to observe, judges the snapshot from before the event, sends the event, then waits like waitFor, or like awaitFinal without a target. A brief state that the event causes is not missed. The event is sent even when the snapshot already matches. It is not sent when the predicate throws on the snapshot.
  • These waits end when a state matches or the actor terminates. When the actor terminates (final, stop, drain, or defect) and no state from the start of the wait matches, they fail with ActorStoppedError. A state counts as reached once it is written, even when its entry crashes later, and even when a stop comes before its notification. A predicate that throws fails the wait with that defect. A wait releases its listener and its subscription when it ends, also when it is interrupted at any point.
  • A sleeping wait wakes at the notification that follows a state write, or at the terminal exit. Code that holds the actor between a write and its notification (for example, holds the latestTransition permit) keeps the wait asleep until that notification or the terminal exit runs. The wait then judges every state written in between, in write order.
  • A supervised restart keeps this order: the waits see a state written by an entry that crashed, then the restart state, then the states of the new generation, in write order.
  • A state restored by recovery reaches waiters and subscribe listeners like any other state change.
  • subscribe (and actor.client.subscribe, and self.client.subscribe inside the machine) calls the listener with every state written after the call, each once, in write order. It does not call it with the state that is current when it subscribes; read getSnapshot for that. Each call is its own registration: the same function subscribed twice gets each write twice, and each unsubscribe ends one registration. A listener added inside another listener's callback gets only later writes too. A listener is not called again while its own callback runs: when the callback sends an event, the listener gets the resulting states after it returns, and every other listener still gets the older state first. A supervised restart keeps write order for listeners as it does for waits. When a stop comes before the notification of the last write, the listeners still get that write before awaitExit completes. After the terminal exit, and after unsubscribe returns, a listener gets nothing more. changes streams the same order, starting with the state current at subscription.
  • awaitOutput returns the final output.
  • awaitExit returns Final, Stopped, or Defect.
  • stop and awaitExit complete after the public lifecycle reports the same terminal result.
  • stop, Machine.scoped, and awaitExit share one shutdown completion. Each caller waits for runtime, child, and scope finalizers. An interrupted waiter does not cancel the shutdown owner. A cleanup failure returns a Defect with phase cleanup and remains visible to later stop callers.
  • client exposes send, stop, snapshot, matching, subscription, and Promise-based capability checks outside Effect.
  • client.canSync supports Boolean transition predicates only.
  • React and Solid should use Actor Atoms for state and capability subscriptions.