Перейти к содержимому
Русский

PersistentActor

Это содержимое пока не доступно на вашем языке.

PersistentActor<Command, Event, State> is the framework’s event-sourcing base class. The model:

  • Commands come in.
  • The command handler validates and decides what facts happened — those facts are events, persisted to the journal.
  • After persistence, a pure event handler updates state.

On startup, the framework reads the journal and replays every event through the event handler. The resulting state is what the actor sees on its first command — wherever the last instance left off, this one resumes.

import { PersistentActor, ActorSystem, ActorSystemOptions, type ActorRef } from 'actor-ts';
import { InMemoryJournal, InMemorySnapshotStore } from 'actor-ts';
import { match } from 'ts-pattern';
type DepositCommand = { kind: 'deposit'; amount: number };
type WithdrawCommand = { kind: 'withdraw'; amount: number };
type GetBalanceCommand = { kind: 'get-balance'; replyTo: ActorRef<number> };
type Command = DepositCommand | WithdrawCommand | GetBalanceCommand;
type DepositedEvent = { kind: 'deposited'; amount: number; ts: number };
type WithdrawnEvent = { kind: 'withdrawn'; amount: number; ts: number };
type Event = DepositedEvent | WithdrawnEvent;
const actorSystemOptions = ActorSystemOptions.create().withPersistence({
journal: new InMemoryJournal(),
snapshotStore: new InMemorySnapshotStore(),
});
type State = { balance: number; };
class Account extends PersistentActor<Command, Event, State> {
readonly persistenceId = 'account-42';
initialState(): State { return { balance: 0 }; }
// Pure: state + event → new state. Runs both on persist + recovery.
onEvent(state: State, e: Event): State {
return match(e)
.with({ kind: 'deposited' }, (ev) => this.onDeposited(state, ev))
.with({ kind: 'withdrawn' }, (ev) => this.onWithdrawn(state, ev))
.exhaustive();
}
private onDeposited(state: State, ev: DepositedEvent): State {
return { balance: state.balance + ev.amount };
}
private onWithdrawn(state: State, ev: WithdrawnEvent): State {
return { balance: state.balance - ev.amount };
}
// Handle a command — validate, persist if valid.
onCommand(state: State, command: Command): void {
match(command)
.with({ kind: 'deposit' }, (c) => this.onDeposit(c))
.with({ kind: 'withdraw' }, (c) => this.onWithdraw(state, c))
.with({ kind: 'get-balance' }, (c) => this.onGetBalance(state, c))
.exhaustive();
}
private onDeposit(c: DepositCommand): void {
this.persist(
{ kind: 'deposited', amount: c.amount, ts: Date.now() },
(next) => { /* side effects with the new state */ },
);
}
private onWithdraw(state: State, c: WithdrawCommand): void {
if (state.balance < c.amount) {
// Reject — don't persist anything.
this.log.warn(`insufficient funds`);
return;
}
this.persist(
{ kind: 'withdrawn', amount: c.amount, ts: Date.now() },
() => {},
);
}
private onGetBalance(state: State, c: GetBalanceCommand): void {
c.replyTo.tell(state.balance); // read-only — no persist
}
}
// Setup — wire journal + snapshot store inline via the create option:
const system = ActorSystem.create('demo', actorSystemOptions);
const account = system.spawn(Account, 'account-42');
account.tell({ kind: 'deposit', amount: 100 });
account.tell({ kind: 'withdraw', amount: 30 });
// state is { balance: 70 } — and stays that way across restarts.

Every PersistentActor subclass implements three methods:

The state before any events. Called at the start of recovery, and the result is what onEvent builds on top of.

initialState(): State {
return { balance: 0 };
}

Pure — no side effects, no tells, no awaits. Just state + event → state. This function runs both:

  • On replay — once per persisted event, when the actor starts and the journal is replayed.
  • After persist — once when a new event lands.

Pure-ness matters because the same events will replay many times over the actor’s lifetime. A side effect inside onEvent runs during every recovery, which means duplicate emails / duplicate HTTP calls / duplicated everything.

