Overview
Where durable state lives. A config-driven StateStore with a Lucid driver that runs on Postgres, MySQL, or SQLite, an in-memory store for tests, and a migration shipped with @adonis-agora/durable.
A state store persists workflow runs and step checkpoints — the source of truth for both durability and the dashboard. It's a pluggable StateStore driver, independent of the transport, so you can mix any store with any transport.
Stores are config-driven drivers: list the ones you use under stores in config/durable.ts, built with the stores factory, and pick the active one by name with store. The lucid driver lazily imports @adonisjs/lucid only when selected.
Drivers
| Store | Database | Schema | |
|---|---|---|---|
| in-memory (the default) | — | — | For tests and local dev. Not durable. |
stores.lucid(...) | Postgres · MySQL · SQLite | shipped migration | The production driver, on @adonisjs/lucid. |
The default — when config/durable.ts sets no store — is an in-memory store: zero setup, but state is lost on restart, so it's only for development and tests. For anything durable, select the Lucid store.
import { defineConfig, stores, transports } from '@adonis-agora/durable'
export default defineConfig({
store: 'lucid',
stores: {
lucid: stores.lucid(),
},
transport: 'memory',
transports: { memory: transports.memory() },
})The interface
A StateStore persists runs and checkpoints and exposes the primitives the engine needs for durability:
- Runs & checkpoints —
createRun,updateRun,updateRunIf,getRun,deleteRun,saveCheckpoint,getCheckpoint,listCheckpoints.createRunmust reject a duplicate id (throw — a SQL primary-key violation is exactly right) rather than overwrite: the engine treats the throw as "someone else already started this id", re-reads the existing run and returns its state, which is what makesstart/signalWithStart/ racing scheduler ticks converge on one run instead of forking or clobbering the winner. - Recovery & leasing —
listIncompleteRuns,listPendingRuns(limit)(so workers poll forpendingruns),listDueTimers(nowMs, namespace?, limit?)(the due runs most overdue first, ascendingwakeAt, with or without alimit; asuspendedrun carryingwakeRequestedAtis due whatever itswakeAt, andupdateRun/getRunmust round-trip that field; the engine passes itstimerBatchSizeas thelimitto cap one timer pass, and a store may ignore the cap), and the atomictryLockRun/renewRunLock/releaseRunLockthat make one-instance-per-run leasing work.releaseRunLock(runId, owner?)is owner-fenced: whenowneris given, release only if that owner still holds the lease (a conditional write, atomic likerenewRunLock) — so a stale executor whose lease was taken over can't wipe the new owner's lease and open the door to a third concurrent executor. Omittingowneris the operator-style unconditional clear (requeue's stale-lease reset); a store implemented against the older single-argument signature simply ignoresownerand degrades to the previous unfenced behavior. - Signals —
putSignalWaiter/takeSignalWaiter/listSignalWaiters/removeSignalWaiter, plusbufferSignal/takeBufferedSignalfor the signal machinery. - Events —
bufferEvent/listBufferedEvents/removeBufferedEvent, so a published event that matched no live waiter is still consumed by a laterctx.waitForEvent. - Querying —
listRuns(query)for the dashboard and CLI.RunQuerycarries the status/workflow/tag/namespace/attribute filters plus exact-matchoriginand the epoch-ms range pushdownscreatedBefore/createdAfter/updatedBefore/wakeBefore— the last two are what let the retention/stalled sweeps and the blocked-run poll ask the store for candidate rows instead of fetching everything and filtering in process. Paging ridespage(1-based, default1) andsize— the same offset shape@adonis-agora/filtertakes, so every Agora listing is paged alike. Resolve the pair with the exportedrunPageWindow(query)helper rather than by hand: it returns the{ limit, offset }your adapter spends, and it is what the conformance kit asserts against (nosizemeans no bound — a store must not invent a default window).
The members below are optional, and the engine works without every one of them:
| Optional member | What you lose by omitting it |
|---|---|
ensureSchema() | Boot-time provisioning. A store with nothing to provision (in-memory) simply has no schema to create. |
transaction(work) | ctx.transaction — the exactly-once "business write and checkpoint commit together" primitive — throws on a store without it. |
recordStepHeartbeat(...) | Step heartbeats are discarded, so a long remote step shows no liveness in the dashboard or durable:runs. |
getLatestCheckpointByName(...) | A fast path only. The engine falls back to listCheckpoints plus an in-JS filter. |
listCheckpointsByNamePrefix(...) | Same — a fast path, with the same fallback. |
listOrphanedRuns(nowMs, limit, namespace?) | The bounded orphan query the periodic recovery pass prefers: the running runs whose lease is free (lockedUntil unset or <= nowMs), oldest createdAt first, at most limit, restricted to namespace when given. On a healthy fleet that is about zero rows per tick. Without it the engine falls back to listIncompleteRuns and filters in process — correct, but on a busy fleet every worker fetches (and lock-probes) every healthy running run once per tick. Strongly recommended. |
releaseRunLockUnlessWakeRequested(runId, owner) | The conditional release a suspending turn lets go of its lease with: release owner's lease only while the run carries no wakeRequestedAt, in one write, and resolve whether it released (true too when the lease is not owner's). A wake refused by the lease stamps wakeRequestedAt, so the holder keeps the lease and runs another turn instead of losing the wake. Without it the engine releases, then reads the run back and wakes it if a request landed; a request landing in between is picked up by the timer poll. |
getRuns(runIds) | The batched run read (id IN (...), any order, unknown ids left out; the engine passes at most 500 ids). Every turn of a ctx.all parent reads the children it still waits on through it, to settle the ones whose completion never reached it. Without it the engine calls getRun once per child, 50 at a time. |
listSignalWaitersByRunIds(runIds) | The targeted per-run waiter lookup (run_id IN (...), indexed). Without it the dashboard's waiting column and the engine's child resolution (run detail, cancel cascades, deleteRun/purgeRuns subtree walks) fall back to listSignalWaiters('') — a scan of every waiter, which a wide ctx.all fan fills with one row per running child. Pair the implementation with an index on run_id. |
hasSignalWaiter(runId, tokenPrefix) | The ctx.all join barrier: whether a run still has a waiter whose token starts with an exact prefix (%/_ literal, case-sensitive). It runs once per settled child, so answer it with a bounded, run_id-indexed lookup. Without it ctx.all stays correct, but the parent replays once per settled child, O(N²) across a fan. |
hasCheckpoints(runId, fromSeq, toSeq, statuses) | The step fan join barrier: whether any of the run's checkpoints with fromSeq <= seq <= toSeq has one of statuses (an empty statuses answers false). The barrier asks it on every batch of a fan's results ("is any step still pending?"). Read from the top of the range down over the (run_id, seq) key and stop at the first match (ORDER BY seq DESC LIMIT 1): a fan's steps are dispatched in seq order, so its highest seqs settle last and the answer comes from the first rows read. Without it the engine asks countCheckpoints. |
countCheckpoints(runId, fromSeq, toSeq, statuses) | How many of the run's checkpoints with fromSeq <= seq <= toSeq have one of statuses (an empty statuses counts nothing); the step fan's barrier asks it on a store without hasCheckpoints. Answer it with a COUNT over the (run_id, seq) key, never by loading rows. Without either, the engine loads the run's checkpoints and checks them in process, once per result. |
saveCheckpointIfPending(checkpoint) | The compare-and-set write for a dispatched step's row: overwrite it only while the stored row is still pending, and resolve whether it wrote. A turn stamping a redispatch deadline and a worker's result settling the same step race on that row. Without it the engine falls back to saveCheckpoint, which can put a completed step back to pending. |
registerChildWaits(entries) | Batched fan dispatch: put each entry's waiter and upsert its running placeholder checkpoint, in one transaction. A waiter must be durable no later than its placeholder, because the engine reads a placeholder as proof its waiter exists. Without it the waiters and placeholders are written one by one. |
compactCheckpoints(runId, fromSeq, toSeq, into) | Folding a settled ctx.all fan: atomically replace the run's checkpoints in a contiguous seq range by one checkpoint. Without it a settled fan keeps one checkpoint per child. |
runValueFacets(axis, query, opts?) | Native picker enumeration (distinct values + counts per filter axis). Callers fall back to counting a bounded listRuns scan in-process — same shape, bounded approximation on the non-column axes. |
countRuns(query) | A single COUNT(*) over the same predicates listRuns applies. The start-time concurrency quota asks it on every start, so it must stay an aggregate. Without it the engine counts a listRuns result. |
getCheckpoints(runId, seqs) | The run's checkpoints at seqs, in seq order, missing seqs left out. The engine reads a batch of one run's step results' checkpoints through it. Without it, one getCheckpoint per seq. |
saveCheckpoints(checkpoints) | saveCheckpoint for many distinct rows, in one transaction. A step fan's dispatching turn writes its pending checkpoints through it. Without it, one saveCheckpoint per row. |
listBufferedSignals(tokenPrefix, afterId, limit) + deleteBufferedSignals(ids) | Implement both or neither. Pages buffered signals whose token starts with an exact prefix (%/_ literal), in insertion order after afterId, and deletes rows by id. The engine's background sweep uses them to drop child: completions no parent will consume, deciding each page with one getRuns and one getCheckpoints per live parent. Without them the sweep does nothing. |
saveCheckpointsIfPending(checkpoints) | saveCheckpointIfPending for a batch: one boolean per entry, in input order, with a later entry for the same row compared against the earlier one's write; atomic as a whole (if it rejects, it wrote nothing). The engine settles a batch of one run's step results through it. Without it each result gets its own saveCheckpointIfPending. |
settleCheckpointsIfPending(settlements) | Settling step results without rewriting their rows: per entry, in input order, while the row at (runId, seq) is still pending, write the entry's status, output, error, events, startedAt and finishedAt (an absent value as absent) and leave every other column (input, attempts, wakeAt, …) as stored; one boolean per entry, a later entry for a row an earlier one settled refused, atomic as a whole. The engine settles every worker result through it, a batch per call. Without it the engine writes each settled step's whole row through saveCheckpointsIfPending / saveCheckpointIfPending, so a step's input (an agent's LLM step carries its whole prompt) is written a second time when its result lands. |
getCheckpointsForSettle(runId, seqs) | getCheckpoints, each row without its input, left out of the query itself. A worker result's settle reads the step it settles through it, so an LLM step's prompt is not read back when its answer lands. The engine uses it only when the store also has settleCheckpointsIfPending: without that, a settle (and the salvage of a late success onto a failed run) writes the row it read back whole, input included. |
listCheckpointsForReplay(runId) | listCheckpoints, except that a completed row comes back without its input, left out of the query itself. The engine reads a run's checkpoints through it at the start of every turn; replaying a completed step needs only its output, while a pending, running or failed row keeps its input for a re-dispatch or a retry. Without it every turn reads the input of every step the run ever completed. The run detail, durable:runs and engine.listCheckpoints keep reading through listCheckpoints. |
deleteRuns(runIds) | deleteRun for a batch, in one transaction. Retention and purgeRuns delete a pass with one call. Without it they call deleteRun per run. |
runFacets(query) | (status, origin) counts in one GROUP BY, absent or blank origins under null. Without it engine.runFacets counts a listRuns result. |
checkpointStats(runId, opts?) + queryCheckpoints(runId, query) | Implement both or neither. checkpointStats is one GROUP BY parallel_group, status, <name kind> returning, per bucket, a count, the seq and name bounds, the earliest enqueuedAt (read as startedAt when null), the start and finish bounds and an attempt sum; the name kind is child (signal:child:), folded (fan:), spawn (spawn:) or step. queryCheckpoints(runId, { group?, excludeGroups?, statuses?, namePrefixes?, offset?, limit? }) pages rows in seq order; excludeGroups keeps ungrouped rows, and an empty statuses or namePrefixes matches nothing. The run detail is built from the buckets and a few paged reads, and carries an ETag. Without them it loads every checkpoint on every poll and has no ETag. The name kind and the prefix filters must be case-sensitive, like startsWith, even on a case-insensitive collation. |
pruneBufferedEvents(publishedBefore, limit) | The eventBufferTtl prune across every event name. Without it expired events are only skipped (and removed) when a waitForEvent for their name scans them. |
tryAdmitSingleton(runId, tag, workflow, limit, retryWakeAt?) | Singleton workflows: the atomic slot claim. Read the key's active runs and claim a slot in one transaction that serializes competing claims before it reads (SELECT … FOR UPDATE, or a write lock taken first). Holders keep the reserved durable:singleton:admitted tag until updateRun/updateRunIf writes a terminal status, which must drop it. On denial, park the run suspended at retryWakeAt under the same lock, never over a run that is no longer active. Build the decision on the exported singletonAdmissionTags / singletonAdmissionDenialPatch. Without it, singleton workflows still work, but fall back to the older scan-based admission (not atomic: replicas can briefly over-admit), and the engine warns once. |
withScope(scope) | Not declared on StateStore, but detected at boot: a store that has it returns a view whose run reads are fenced to scope.namespace. scopeReads needs it; without it scopeReads silently has no effect. The Lucid and in-memory stores implement it. |
saveSchedule / getSchedule / updateSchedule / deleteSchedule / listSchedules | Persisted schedules. Implement all five or none: engine.schedules refuses to run on a store without them. updateSchedule(id, patch, expectedNextFireAt?) is a compare-and-set on nextFireAt (null matching NULL) — how two schedulers racing on one due schedule agree on who advanced it. |
Everything else is required, including updateRunIf.
The Lucid driver implements every optional member in the table. updateRunIf is one conditional UPDATE … WHERE id = ? AND status IN (…), saveCheckpointIfPending is one conditional UPDATE … WHERE status = 'pending', never a read followed by a write, hasCheckpoints is one SELECT seq … ORDER BY seq DESC LIMIT 1 over the run's seq range, and countCheckpoints and countRuns are single COUNT(*) queries.
Two things a store must not try to do in SQL: decide whether a child failed, or count the children a folded checkpoint stands for. Both live in the payload ({ ok: false }, output.values), which a CodecStateStore may have encrypted. The engine loads those rows through queryCheckpoints and reads them after decoding.
updateRunIf — the compare-and-set write
updateRunIf(
runId: string,
expectedStatuses: RunStatus[],
patch: Partial<WorkflowRun>,
): Promise<boolean>Apply patch only if the run's currently persisted status is one of expectedStatuses at write time. It returns true when the write landed and false when the predicate did not match. A non-match is a normal outcome, never a throw.
The predicate must be atomic with the write
For a SQL store the predicate belongs in the UPDATE … WHERE clause. For an in-process store it must be a synchronous check with no await between reading the status and mutating the row. A read-then-write in application code is a TOCTOU race, not a substitute: two racing callers can both observe a matching status before either writes.
Why it exists
A workflow turn computes its outcome from a run snapshot loaded when the turn began — and a turn can run for minutes across remote steps. Meanwhile the run's persisted status can change through a completely different path: an operator cancels it, the execution-timeout sweep marks it cancelled, a poison run is dead-lettered, or ctx.all in failFast mode cancels a sibling.
With a plain updateRun, the turn's now-stale terminal write lands last and resurrects a terminal run: a cancelled run silently reports completed, its output is written, its parent is notified, and a run.completed event fires. updateRunIf moves the "is this run still eligible?" test into the same atomic write, so the loser of the race gets false, emits nothing, and reports the run's true current status instead.
The engine leans on it in five places — the execution-timeout sweep, all three terminal/suspend transitions of a settling turn, and parking a run as blocked — and the conformance kit covers it in three cases, including the two-sequential-writes race directly.
Here is the Lucid driver's implementation, as the shape to copy:
const affected = await trx
.from(DURABLE_TABLES.runs)
.where('id', runId)
.whereIn('status', expectedStatuses)
.update(row)
return rowsAffected(affected) === 1Writing a custom store
Implement every required member above, then run the shared conformance contract against it — that is the definition of "correct", not the prose here:
import { runStateStoreContract } from '@adonis-agora/durable/testing/conformance'
import { MyStateStore } from '../src/my_state_store.js'
runStateStoreContract('MyStateStore', async () => {
const store = new MyStateStore(/* connection */)
await store.ensureSchema?.()
return {
store,
cleanup: async () => {
await store.close()
},
}
})The contract requires a few of the optional members above, because the engine's fallback for them is slow or unsafe: getLatestCheckpointByName, listCheckpointsByNamePrefix, countCheckpoints, hasCheckpoints, countRuns, saveCheckpointIfPending, saveCheckpointsIfPending, deleteRuns, runFacets, pruneBufferedEvents, the five schedule methods, and the checkpointStats + queryCheckpoints pair — whose answers are compared against in-memory references, and whose run detail and fan pages must equal the ones built from a full read. A store with withScope also has its scoping checked. A store that leaves one out fails its case. It also runs cases for settleCheckpointsIfPending (a refused entry, a duplicate in one call, every column a result does not carry left as stored, a batch wider than one statement) getCheckpointsForSettle (every row loses its input, everything else reads as getCheckpoints reads it) and listCheckpointsForReplay (only a completed row loses its input, everything else reads as listCheckpoints reads it) when the store has them. For the last two of the required members it also checks that both seq bounds are inclusive, that a count covers only its own run, that a refused write leaves a completed or failed row unchanged, and that the settling write replaces the pending row whole, clearing its wakeAt.
The factory runs once per test case, so each case gets a fresh store. Members that are optional for the engine but not required by the kit (listOrphanedRuns, listSignalWaitersByRunIds, hasSignalWaiter, registerChildWaits, compactCheckpoints, runValueFacets, tryAdmitSingleton, transaction) are checked only when the store has them. Two escape hatches exist for drivers that genuinely cannot satisfy a slice of the contract, returned from the factory next to store and cleanup: supportsAsyncTransaction: false (a synchronous driver) and supportsTagFilter: false. If the backing infrastructure is not available — no Docker for a testcontainers store — throw StateStoreUnavailableError from the factory and the suite skips instead of failing.
The conformance kit lives on the @adonis-agora/durable/testing/conformance subpath rather than @adonis-agora/durable/testing, because it generates the suite with vitest's describe/it. See Testing.
Auto-schema
By default the provider calls the selected store's ensureSchema() at boot — for the Lucid driver, the same idempotent createDurableTables(db, connection) the published migration runs. Set autoSchema: false in config/durable.ts to own the schema through migrations only; the in-memory store has no schema, so the flag is a no-op for it. See the Lucid page for the tables it creates and the upgrade caveat. It only ever adds the durable tables — it never touches yours.
Exactly-once DB writes with ctx.transaction
A plain ctx.step is at-least-once: if the process dies after the step's business write commits but before its checkpoint does, the step re-runs on replay. ctx.transaction(name, fn) closes that gap by running fn and saving the step's checkpoint inside one store transaction, so they commit or fail together. It relies on the optional transaction(work) member: the store hands work its native transaction handle (raw) plus a saveCheckpoint that writes inside that same transaction.
For the Lucid driver, raw is the Lucid TransactionClientContract opened on the store's connection — run your writes on it, not on the app's regular db service, or they land outside the transaction. On a store without transaction(), ctx.transaction throws. See Transactional steps for a full example.
Retention & pruning
Terminal runs (completed, failed, cancelled, dead) otherwise accumulate forever. The retention config — per-status ages plus scoped policies — is swept by the durable:work tick, throttled to one pass a minute per instance. The sweep needs nothing beyond the required members: it asks listRuns for up to 100 candidates per pass using the statuses and updatedBefore pushdowns, then deletes them (and their child subtrees) through deleteRuns in one call, or deleteRun per run on a store without it. Any store that implements the required interface supports retention. See Retention & archival.
Encrypting payloads at rest
CodecStateStore decorates any store and runs run/step payloads through a PayloadCodec — encoded on write, decoded on read — so inputs and outputs are never stored in the clear. Searchable metadata (id, status, workflow, tags, namespace, timestamps) is left untouched, so the dashboard, queries and recovery keep working.
Know what the default coverage leaves in the clear: step events (everything step code passes to ctx.log), the structured error messages, and heartbeat progress. A deployment described as "encrypted payloads" still stores whatever its steps log as plaintext. Opt into new CodecStateStore(inner, codec, { coverage: 'extended' }) to cover those too — but only on a fresh table, or with a codec whose decode recognizes its own ciphertext and passes other values through, because rows written before the switch hold those fields unencoded. Search attributes always stay clear (they must remain queryable). And remember the console renders everything decoded — pair the codec with the dashboard config's redact hooks to keep PII out of the operator's browser.
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto'
import { CodecStateStore, defineConfig, stores, type PayloadCodec } from '@adonis-agora/durable'
import env from '#start/env'
const key = Buffer.from(env.get('DURABLE_PAYLOAD_KEY'), 'base64') // 32 bytes
const aesCodec: PayloadCodec = {
encode(value) {
const iv = randomBytes(12)
const cipher = createCipheriv('aes-256-gcm', key, iv)
const body = Buffer.concat([cipher.update(JSON.stringify(value), 'utf8'), cipher.final()])
return {
iv: iv.toString('base64'),
tag: cipher.getAuthTag().toString('base64'),
body: body.toString('base64'),
}
},
decode(value) {
const { iv, tag, body } = value as { iv: string; tag: string; body: string }
const decipher = createDecipheriv('aes-256-gcm', key, Buffer.from(iv, 'base64'))
decipher.setAuthTag(Buffer.from(tag, 'base64'))
const plain = Buffer.concat([decipher.update(Buffer.from(body, 'base64')), decipher.final()])
return JSON.parse(plain.toString('utf8'))
},
}
export default defineConfig({
store: 'encrypted',
stores: {
// Build the Lucid store with its own factory, then wrap it. The factory resolves Lucid's `db`
// from the container at boot; `@adonisjs/lucid/services/db` is still undefined at that point.
encrypted: async (ctx) => new CodecStateStore(await stores.lucid()(ctx), aesCodec),
},
})encode and decode are synchronous and decode must be the exact inverse of encode — wrap an async KMS behind a local cache yourself. Beyond run and checkpoint payloads the decorator also encodes buffered signal and event payloads.
CodecStateStore never hides a capability of the store it wraps. saveCheckpointIfPending, saveCheckpointsIfPending, settleCheckpointsIfPending, getCheckpointsForSettle, listCheckpointsForReplay, countCheckpoints, hasCheckpoints, registerChildWaits, compactCheckpoints, hasSignalWaiter, deleteRuns, runFacets, pruneBufferedEvents, tryAdmitSingleton, withScope, the checkpointStats + queryCheckpoints pair and the five schedule methods are bound only when the inner store has them, so the engine's fallbacks still apply through the wrapper. A few members are always present on the wrapper and fall back inside it when the inner store lacks them: listOrphanedRuns (filters listIncompleteRuns), listSignalWaitersByRunIds (filters a full waiter scan), countRuns (counts a listing), runValueFacets (counts a bounded scan), getLatestCheckpointByName and listCheckpointsByNamePrefix (filter listCheckpoints), recordStepHeartbeat (a no-op) and transaction() (throws). Payload-carrying writes (updateRun and updateRunIf patches, saveCheckpointIfPending, the placeholder checkpoints of registerChildWaits, the folded checkpoint of compactCheckpoints, a schedule's spec) are encoded; countCheckpoints, hasCheckpoints and hasSignalWaiter read no payload and are forwarded verbatim. recordStepHeartbeat encodes the progress payload under extended coverage, the run reads (getRun, listRuns, listPendingRuns, listDueTimers, listOrphanedRuns) decode what they return, and transaction() encodes the checkpoint its saveCheckpoint writes. The batch and aggregate members follow the same rule: saveCheckpointsIfPending encodes every entry, settleCheckpointsIfPending encodes each settlement's output (and events and error under extended coverage), getCheckpointsForSettle and listCheckpointsForReplay decode the rows they return, queryCheckpoints decodes the rows it returns, checkpointStats, runFacets, deleteRuns and pruneBufferedEvents read no payload and are forwarded verbatim, and withScope wraps the inner store's scoped view in the same codec.
Control plane
The cross-instance broadcast channel for lifecycle events and cancellation — separate from the point-to-point task transport. Omit it and the engine is local-only; pick controlPlanes.redis to fan out across every replica over Redis pub/sub, interoperable with a NestJS fleet.
Lucid
The Lucid StateStore driver — persist runs and checkpoints to Postgres, MySQL, or SQLite through @adonisjs/lucid. The migration ships with @adonis-agora/durable; select the store with stores.lucid().