State & persistence
Persistence is explicit by default: only ctx.save() writes. A method that
returns success has persisted what it acknowledged.
Explicit saves
methods: (ctx) => ({
async addItem(item: Item) {
ctx.state.items.push(item);
await ctx.save(); // durable before we ack
return ctx.state.items.length;
},
})
ctx.state is a deep reactive proxy — mutate it directly, no setter and no immutable update
needed. ctx.save() writes the whole state through the ActorStorage
seam with an etag, so a conflicting writer is detected rather than overwritten.
Forget the await and you have told the caller a thing is durable when it is not. That is the
one mistake this API can't stop you making, which is why the default is explicit.
Deferring a save
await ctx.save({ durability: 'eventual' });
ctx.save() takes SaveOptions (exported from @sigx/actors). 'immediate', the default,
resolves once the record is stored. 'eventual' marks the state for the activation's
write-behind debounce and resolves at once, so a burst of eventual saves costs one write. The
actor stays explicit: no write happens that was not asked for. A later immediate save() or
the deactivation flush picks up whatever is still pending.
The write stores the state as it stands when it runs, not as it stood at the call. What an
eventual save gives up is the acknowledgement: a host death inside the debounce window loses
the eventual saves since the last durable one, and a failed write reports through
onStateError rather than to a caller. ctx.clearState()
drops an eventual save that is still pending, so a deleted record is not written back.
Write-behind
persistence: { mode: 'write-behind', debounceMs: 200 }
Saves automatically after mutating turns, debounced. Pending writes flush on deactivation and on shutdown.
Acked ≠ persisted under write-behind. A method can return success for state that is still only in memory. Use it for state you can afford to lose — a presence marker, a scroll position, a hit counter — and not for anything a user believes they have saved.
When a deferred save fails
A write-behind save, or an explicit actor's ctx.save({ durability: 'eventual' }), has no
caller to throw to. onStateError on the definition hears about it instead:
import { defineActor, type StateErrorPhase } from '@sigx/actors';
defineActor({
type: 'Presence',
persistence: { mode: 'write-behind', debounceMs: 200 },
async onStateError(ctx, error, phase: StateErrorPhase) {
if (phase === 'final-flush') await deadLetter(ctx.ref, ctx.snapshot(), error);
else reportSaveFailure(ctx.ref, error);
},
// …
});
phase is a StateErrorPhase — 'flush' | 'final-flush':
| Phase | Which save | What it means for the state |
|---|---|---|
'flush' | the debounced save between turns; the hook runs inside that flush's own turn, so ctx.state is the frame that failed to save | A transient storage error leaves the state dirty and schedules no retry of its own: the next write, or the final flush at deactivation, carries it — a non-mutating turn does not. An etag conflict (ActorStateConflictError) discards it — see below. |
'final-flush' | the save at deactivation, after the queue drains | The last chance for those writes, so a failure here is lost data. Dead-letter ctx.snapshot() from here. |
An etag conflict in the 'flush' phase means another activation wrote first. Once the hook
returns, the activation is torn down with no further call: onDeactivate(ctx, 'conflict')
fires, the final flush is skipped so the stale state never overwrites the winner, and the next
call activates against the winning state. The hook is the last time that state is
observable, which makes dead-lettering ctx.snapshot() there the right move.
The hook is awaited in both phases, and a throw from it is dev-logged and ignored. It only
observes — it cannot retry the save for you. An explicit ctx.save() that fails still rejects
the turn that called it and never reaches the hook; a ctx.save({ durability: 'eventual' }),
which resolves before its write, reports through it like a write-behind flush. Without the
hook, a dev build logs the failure.
Rich types survive
Date, Map, Set, bigint, URL, RegExp and your own registered handlers round-trip
through storage and the wire, because both use the same
@sigx/serialize vocabulary.
state: () => ({ lastVisit: null as Date | null, tags: new Set<string>() }),
lastVisit is a real Date when it comes back out of storage, and a real Date in the
browser. Register your own types with defineTypeHandler and add them via the app's types:
option or a plugin's addTypeHandlers.
Conflicts
Every save is compare-and-set on the etag. If another activation wrote first, yours raises
ActorStateConflictError and the stale activation is faulted — the next call activates fresh
against the winning state.
You do not normally handle this. It means the single-activation invariant was briefly violated — a network partition, a directory hiccup, a migration — and the recovery is automatic. What it is good for is alerting: a steady trickle of conflicts says something in your cluster is wrong. See Metrics.
Re-running queued turns
By default a conflict fails more than the losing turn: every turn queued behind it rejects
with ActorStateConflictError too, and the activation is discarded
(onDeactivate(ctx, 'conflict')). For an actor whose methods are safe to re-run, opt out of
that:
defineActor({
type: 'Tally',
retryQueuedOnConflict: true,
// …
});
The losing turn still rejects — its writes were computed on stale state and are discarded, and
its caller retries. But the activation stays. Before the next turn runs, the winning record is
reloaded in place (through migrateState, as on activation) and the queued turns run in their
original order against it, as if they had arrived after the other writer. There is no
deactivation, no second onActivate and no re-dispatch. Change-feed subscribers never see the
losing turn's writes; the winning state is their next boundary.
Only for methods that are idempotent or commutative. A queued turn is re-run against state it did not see when it was queued, so a method that reads, decides and then writes can act twice or act on the wrong premise.
The scope is deliberately narrow:
- Turn-path saves only. A write-behind or eventual-save flush conflict still deactivates, as above.
- Serial actors only. With
reentrant: 'always', or amethodReentrancymap naming any method, the first activation fails — interleaved turns are never queued, so there is nothing to re-run.reentrant: 'call-chain'is fine. - A reload that fails faults the activation exactly as without the option.
defineActoronly —defineWorkerhas no state to reload.
Migrating state
migrateState evolves a record whose shape predates this deploy — the answer to "the state:
shape changed and the stored records didn't."
defineActor({
type: 'Cart',
state: () => ({ v: 2, items: [], coupons: [] }),
migrateState: (stored) => {
const s = stored as CartV1 | CartV2;
if ('v' in s) return s; // fast path
return { v: 2, items: s.items ?? [], coupons: [] }; // v1 → v2
},
});
When it runs. Between the storage read and activation, and only on a load that found a
record — never on the fresh state(key) path, never after ctx.clearState(). Always before
onActivate, which therefore always sees migrated state.
What it receives. The codec-revived state, so Date and Map are already real objects.
unknown here means unknown shape, not raw JSON. A second argument carries { raw, key }
when the revived view cannot tell two stored versions apart — raw is the encoded record as
storage holds it.
Returning the input unchanged is the fast path, and identity is how that is detected. To migrate, return a new object.
Write-back is lazy, and that is the contract. The migrated shape rides the next save the
actor would have made anyway, so a read-only activation still issues zero writes and a rolling
deploy costs no extra ones. This holds in both persistence modes — migrateState never causes
a write by itself, so a write-behind actor that is only ever read after a migration does not
persist it.
For a record that would otherwise never be saved at all — and so would be re-migrated on every activation forever — opt into one CAS write-back at activation:
migrateState: { persist: 'eager', migrate: (stored) => /* … */ },
Concurrent migration is safe by design. A fleet mid-deploy can migrate the same record on
several hosts. The hook is a pure function of the stored value and every write is etag-CAS'd,
so first save wins and the loser either adopts the winner (eager) or takes
ActorStateConflictError and re-activates against it (lazy).
Failure is loud. The hook is synchronous; a throw fails activation with
ActorActivationError and leaves the stored record untouched. Corrupt state is never silently
reset. Malformed declarations throw at definition time, and an async hook is a type error.
Non-goals. Version-field bookkeeping is your convention — the runtime neither reads nor writes one. And this is not a scheme for versioning an actor's interface across a mixed-version fleet.
defineJobdoes not acceptmigrateState. A job's stored record is the job envelope —status,progress,checkpointand so on, with your own state underextra— so a hook over it would hand you a runtime shape you do not own. Migrating theextrahalf wants its own option, and is not part of this. See Jobs.
Appending entries
ctx.save() writes the whole state, so its cost grows with the state. ctx.append(entry)
writes one entry instead. Declare the reducer that folds an entry into the state, in place:
defineActor({
type: 'Ledger',
state: () => ({ balance: 0, entries: [] as Entry[] }),
applyEntry(state, entry) {
const e = entry as Entry;
state.balance += e.amount;
state.entries.push(e);
},
methods: (ctx) => ({
async post(entry: Entry) {
await ctx.append(entry); // folds it into ctx.state, then stores the entry alone
return ctx.state.balance;
},
}),
});
ctx.append runs applyEntry on the live state and appends the entry to the record's log
under the record's etag — O(entry) where ctx.save() is O(state). It is durable when it
resolves, and it is a write like any other: a conflict behaves exactly as a save's does,
including the retryQueuedOnConflict reload.
The record is a snapshot plus a log. On activation the log is replayed through
applyEntry onto the stored snapshot, in append order — after migrateState and
before onActivate. A full save — ctx.save(), a write-behind or eventual flush, the
deactivation flush — stores the folded state and truncates the log. A full save is the
compaction; there is no other.
applyEntry must be pure. It runs twice per entry over the record's life: once at append
and once at every replay, possibly on another host. Same state and entry, same result.
What an append makes durable is the entry. State written directly, not through the reducer, since the last full save is not in the log; the next full save carries it.
It works on every storage. Where the storage has no
appendText (fileStorage), or there is
no record yet, an append is a full save: same result, O(state) cost.
The rules at the edges:
ctx.appendwithoutapplyEntrythrows.- A record carrying a non-empty log fails activation under a definition without
applyEntry, naming the type — it was written by one that had it. - It is not available on a
tasks:body's context (usectx.turn) or on a stateless worker.
Jobs have the same seam as
job.append(entry).
Providers
| Provider | Use |
|---|---|
memoryStorage() | tests and dev |
fileStorage({ dir }) | dev — one cat-able JSON file per actor |
redisStorage | production, with the cluster providers |
pgStorage | production, text rows with etag CAS |
sqliteStorage | a single-node host — one file on node:sqlite |
Implement ActorStorage — load / save / clear with etags — for anything else. See
Storage.
Next steps
- Storage — the seam and how to implement it.
- Lifecycle — when state is loaded and flushed.
- Reentrancy — how interleaving changes save semantics.