That is also why onEvent is synchronous while onCommand is async — the asymmetry is the point, not an oversight. A command decides, and deciding does I/O: persist writes to the journal. An event is already a fact, and folding a fact into state is arithmetic. Anything you would want to await here is precisely what must not run again on recovery; put it in the persist callback or onRecoveryComplete, neither of which replay.

Read state from the parameter, never this.state. During replay onEvent runs detached, before this.state has been assigned — and the DevTools time-travel panel borrows it the same way, as a free fold over any point in history. A handler that reaches for this.state works on the persist path and fails only after a restart, which is the most expensive way to find out.

onCommand(state, command) → void | Promise<void>

Section titled “onCommand(state, command) → void | Promise<void>”

Validates the command against the current state and decides what events to persist. Three valid outcomes:

  • this.persist(event, afterPersist) — persist an event. The callback runs with the new state once the event is appended and applied via onEvent. Side effects go here.
  • Reply without persisting — for read-only commands (e.g. { kind: 'get-balance' }), reach into state and tell the reply directly.
  • Reject — log, ignore, or reply with an error. No events written.
onCommand(state: State, command: Command): void {
match(command)
.with({ kind: 'get-balance' }, (c) => this.onGetBalance(state, c))
.with({ kind: 'withdraw' }, (c) => this.onWithdraw(state, c))
.exhaustive();
}
private onGetBalance(state: State, command: GetBalanceCommand): void {
command.replyTo.tell(state.balance); // read-only — no persist
}
private onWithdraw(state: State, command: WithdrawCommand): void {
if (state.balance < command.amount) return; // reject
this.persist({ kind: 'withdrawn', amount: command.amount, ts: Date.now() },
() => {});
}
this.persist(event, (newState) => {
// 1. event has been written to the journal
// 2. onEvent has been called; this.state and `newState` reflect it
// 3. it's safe to do side effects here
this.sender.forEach(s => s.tell({ ok: true, balance: newState.balance }));
});

Three guarantees the callback gives you:

  1. The event is durable — if the process crashes after this, the journal still has it. The next recovery picks it up.
  2. The state reflects itonEvent has run; this.state is the new state.
  3. Commands are stashed during the persist — incoming commands wait until the persist completes and its callback fires. No interleaving.

The third point is what makes persist safe to use as the “transaction boundary” for command processing. Side effects (replies, notifications, follow-up tells) belong in the callback.

When the actor starts:

preStart() runs:
→ load latest snapshot (if any) → set state, seqNr
→ read events from journal starting at seqNr+1
→ for each event, state = onEvent(state, event)
→ onRecoveryComplete(state)
→ ready to process the first command

While recovery is running, no commands are processed. The mailbox piles up; once recovery finishes, the actor drains them in order against the recovered state.

onRecoveryComplete(state) is an optional hook fired after the last event is replayed. Use it for one-time post-recovery setup (register watchers, fetch related actors, etc.) — but not for side effects per event, which would duplicate on every restart. A throw from this hook is an ordinary actor failure and goes to supervision; it is not a recovery failure, because the state recovered fine.

A corrupt event, a missing upcaster, a snapshot the integrity check refuses, a journal handing back its events out of order — recovery can throw, and then onRecoveryFailure is called.

It is a notification, not a decision. Recovery failure is terminal either way; the hook only chooses who hears about it:

// Default — rethrows, so the failure reaches supervision as an
// ActorInitializationError and the strategy decides.
onRecoveryFailure(reason: Error): void { throw reason; }
// Override that returns — you have taken the failure as handled,
// and the actor is then stopped.
override onRecoveryFailure(reason: Error): void {
this.metrics.recoveryFailed.inc();
}

The two integrity failures are exported classes, so the hook can tell them apart. Which of the two stores broke its contract is the first thing an operator needs, and both carry the persistenceId and the offending sequenceNr:

import { JournalIntegrityError, SnapshotIntegrityError } from 'actor-ts';
override onRecoveryFailure(reason: Error): void {
if (reason instanceof JournalIntegrityError) {
this.log.error(`journal broke at ${reason.sequenceNr} for ${reason.persistenceId}`);
} else if (reason instanceof SnapshotIntegrityError) {
this.log.error(`snapshot ${reason.sequenceNr} of ${reason.persistenceId} is not trustworthy`);
}
}

