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
15 changes: 15 additions & 0 deletions .changeset/durable-host-adapters.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
'@xstate/inngest': minor
'@xstate/rivet': minor
'xstate': patch
---

Add experimental durable execution adapters for Inngest and Rivet workflows.
Host runtime mappings now receive the complete built-in effect, allowing them
to map timers, sends and child actors without coupling XState to either host.

```ts
import { createDurable } from '@xstate/inngest';

const output = await createDurable(machine, options).run(input);
```
13 changes: 13 additions & 0 deletions .changeset/fresh-melons-wait.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
'xstate': minor
---

Add an experimental, host-neutral durable execution helper at `xstate/durable`.
It assigns stable IDs to effects and event waits from pure transitions while
leaving durable execution, timers, messaging and child actors to the host.
Hosts can read `nextTransitionIndex` after every transition for checkpointing.

```ts
const durable = createDurable(machine, adapter);
const output = await durable.run(input);
```
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ This is a monorepo containing XState, @xstate/store, and related packages.
- Run `pnpm lint` and `pnpm format:check` to check linting and formatting.
- Run `pnpm typecheck` to make sure that there are no type errors.
- Before making a PR, run `pnpm changeset` to create a changeset with a short description of the changes, and a code example if applicable. Do not include implementation details; only pertinent details for developers using the package.
- For every new feature or behavior change, audit the relevant docs and READMEs and fix missing or inaccurate guidance in the same PR.

## XState v6 (alpha)

Expand Down
1 change: 1 addition & 0 deletions docs/backend-workflows.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ For deadlines that must respect real time, persist the scheduled and due times n

## What next?

- [Delegate the transition loop to a durable execution host](durable-execution.md).
- [Persist and version snapshots](persistence.md).
- [Retry failed steps and bound how long they may run](retries-and-timeouts.md).
- [Write the async logic a workflow invokes](actor-logic.md).
Expand Down
130 changes: 130 additions & 0 deletions docs/durable-execution.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
---
title: Durable execution
description: Run pure XState transitions on a durable host.
---

Import the experimental durable execution helper from `xstate/durable` when a
workflow platform owns persistence, retries, timers, messaging and child
execution.

This is for hosts that durably replay or checkpoint the execution loop. If your
application instead restores an actor for each request, processes one event and
saves its snapshot, use the [backend workflow](backend-workflows.md) pattern.

```ts
import { createDurable } from 'xstate/durable';

const durable = createDurable(machine, {
executeAction: (action, { id }, runtime) =>
host.runAction(id, () => action.exec(runtime)),
runtime: ({ id: effectId }, effect) => ({
sendEvent: (_source, target, event) =>
host.send(effectId, target.id, event),
scheduleTimer: (source, id, delay) =>
host.schedule({ effectId, actorId: source.id, timerId: id, delay }),
cancelTimer: (source, id) =>
host.cancelTimer({ effectId, actorId: source.id, timerId: id })
// Map the remaining ActorSystemRuntime operations supported by the host.
}),
waitForEvent: ({ id }) => host.waitForEvent(id)
});

const output = await durable.run(input);
```

`run()` is convenience over the explicit transition loop:

```ts
let [state, effects] = durable.initialTransition(input);
await durable.executeEffects(effects);

while (state.status === 'active') {
const event = await durable.waitForEvent();
[state, effects] = durable.transition(state, event);
await durable.executeEffects(effects);
}
```

`initialTransition()` and `transition()` remain pure. The helper tags their
ordered effects with stable IDs such as `0:0` and `1:0`, and event waits with
IDs such as `event:0`. A durable host should memoize or deduplicate each
operation using that ID. Replaying the same events from the beginning
reconstructs the same snapshots, effects, waits and IDs.

The host runtime subsumes the local actor system. XState calculates transitions
and stable operation IDs; the adapter maps those operations to durable host
primitives:

- Actions run through `executeAction()` with their stable effect ID and the
runtime returned by `runtime()`. External work should use the effect ID as an
idempotency key. A host may require actions to have registered `type` values
when its activity model cannot replay inline code.
- Sends route through the host's actor or workflow identity.
- Timers register host-managed delivery and return immediately. The host stamps
`dueAt` when it durably commits the timer; transition calculation never reads
wall-clock time.
- Invoked or spawned actors use host child-workflow facilities when available.
- Unsupported runtime operations throw.

