Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/lazy-actor-host.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"effect-machine": minor
---

Add ActorHost for lazy actors owned by a parent state scope. Consumers share startup without owning cancellation or shutdown. The factory captures service dependencies, uses the first matching request input, and releases the actor on state exit or host service shutdown.
14 changes: 14 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,20 @@ machine.spawn(State.Active, ({ self }) =>
- `self.spawn` returns `Effect<ActorRef, DuplicateActorError, R>` — use `Effect.orDie` in handlers
- Every `ActorRef` has `actor.system` for child access: `actor.system.get("worker-1")`

## Lazy State-Owned Actors

Use `ActorHost.make({ identity, spawn })` when consumers must request a child without owning its lifetime.

- Construct the host in the service layer. `spawn` captures those services.
- Run `host.host(input)` in the parent state's `.spawn` handler. It registers a generation and waits for a consumer.
- Consumers call `host.acquire(input)`. Identity values match with `Object.is`. The first matching consumer supplies the factory input.
- Concurrent consumers share startup and its result. Cancelling one consumer does not cancel startup. ActorHost starts actors from either `Machine.spawn` or `system.spawn` before it returns them.
- The factory receives the host generation's Scope and ActorScope. State exit closes the child. Closing the host service also closes the current generation and fails pending consumers.
- A second active host fails with `ActorHostOccupiedError`. A closed generation fails pending acquisition with `ActorHostClosedError`.
- Keep session validity, authorization, and completion events in application services. ActorHost only owns actor creation and lifetime.
- A failed factory stays failed until the hosting scope closes. Handle expected factory errors in the spawn handler with a transition or event. `Effect.orDie` is appropriate only for invariant failures; it defects the parent and cannot support a later reentry.
- Once registered, `host` is interrupted when its generation closes. Consumers get `ActorHostClosedError` before actor cleanup starts. An acquisition belongs to the generation it observed; call `acquire` again after reentry. Pre-registration calls to a closed host service fail with `ActorHostClosedError`.

## ActorRef API

```ts
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ Effect Machine does not add an action queue or a second context system.
| Actor-owned stream or resource | `.background` |
| Autonomous machine sequence | `Machine.run` with `Effect.flatMap` |
| Interactive multi-phase flow | Parent machine with child actors |
| Lazy child requested by consumers | `ActorHost` in a parent state scope |

Effect requirements remain in `R`. A machine cannot start until the application provides every required service. Effectful transition handlers must have `never` in their error channel. Convert expected failures to states or events.

Expand Down
6 changes: 6 additions & 0 deletions SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -365,3 +365,9 @@ Input machines require `input: (entityId) => Input`. Use `initializeState` only
| `examples/react` | React Suspense and selector example |
| `examples/solid` | Solid Suspense and selector example |
| `docs` | User and migration guides |

## Lazy actors owned by a parent state

Create `ActorHost.make({ identity, spawn })` in a scoped service layer. Use `host.host(input)` in a state-scoped `.spawn` handler. Use `host.acquire(input)` from consumers. Identity uses `Object.is`. The first matching consumer supplies the spawn input. Concurrent consumers share startup. Consumer cancellation does not stop startup or the actor. The parent state scope owns the actor, and host service shutdown closes any active generation. Keep session validation in the application.

ActorHost starts direct `Machine.spawn` results too. A registered `host` wait is interrupted on generation close; consumers receive `ActorHostClosedError` before actor cleanup. A consumer attached to an old generation must acquire again after reentry. Handle expected factory errors in the parent's spawn handler. Use `Effect.orDie` only for invariant failures, because it defects the parent. Both host errors are exported from the package root.
27 changes: 27 additions & 0 deletions docs/actors.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,33 @@ The child uses the same actor system. The child stops when the parent exits the

This pattern replaces a root router that invokes one screen actor for each route. See [`actor-system.ts`](../examples/core/src/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.

```ts
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.

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.
Expand Down
173 changes: 173 additions & 0 deletions src/actor-host.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
import { Deferred, Effect, Exit, Option, Scope, Stream, SubscriptionRef } from "effect";

import type { ActorRef } from "./actor.js";
import * as Machine from "./machine.js";

import { ActorHostClosedError, ActorHostOccupiedError } from "./errors.js";

export { ActorHostClosedError, ActorHostOccupiedError } from "./errors.js";

export interface ActorHost<Input, S extends { readonly _tag: string }, E, Output, Failure> {
/** Register in the current scope. Wait for acquisition; interrupt if this generation closes. */
readonly host: (
input: Input,
) => Effect.Effect<
ActorRef<S, E, Output>,
Failure | ActorHostClosedError | ActorHostOccupiedError,
Scope.Scope
>;
/** Wait for a matching generation. Caller cancellation does not cancel actor startup. */
readonly acquire: (
input: Input,
) => Effect.Effect<ActorRef<S, E, Output>, Failure | ActorHostClosedError>;
}

/**
* A lazy actor whose lifetime belongs to its host scope, not its consumers.
*
* Call `host` from a machine's state-scoped spawn handler. Call `acquire` from
* consumers. The first matching consumer supplies the spawn input. Identity
* values match with Object.is. The factory captures the services at make time;
* its Scope and ActorScope always belong to the hosting generation. The actor
* starts before publication, including factories that use Machine.spawn.
*/
export const make = Effect.fn("effect-machine.actorHost.make")(function* <
Input,
S extends { readonly _tag: string },
E,
Output,
Failure,
R,
>(options: {
readonly identity: (input: Input) => unknown;
readonly spawn: (input: Input) => Effect.Effect<ActorRef<S, E, Output>, Failure, R>;
}) {
type Actor = ActorRef<S, E, Output>;
interface Entry {
readonly identity: unknown;
readonly scope: Scope.Closeable;
readonly requested: Deferred.Deferred<Input>;
readonly actor: Deferred.Deferred<Actor, Failure | ActorHostClosedError>;
readonly closed: Deferred.Deferred<void>;
}
const services = yield* Effect.context<Exclude<R, Scope.Scope>>();
const current = yield* SubscriptionRef.make(Option.none<Entry>());
const closed = yield* Deferred.make<void>();
const failClosed = Effect.fail(ActorHostClosedError.make({}));
yield* Effect.addFinalizer(() =>
Effect.gen(function* () {
yield* Deferred.succeed(closed, undefined);
const entry = yield* SubscriptionRef.get(current);
if (Option.isSome(entry)) yield* Scope.close(entry.value.scope, Exit.void);
}),
);

const awaitActor = (entry: Entry): Effect.Effect<Actor, Failure | ActorHostClosedError> =>
Effect.raceFirst(
Deferred.await(entry.actor),
Deferred.await(entry.closed).pipe(Effect.andThen(failClosed)),
).pipe(
Effect.flatMap((actor) =>
Deferred.isDone(entry.closed).pipe(
Effect.flatMap((ended) => {
if (ended) return failClosed;
return Effect.succeed(actor);
}),
),
),
);

const host: ActorHost<Input, S, E, Output, Failure>["host"] = Effect.fn(
"effect-machine.actorHost.host",
)((input) =>
Effect.uninterruptibleMask((restore) =>
Effect.gen(function* () {
if (yield* Deferred.isDone(closed)) return yield* failClosed;
const generation = yield* Scope.fork(yield* Scope.Scope);
const entry: Entry = {
identity: options.identity(input),
scope: generation,
requested: yield* Deferred.make<Input>(),
actor: yield* Deferred.make<Actor, Failure | ActorHostClosedError>(),
closed: yield* Deferred.make<void>(),
};
yield* Scope.addFinalizer(
generation,
SubscriptionRef.update(current, (value) => {
if (Option.isSome(value) && value.value === entry) return Option.none();
return value;
}),
);
const scope = yield* Scope.fork(generation);

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. State ownership and shutdown order
+ host(input)
+   generation scope
+     inner scope: startup fiber and actor
+   close generation
+     mark closed; fail waiting consumers
+     close inner scope; stop actor
+     remove the matching generation

Finalizers run in reverse registration order. The slow-cleanup test verifies that consumers fail closed and a new host remains rejected until cleanup ends. This trace comes from the source. calldiff could not resolve the Effect.fn generator entry point.

// Publish closure before startup cancellation; retire the entry after actor cleanup.
yield* Scope.addFinalizer(
generation,
Deferred.succeed(entry.closed, undefined).pipe(
Effect.andThen(Deferred.fail(entry.actor, ActorHostClosedError.make({}))),
),
);
const registered = yield* SubscriptionRef.modify(current, (value) => {
if (
Option.isSome(value) ||
Deferred.isDoneUnsafe(closed) ||
Deferred.isDoneUnsafe(entry.closed)
) {
return [false, value];
}
return [true, Option.some(entry)];
});
if (!registered) {
const ended = (yield* Deferred.isDone(closed)) || (yield* Deferred.isDone(entry.closed));
yield* Scope.close(generation, Exit.void);
if (ended) return yield* failClosed;
return yield* ActorHostOccupiedError.make({});
}
yield* Effect.forkIn(
Deferred.complete(
entry.actor,
Deferred.await(entry.requested).pipe(
Effect.flatMap((requested) =>
Machine.scoped(options.spawn(requested).pipe(Effect.tap((actor) => actor.start))),

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. Shared lazy startup
+ acquire(input)
+   select matching generation
+   supply the first request input
+   await shared actor
+ host startup fiber
+   Machine.scoped(factory(input))
+   actor.start
+   publish shared result

Consumer cancellation only ends that consumer's wait. The generation scope owns startup. Tests cover real recovery cancellation, direct Machine.spawn startup, and typed failures from a nested host. This trace comes from the source; the calldiff entry point was not resolved.

),
Scope.provide(scope),
Effect.provideContext(services),
),
),
scope,
);
return yield* restore(
awaitActor(entry).pipe(
Effect.catchTag("ActorHostClosedError", (error) =>
Deferred.isDone(entry.closed).pipe(
Effect.flatMap((ownGenerationClosed) => {
if (ownGenerationClosed) return Effect.interrupt;
return Effect.fail(error);
}),
),
),
),
);
}),
),
);
const acquire: ActorHost<Input, S, E, Output, Failure>["acquire"] = Effect.fn(
"effect-machine.actorHost.acquire",
)((input) =>
Effect.raceFirst(
Effect.gen(function* () {
const identity = options.identity(input);
const entry = yield* SubscriptionRef.changes(current).pipe(
Stream.filter(Option.isSome),
Stream.map((value) => value.value),
Stream.filter((value) => Object.is(value.identity, identity)),
Stream.runHead,
Effect.flatMap(Option.match({ onNone: () => failClosed, onSome: Effect.succeed })),
);
yield* Deferred.succeed(entry.requested, input);
return yield* awaitActor(entry);
}),
Deferred.await(closed).pipe(Effect.andThen(failClosed)),
),
);
return { host, acquire } satisfies ActorHost<Input, S, E, Output, Failure>;
});
12 changes: 12 additions & 0 deletions src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,3 +55,15 @@ export class VersionConflictError extends Schema.TaggedError<VersionConflictErro
"VersionConflictError",
{ expected: Schema.Finite, actual: Schema.Finite },
) {}

/** The owning state or the host service closed before acquisition completed. */
export class ActorHostClosedError extends Schema.TaggedError<ActorHostClosedError>()(
"ActorHostClosedError",
{},
) {}

/** A host can own only one state generation at a time. */
export class ActorHostOccupiedError extends Schema.TaggedError<ActorHostOccupiedError>()(
"ActorHostOccupiedError",
{},
) {}
4 changes: 4 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
// Machine namespace (Effect-style)
export * as Machine from "./machine.js";

export * as ActorHost from "./actor-host.js";

// Errors
export {
ActorHostClosedError,
ActorHostOccupiedError,
ActorStoppedError,
AssertionError,
DuplicateActorError,
Expand Down
Loading
Loading