コンテンツにスキップ
日本語

Durable state

このコンテンツはまだ日本語訳がありません。

DurableStateActor<Command, S> is the “I just want the current value to survive” persistence model. No event log, no replay — just a snapshot of the state, overwritten on each persist(newState).

DurableStateActor

onCommand(command)

read this.state

next = transform(...)

await this.persist(next)

revision++

Durable State Store

Compared to PersistentActor:

AspectPersistentActorDurableStateActor
What’s storedEvery event everThe current state snapshot
RecoveryReplay eventsLoad the snapshot
Storage costGrows with eventsConstant per actor
HistoryYesNo
Audit / time travelYesNo
Concurrent writesSequentialOptimistic (revision check)

Pick durable state when history isn’t useful — feature flags, last-known configs, “current cart contents” without the audit trail.

import { DurableStateActor, DurableStateOptions, ActorSystem } from 'actor-ts';
import { InMemoryDurableStateStore } from 'actor-ts';
import { match } from 'ts-pattern';
type AddCommand = { kind: 'add'; sku: string };
type RemoveCommand = { kind: 'remove'; sku: string };
type ViewCommand = { kind: 'view'; replyTo: ActorRef<State> };
type CartCommand = AddCommand | RemoveCommand | ViewCommand;
type State = { items: string[]; };
class Cart extends DurableStateActor<CartCommand, State> {
constructor(options: DurableStateOptions<State>) { super(options); }
override async onCommand(command: CartCommand): Promise<void> {
await match(command)
.with({ kind: 'add' }, (c) => this.onAdd(c))
.with({ kind: 'remove' }, (c) => this.onRemove(c))
.with({ kind: 'view' }, (c) => this.onView(c))
.exhaustive();
}
private onAdd(c: AddCommand): Promise<void> {
return this.persist({ items: [...this.state.items, c.sku] });
}
private onRemove(c: RemoveCommand): Promise<void> {
return this.persist({ items: this.state.items.filter(s => s !== c.sku) });
}
private onView(c: ViewCommand): void {
c.replyTo.tell(this.state);
}
}
// Setup:
const system = ActorSystem.create('demo');
const store = new InMemoryDurableStateStore();
const durableStateOptions = DurableStateOptions.create<State>()
.withPersistenceId('cart-user-42')
.withStore(store)
.withEmptyState(() => ({ items: [] }));
const cart = system.spawn(
() => new Cart(durableStateOptions),
'cart',
);
cart.tell({ kind: 'add', sku: 'book-1' });
cart.tell({ kind: 'add', sku: 'book-2' });
// After restart: `this.state.items` is ['book-1', 'book-2'] again.
type DurableStateOptionsType<S> = {
persistenceId: string;
store: DurableStateStore;
emptyState: () => S;
};

Three fields:

  • persistenceId — the key under which the state is stored. Like PersistentActor, one ID per logical entity (cart-user-42, flags-region-eu, …), and subject to the same rules on what an ID may contain: non-empty, at most 255 characters, no / or \, no control characters. DurableStateOptionsValidator checks them in the constructor — earlier than preStart, because an ID that can never address a record is worth refusing before the actor is wired to a store — and reports a violation as an OptionsError on the persistenceId field.
  • store — the DurableStateStore implementation (in-memory, SQLite, object-storage, custom).
  • emptyState() — factory invoked when no record exists yet (first run, deleted state). Provides the initial value.

Pass them through the () => new Cart({...}) factory. The settings can vary per actor incarnation (different IDs for different users, same store).

Inside the actor’s handlers:

this.state // current state value — read anywhere
this.revision // monotonic counter, bumped on every persist
this.persist(s) // overwrite the stored state with `s`, returns Promise<DurableStateRecord<S>>

this.state is synchronous — the framework loads the state in preStart, and state returns whatever’s currently set in memory. Before the first persist (or load), it returns emptyState().

this.persist(next) writes the new state to the store with the current revision + 1. Returns once the store acknowledges. Inside onCommand, await it before treating the next state as authoritative.

State is stored in the tagged JSON tree format on every backend, so Date / Map / Set / bigint / Uint8Array round-trip as real instances — see what events and state may contain.

try {
await this.persist(next);
} catch (e) {
if (e instanceof DurableStateConcurrencyError) {
// Another writer beat us — reload and retry, or surface to the user.
}
}

When two writers update the same persistenceId concurrently, the store’s revision check rejects the second one with DurableStateConcurrencyError. Strategies:

  • Avoid the problem — make sure only one actor at a time writes to a given persistenceId. This is usually trivial: each cart-user-42 has one actor, on one node (via sharding or singleton).
  • Reload and retry — catch the error, reload state, recompute the new value, persist again. Works when the operation is idempotent.
  • Surface to caller — reply with an error; let the caller decide whether to retry.

For most actor-system patterns, concurrent writes shouldn’t happen — one entity per persistenceId, addressed via routing or sharding. If you see concurrency errors in production, that usually means two actors are writing the same key, which is a routing bug.

When durable state wins over persistent actor

Section titled “When durable state wins over persistent actor”

Three signals you’ve picked the right tool:

  • The state shape is simple (a single object, a small map). Overwriting the whole thing is cheap.
  • You don’t need an event stream — no projections, no audit log, no “show me how we got here” requirements.
  • Reads dominate writes — every read is a synchronous this.state, no replay.

Three signals you should reach for PersistentActor instead:

  • History matters — auditing, regulatory, “show the user a changelog.”
  • State is large and changes are small — writing the whole state on every change is wasteful; appending small events is cheaper.
  • You want projections — read-side views that need the event stream.

Like PersistentActor, durable state supports schema evolution through an adapter:

import { StateAdapter } from 'actor-ts';
const v1ToV2Adapter: StateAdapter<StateV2> = {
manifest: () => 'Cart',
toJournal: (state) => ({ manifest: 'Cart', version: 2, payload: state }),
fromJournal: (stored) => stored.version === 1
? migrate(stored.payload as StateV1)
: stored.payload as StateV2,
};
class Cart extends DurableStateActor<...> {
protected stateAdapter() { return v1ToV2Adapter; }
}

The persisted record gets wrapped in a { _v, _t, _e } envelope; on load, the adapter’s fromJournal runs to migrate older versions. See Migration overview for the full story.

Per-actor overrides are available:

class Sensitive extends DurableStateActor<...> {
protected encryption() { return { algorithm: 'aes-gcm', keyId: 'k1' }; }
protected compression() { return { algorithm: 'gzip' }; }
}

Honored by stores that implement them (object-storage with encryption, etc.); ignored by stores that don’t (in-memory, SQLite). See Object storage encryption for the durable-state encryption story.

The DurableStateActor API reference covers the full surface.