The actor cannot carry on either way: state was never assigned and lastSequenceNr is unknown, so there is no state in which it could answer a command. Stopping is what keeps that visible — commands already queued become dead letters instead of disappearing into the stash, and the stop is published on the event stream.

Before a single event reaches onEvent, replay checks that what journal.read() returned matches the Journal contract: sequence numbers ascending, contiguous, and inside the window that was asked for. Anything else raises JournalIntegrityError.

Fatal rather than a warning, because the fold is recovery. A shuffled stream replays history in an order it never happened in — a disableAccount landing before the setPassword it was meant to follow — and it leaves the actor’s sequence on the last event delivered rather than the highest one, after which every persist fails with a JournalConcurrencyError pointing at a perfectly healthy journal, one restart away from its cause.

The same check refuses a history whose beginning is missing: events compacted away with no snapshot covering them. That state cannot be reconstructed, and folding the surviving tail onto initialState() would invent one. Compact only past a snapshotdeleteHistory(seq) keeps the snapshot at seq for exactly this reason.

The DevTools time-travel panel shares this replay code but opts out of that last part. It asks what state looked like in the past, reaching for the newest snapshot before the target, so it routinely lands on a window whose covering snapshot has since been pruned. There it shows a partial fold — with the sequence it reached and the number of events it applied — rather than refusing to open.

Every PersistentActor declares a persistenceId:

class Account extends PersistentActor<...> {
readonly persistenceId = 'account-42';
}

The ID identifies the event stream in the journal. Two actors with the same persistenceId would share the same event log — usually a bug.

For per-entity actors (one account per user, one cart per user), make the ID dependent on the entity:

class Account extends PersistentActor<...> {
constructor(public readonly userId: string) { super(); }
readonly persistenceId = `account-${this.userId}`;
}

For sharded entities, the shard region typically passes the entity ID via constructor, and the persistence ID derives from it.

An ID is rarely a constant — it is usually built from user input (`account-${request.params.id}`, a sharding entity ID). It is always bound as a query parameter, so SQL injection is not reachable, but the ID is more than a value: it names a stream, and several backends build a structured storage key out of it. preStart therefore refuses an ID that cannot be one, before the journal is ever touched:

RejectedWhy
emptyevery backend would happily write under '', so an ID some code path forgot to fill in silently shares one stream with every other actor that forgot the same thing
longer than 255 charactersthe width of the persistence_id column in every relational dialect’s DDL, and part of its primary key — a longer ID is truncated (two IDs collapsing onto one stream) or rejected deep inside a driver
/ or \the object-storage stores lay an ID out as a directory (<prefix><persistenceId>/<seq>.json) and read it back by listing that prefix, so a/b nests inside aa’s loadLatest then returns a/b’s snapshot, and its delete prunes it
. or .. (the whole ID)traversal meaning, one level up, with the same consequence
control charactersIDs are interpolated into a log line on every recovery and every persist, so a newline lets a caller forge log records

Everything else is allowed, deliberately. A comma is fine — the comma-separated column in the SQLite journal carries tags, and the ID is a separate bound column. A | is fine too, and the chat example relies on it: a DM channel is dm-channel-alice|bob. The projection offset store joins keys as <projection>|seq|<persistenceId> with the ID last, so a | inside it cannot split off an extra field, and nothing anywhere splits an ID back apart.

The same rules run again inside journal.append() — defence in depth for a journal reached without an actor — and, for DurableStateActor, in the options validator, where a violation is an OptionsError on the persistenceId field.

Check your own IDs against them without starting an actor:

import { assertValidPersistenceId } from 'actor-ts';
assertValidPersistenceId(`account-${userId}`);

Reading is never refused. Only writes are validated, so events already stored under an ID that these rules reject stay reachable via journal.read(oldId, 1) — copy them to a corrected ID and delete the old stream.

Replaying 100 000 events at startup is slow. Configure a snapshot policy:

import { everyNEvents } from 'actor-ts';
class Account extends PersistentActor<...> {
override snapshotPolicy() { return everyNEvents(100); }
// After every 100 events, the current state is snapshotted.
}

The framework writes a snapshot via the snapshot store; on recovery, it loads the snapshot first and only replays events after that snapshot’s seqNr.

everyNEvents(N) is the common case. For custom policies (snapshot on a specific event kind, time-based), implement:

override snapshotPolicy() {
return (seqNr, state, event) => event.kind === 'finalized';
}

See Snapshots for the full configuration.

class Account extends PersistentActor<...> {
override tagsFor(event: Event): ReadonlyArray<string> | undefined {
return ['account']; // or based on event kind
}
}

Projections read events from the journal by tag. Tagging an event makes it discoverable to a read-side view that subscribes to 'account' events.

Returning undefined (the default) means “no tags” — fine if you don’t have projections yet.

When event shapes change over time (a field is renamed, a value is split, an enum is added), old events stay in the journal forever. The event adapter upgrades them on read:

import { EventAdapter } from 'actor-ts';
const v1ToV2Adapter: EventAdapter<EventV2> = {
manifest: () => 'deposited',
toJournal: (event) => ({ manifest: 'deposited', version: 2, payload: event }),
fromJournal: (stored) => stored.version === 1
? migrate(stored.payload as EventV1)
: stored.payload as EventV2,
};
class Account extends PersistentActor<...> {
override eventAdapter() { return v1ToV2Adapter; }
}

With an adapter configured, every event is wrapped in a { _v, _t, _e } envelope (version + type + payload) at persist time, and unwrapped through adapter.fromJournal(stored) at read time. Backward compatibility is the actor’s responsibility.

See Migration overview for the full migration story.

Every store — journal, snapshot store, durable-state store, on every backend — writes payloads in the tagged JSON tree format (the same tree JsonSerializer uses), so rich types survive the round-trip without any configuration. The in-memory stores apply the same round-trip, so what works in a test works in production and vice versa.

Payload contentRound-trip behavior
Plain objects, arrays, strings, finite numbers, booleans, nullStored as plain JSON — byte-identical to before.
Date, Map, Set, bigint, Uint8ArrayRound-trip as real instances via type tags.
BidirectionalMapRound-trips as a real instance — one of two framework classes with a tag of its own (#1035). Only the forward pairs are stored; the inverse is rebuilt on decode.
BidirectionalMultiMapRound-trips as a real instance (#1037). Stored as a forward adjacency list; the inverse is rebuilt on decode, and a participant left with no partners is not stored at all.
NaN, Infinity, -Infinity, -0Round-trip exactly — no more null / 0 (#889).
RegExp (source + flags), URLRound-trip as real instances.
Error (incl. subclasses, cause, AggregateError)Round-trips name + message + cause — deliberately without the stack: a persisted stack would leak filesystem paths into long-lived rows.
Typed arrays (Int8ArrayBigUint64Array), DataView, ArrayBufferRound-trip byte-exactly.
undefined object propertyDropped, like JSON.stringify — reads back as undefined.
undefined in arrays / Set / Map entriesPreserved as undefined (#889) — plain JSON’s null there would be a different value.
new Number/String/Boolean wrappersUnwrap to their primitive, like JSON.stringify.
Class instancestoJSON() is honoured; otherwise stored as plain { ...fields } — methods and instanceof are gone after recovery. Use plain data, an event adapter, or a per-store serializer (below).
Functions, symbols, circular references, Promise, WeakMap / WeakSetThrow at persist time (SerializationError — loud, instead of corrupting the stored row).

Every store options builder takes withSerializer(serializer) — the way to preserve class identity or use a binary format for rows. See using a custom serializer for persistence for the framing, mixed-history behavior, and the in-memory exception.

CborSerializer carries the whole table above, so swapping it in for row size costs you nothing from this list. Any other serializer carries whatever it carries — a schema-driven one (Avro, Protobuf) only knows the fields in its schema.

Rows written by earlier actor-ts versions used bare JSON.stringify; they keep decoding unchanged — the tags only appear in newly written rows where plain JSON would corrupt the value.

The PersistentActor API reference covers the full base-class surface.