Custom actions are dispatched separately from actor-system effects. This keeps
host operations such as timers and child workflows visible to runtimes that do
not permit durable operations to be nested inside a generic activity. Both
callbacks receive the complete effect metadata. `runtime()` creates the host
runtime and receives the complete effect; `executeAction()` receives that
runtime when it executes the action.

`run()` resolves with the machine output when the machine is done, throws the
machine error when it fails, and throws `DurableExecutionCancelledError` when
it stops. It only starts fresh executions. A nonzero `transitionIndex`, or
calling a lower-level transition method before `run()`, causes
`DurableExecutionResumeError`; resume with the persisted snapshot and the
explicit transition loop instead.

The helper does not prescribe storage, inboxes, retries or timer
implementations. Hosts that restore from checkpoints instead of replaying from
the beginning should persist `durable.nextTransitionIndex` after every
transition, including transitions with no effects, and pass it as
`transitionIndex` when recreating the durable execution.

## Host adapters

Experimental adapters provide the common loop without hiding host-specific
semantics:

```ts
import { createDurable } from '@xstate/inngest';

const output = await createDurable(machine, {
step,
event: 'machine/event',
timeout: '30 days',
if: 'async.data.actorId == event.data.actorId',
runtime: ({ id }, effect) => host.runtimeFor(id, effect)
}).run(input);
```

`@xstate/inngest` maps actions and event waits to Inngest steps.
`@xstate/rivet` maps actions to workflow steps and uses a Rivet queue as the
inbox. Both expose `create…Adapter()` for the explicit transition loop and pass
the complete effect to `runtime` so the application can map timers, sends and
child actors without coupling XState core to either host.

These adapters deliberately do not approximate missing host semantics. For
example, awaiting a sleep inline cannot implement a cancellable timer while
also receiving intervening events. Such operations require a host-native
timer/inbox mapping; an unmapped operation throws.

## Adapter contract tests

Official adapters keep fast context-contract tests in this monorepo. They use
small host doubles and run in the normal CI suite, verifying stable IDs, action
execution, waits, outputs, errors and runtime-effect mapping without starting
vendor infrastructure. Unsupported mailbox, timer or child-actor semantics
remain explicit gaps.

## What next?

- [Run backend workflows](backend-workflows.md) when your application owns
snapshot storage and restores an actor per request.
- [Persistence](persistence.md) for snapshot versioning, migration and event
adaptation.
1 change: 1 addition & 0 deletions docs/meta.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
"cancellation",
"forms-and-wizards",
"backend-workflows",
"durable-execution",
"persist-and-restore-actors",
"testing",
"inspect-actor-systems",
Expand Down
8 changes: 8 additions & 0 deletions packages/core/durable/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"main": "../dist/xstate-durable.cjs.js",
"module": "../dist/xstate-durable.esm.js",
"umd:main": "../dist/xstate-durable.umd.min.js",
"preconstruct": {
"umdName": "XStateDurable"
}
}
18 changes: 17 additions & 1 deletion packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,20 @@
"import": "./dist/xstate-actors.cjs.mjs",
"default": "./dist/xstate-actors.cjs.js"
},
"./durable": {
"types": {
"import": "./dist/xstate-durable.cjs.mjs",
"default": "./dist/xstate-durable.cjs.js"
},
"development": {
"module": "./dist/xstate-durable.development.esm.js",
"import": "./dist/xstate-durable.development.cjs.mjs",
"default": "./dist/xstate-durable.development.cjs.js"
},
"module": "./dist/xstate-durable.esm.js",
"import": "./dist/xstate-durable.cjs.mjs",
"default": "./dist/xstate-durable.cjs.js"
},
"./validation": {
"types": {
"import": "./dist/xstate-validation.cjs.mjs",
Expand Down Expand Up @@ -87,6 +101,7 @@
"dev",
"graph",
"validation",
"durable",
"bin"
],
"keywords": [
Expand Down Expand Up @@ -124,7 +139,8 @@
"./index.ts",
"./actors/index.ts",
"./graph/index.ts",
"./validation/index.ts"
"./validation/index.ts",
"./durable/index.ts"
]
}
}
Loading