PersistentActor
PersistentActor<Command, Event, State> ist die Event-Sourcing-Basisklasse
des Frameworks. Das Modell:
- Commands kommen rein.
- Der Command-Handler validiert und entscheidet, welche Fakten passiert sind — diese Fakten sind Events, die ins Journal persistiert werden.
- Nach der Persistenz aktualisiert ein reiner Event-Handler den State.
Beim Start liest das Framework das Journal und spielt jedes Event durch den Event-Handler ab. Der resultierende State ist das, was der Actor bei seinem ersten Command sieht — wo auch immer die letzte Instanz aufgehört hat, diese macht weiter.
Ein minimales Beispiel
Abschnitt betitelt „Ein minimales Beispiel“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 }; }
// Rein: state + event → neuer state. Läuft sowohl bei Persist als auch bei 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 }; }
// Ein Command behandeln — validieren, bei Gültigkeit persistieren. 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) => { /* Seiteneffekte mit dem neuen State */ }, ); }
private onWithdraw(state: State, c: WithdrawCommand): void { if (state.balance < c.amount) { // Ablehnen — nichts persistieren. 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 — kein Persist }}
// Setup — Journal + Snapshot-Store inline via die Create-Option verdrahten: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 ist { balance: 70 } — und bleibt das über Neustarts hinweg.Die drei abstrakten Methoden
Abschnitt betitelt „Die drei abstrakten Methoden“Jede PersistentActor-Subklasse implementiert drei Methoden:
initialState()
Abschnitt betitelt „initialState()“Der State, bevor irgendwelche Events da sind. Wird am Anfang der
Recovery aufgerufen, und das Ergebnis ist das, worauf onEvent
aufbaut.
initialState(): State { return { balance: 0 };}onEvent(state, event) → newState
Abschnitt betitelt „onEvent(state, event) → newState“Rein — keine Seiteneffekte, keine tells, keine awaits. Nur
state + event → state. Diese Funktion läuft sowohl:
- Beim Replay — einmal pro persistiertem Event, wenn der Actor startet und das Journal abgespielt wird.
- Nach dem Persist — einmal, wenn ein neues Event landet.
Reinheit ist wichtig, weil dieselben Events über die Lebensdauer des
Actors viele Male abgespielt werden. Ein Seiteneffekt in onEvent
läuft bei jeder Recovery, was duplizierte E-Mails / duplizierte
HTTP-Calls / alles dupliziert bedeutet.
Genau deshalb ist onEvent synchron, während onCommand async
ist — die Asymmetrie ist der Punkt, kein Versehen. Ein Command
entscheidet, und Entscheiden macht I/O: persist schreibt ins
Journal. Ein Event ist bereits eine Tatsache, und eine Tatsache in
den State zu falten ist Rechnen. Alles, was du hier awaiten
wolltest, ist genau das, was bei der Recovery nicht erneut laufen
darf; es gehört in den persist-Callback oder nach
onRecoveryComplete — beide werden nicht abgespielt.
Lies state aus dem Parameter, niemals aus this.state. Beim
Replay läuft onEvent losgelöst, bevor this.state zugewiesen ist
— und das DevTools-Time-Travel-Panel leiht es sich genauso, als
freien Fold über jeden Punkt der Historie. Ein Handler, der nach
this.state greift, funktioniert auf dem Persist-Pfad und fällt
erst nach einem Neustart um — die teuerste Art, das herauszufinden.
onCommand(state, command) → void | Promise<void>
Abschnitt betitelt „onCommand(state, command) → void | Promise<void>“Validiert das Command gegen den aktuellen State und entscheidet, welche Events persistiert werden. Drei gültige Ergebnisse:
this.persist(event, afterPersist)— ein Event persistieren. Der Callback läuft mit dem neuen State, sobald das Event angehängt und peronEventangewendet ist. Seiteneffekte gehören hierher.- Antworten ohne Persist — für Read-only-Commands (z. B.
{ kind: 'get-balance' }) greifst du aufstatezu und schickst die Antwort direkt pertell. - Ablehnen — loggen, ignorieren oder mit einem Fehler antworten. Keine Events werden geschrieben.
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 — kein Persist}
private onWithdraw(state: State, command: WithdrawCommand): void { if (state.balance < command.amount) return; // ablehnen this.persist({ kind: 'withdrawn', amount: command.amount, ts: Date.now() }, () => {});}Der persist-Callback
Abschnitt betitelt „Der persist-Callback“this.persist(event, (newState) => { // 1. Das Event wurde ins Journal geschrieben // 2. onEvent wurde aufgerufen; this.state und `newState` spiegeln es wider // 3. Es ist sicher, hier Seiteneffekte auszuführen this.sender.forEach(s => s.tell({ ok: true, balance: newState.balance }));});Drei Garantien, die der Callback dir gibt:
- Das Event ist durable — wenn der Prozess danach abstürzt, hat das Journal es trotzdem. Die nächste Recovery nimmt es auf.
- Der State spiegelt es wider —
onEventist gelaufen;this.stateist der neue State. - Commands werden während des Persist gestasht — eingehende Commands warten, bis der Persist abgeschlossen ist und sein Callback feuert. Keine Verschachtelung.
Der dritte Punkt macht persist sicher als
“Transaktions-Grenze” für die Command-Verarbeitung. Seiteneffekte
(Antworten, Benachrichtigungen, Follow-up-Tells) gehören in den
Callback.
Recovery
Abschnitt betitelt „Recovery“Wenn der Actor startet:
preStart() läuft: → neuesten Snapshot laden (falls vorhanden) → state, seqNr setzen → Events aus dem Journal lesen ab seqNr+1 → für jedes Event: state = onEvent(state, event) → onRecoveryComplete(state) → bereit, das erste Command zu verarbeitenWährend die Recovery läuft, werden keine Commands verarbeitet. Die Mailbox füllt sich; sobald die Recovery fertig ist, arbeitet der Actor sie der Reihe nach gegen den wiederhergestellten State ab.
onRecoveryComplete(state) ist ein optionaler Hook, der nach dem
Abspielen des letzten Events feuert. Verwende ihn für einmaliges
Post-Recovery-Setup (Watchers registrieren, verwandte Actors
beschaffen, etc.) — aber nicht für Seiteneffekte pro Event, die bei
jedem Neustart duplizieren würden. Wirft dieser Hook, ist das ein
gewöhnlicher Actor-Fehler und geht an die Supervision; es ist kein
Recovery-Fehler, denn der State wurde einwandfrei wiederhergestellt.
Wenn die Recovery fehlschlägt
Abschnitt betitelt „Wenn die Recovery fehlschlägt“Ein korruptes Event, ein fehlender Upcaster, ein Snapshot, den die
Integritätsprüfung ablehnt, ein Journal, das seine Events in falscher
Reihenfolge zurückgibt — die Recovery kann werfen, und dann wird
onRecoveryFailure aufgerufen.
Das ist eine Benachrichtigung, keine Entscheidung. Ein Recovery-Fehler ist so oder so endgültig; der Hook wählt nur, wer davon erfährt:
// Default — rethrowt, der Fehler erreicht die Supervision als// ActorInitializationError, und die Strategie entscheidet.onRecoveryFailure(reason: Error): void { throw reason; }
// Override, das zurückkehrt — du hast den Fehler als behandelt// übernommen, und der Actor wird daraufhin gestoppt.override onRecoveryFailure(reason: Error): void { this.metrics.recoveryFailed.inc();}Die beiden Integritätsfehler sind exportierte Klassen, der Hook
kann sie also auseinanderhalten. Welcher der beiden Stores seinen
Vertrag gebrochen hat, ist das Erste, was ein Betreiber wissen
muss, und beide tragen die persistenceId und die betroffene
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`); }}Weitermachen kann der Actor in keinem der beiden Fälle: state
wurde nie zugewiesen und lastSequenceNr ist unbekannt, es gibt
also keinen Zustand, in dem er ein Command beantworten könnte. Das
Stoppen ist es, was das sichtbar hält — bereits wartende Commands
werden zu Dead Letters, statt im Stash zu verschwinden, und der
Stopp wird auf dem Event-Stream veröffentlicht.
Was der Replay ablehnt
Abschnitt betitelt „Was der Replay ablehnt“Bevor ein einziges Event onEvent erreicht, prüft der Replay, ob
das, was journal.read() zurückgegeben hat, dem
Journal-Vertrag
entspricht: Sequenznummern aufsteigend, lückenlos und innerhalb des
angefragten Fensters. Alles andere wirft JournalIntegrityError.
Endgültig statt nur eine Warnung, denn die Faltung ist die
Recovery. Ein durcheinandergeratener Stream spielt die Historie in
einer Reihenfolge ab, in der sie nie stattgefunden hat — ein
disableAccount landet vor dem setPassword, auf das es folgen
sollte — und er lässt die Sequenz des Actors auf dem zuletzt
gelieferten Event statt auf dem höchsten stehen. Danach scheitert
jedes persist an einem JournalConcurrencyError, der auf ein
kerngesundes Journal zeigt und einen Neustart von seiner Ursache
entfernt ist.
Dieselbe Prüfung lehnt eine Historie ab, deren Anfang fehlt:
wegkompaktierte Events, die kein Snapshot abdeckt. Dieser Zustand
lässt sich nicht rekonstruieren, und den überlebenden Rest auf
initialState() zu falten würde einen erfinden. Kompaktiere nur
über einen Snapshot hinaus — genau dafür behält
deleteHistory(seq) den Snapshot bei seq.
Das Time-Travel-Panel der DevTools teilt sich diesen Replay-Code, klinkt sich beim letzten Punkt aber aus. Es fragt, wie der State in der Vergangenheit aussah, und greift dafür zum neuesten Snapshot vor dem Zielpunkt — es landet also regelmäßig in einem Fenster, dessen abdeckender Snapshot inzwischen weggeräumt wurde. Dort zeigt es eine unvollständige Faltung — mit der erreichten Sequenz und der Zahl der angewendeten Events —, statt sich zu weigern, überhaupt zu öffnen.
Persistenz-ID
Abschnitt betitelt „Persistenz-ID“Jeder PersistentActor deklariert eine persistenceId:
class Account extends PersistentActor<...> { readonly persistenceId = 'account-42';}Die ID identifiziert den Event-Stream im Journal. Zwei Actors
mit derselben persistenceId würden sich das gleiche Event-Log
teilen — meistens ein Bug.
Für Per-Entity-Actors (ein Account pro User, ein Cart pro User) mache die ID abhängig von der Entity:
class Account extends PersistentActor<...> { constructor(public readonly userId: string) { super(); } readonly persistenceId = `account-${this.userId}`;}Für sharded Entities übergibt die Shard-Region typischerweise die Entity-ID per Konstruktor, und die Persistenz-ID leitet sich daraus ab.
Was eine Persistenz-ID enthalten darf
Abschnitt betitelt „Was eine Persistenz-ID enthalten darf“Eine ID ist selten eine Konstante — meist wird sie aus User-Input
gebaut (`account-${request.params.id}`, eine Sharding-Entity-ID).
Sie wird immer als Query-Parameter gebunden, SQL-Injection ist
also nicht erreichbar. Aber die ID ist mehr als ein Wert: sie
benennt einen Stream, und mehrere Backends bauen daraus einen
strukturierten Storage-Key. preStart weist deshalb eine ID
zurück, die keiner sein kann — bevor das Journal überhaupt
angefasst wird:
| Zurückgewiesen | Warum |
|---|---|
| leer | jedes Backend würde bereitwillig unter '' schreiben — eine ID, die irgendein Code-Pfad zu füllen vergessen hat, teilt sich dann still einen Stream mit jedem anderen Actor, der dasselbe vergessen hat |
| länger als 255 Zeichen | die Breite der Spalte persistence_id im DDL jedes relationalen Dialekts, und Teil ihres Primärschlüssels — eine längere ID wird abgeschnitten (zwei IDs fallen auf einen Stream zusammen) oder tief im Treiber abgelehnt |
/ oder \ | die Object-Storage-Stores legen eine ID als Verzeichnis ab (<prefix><persistenceId>/<seq>.json) und lesen sie über genau dieses Prefix zurück — a/b liegt also innerhalb von a: loadLatest von a liefert dann den Snapshot von a/b, und delete von a löscht ihn |
. oder .. (als ganze ID) | Traversal-Bedeutung, eine Ebene höher, mit derselben Konsequenz |
| Control-Zeichen | IDs werden bei jeder Recovery und jedem Persist in eine Logzeile interpoliert — ein Newline lässt einen Aufrufer Log-Einträge fälschen |
Alles andere ist bewusst erlaubt. Ein Komma ist in Ordnung — die
kommaseparierte Spalte im SQLite-Journal führt Tags, und die ID
ist eine eigene gebundene Spalte. Ein | ist es ebenfalls, und
das Chat-Beispiel lebt davon: ein DM-Kanal heißt
dm-channel-alice|bob. Der Offset-Store der Projektionen setzt
Keys als <projection>|seq|<persistenceId> zusammen — mit der ID
am Ende. Ein | darin kann also kein zusätzliches Feld
abspalten, und nirgends wird eine ID wieder zerlegt.
Dieselben Regeln laufen noch einmal in journal.append() —
Defence-in-Depth für ein Journal, das ohne Actor erreicht wird —
und bei DurableStateActor im
Options-Validator, wo ein Verstoß ein OptionsError auf dem Feld
persistenceId ist.
Prüfe deine eigenen IDs dagegen, ohne einen Actor zu starten:
import { assertValidPersistenceId } from 'actor-ts';
assertValidPersistenceId(`account-${userId}`);Lesen wird nie verweigert. Nur Schreibpfade validieren —
Events, die bereits unter einer nun ungültigen ID liegen, bleiben
über journal.read(oldId, 1) erreichbar: kopiere sie auf eine
korrigierte ID und lösche den alten Stream.
Snapshots
Abschnitt betitelt „Snapshots“100 000 Events beim Start abspielen ist langsam. Konfiguriere eine Snapshot-Policy:
import { everyNEvents } from 'actor-ts';
class Account extends PersistentActor<...> { override snapshotPolicy() { return everyNEvents(100); } // Nach jeweils 100 Events wird der aktuelle State gesnappshottet.}Das Framework schreibt einen Snapshot über den Snapshot Store; bei der Recovery lädt es den Snapshot zuerst und spielt nur Events nach der seqNr dieses Snapshots ab.
everyNEvents(N) ist der häufige Fall. Für individuelle Policies
(Snapshot bei einer bestimmten Event-Art, zeitbasiert),
implementiere:
override snapshotPolicy() { return (seqNr, state, event) => event.kind === 'finalized';}Siehe Snapshots für die vollständige Konfiguration.
Events für Projektionen taggen
Abschnitt betitelt „Events für Projektionen taggen“class Account extends PersistentActor<...> { override tagsFor(event: Event): ReadonlyArray<string> | undefined { return ['account']; // oder basierend auf der Event-Art }}Projektionen lesen Events aus
dem Journal nach Tag. Ein Event zu taggen macht es entdeckbar
für eine Read-Side-View, die 'account'-Events abonniert.
undefined zurückzugeben (der Default) bedeutet “keine Tags” —
in Ordnung, wenn du noch keine Projektionen hast.
Schema-Evolution — Event-Adapter
Abschnitt betitelt „Schema-Evolution — Event-Adapter“Wenn sich Event-Formen über die Zeit ändern (ein Feld wird umbenannt, ein Wert wird gesplittet, ein Enum wird hinzugefügt), bleiben alte Events für immer im Journal. Der Event-Adapter rüstet sie beim Lesen auf:
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; }}Mit einem konfigurierten Adapter wird jedes Event in einem
{ _v, _t, _e }-Envelope (Version + Typ + Payload) zur Persist-Zeit
verpackt und beim Lesen über adapter.fromJournal(stored)
entpackt. Rückwärtskompatibilität ist die Verantwortung des Actors.
Siehe Migration im Überblick für die vollständige Migrations-Story.
Was Events und State enthalten dürfen
Abschnitt betitelt „Was Events und State enthalten dürfen“Jeder Store — Journal, Snapshot-Store, Durable-State-Store, auf
jedem Backend — schreibt Payloads im getaggten
JSON-Tree-Format (derselbe Tree, den
JsonSerializer nutzt), sodass reiche
Typen den Round-Trip ohne Konfiguration überleben. Die
In-Memory-Stores wenden denselben Round-Trip an — was im Test
funktioniert, funktioniert in Produktion und umgekehrt.
| Payload-Inhalt | Round-Trip-Verhalten |
|---|---|
Plain-Objekte, Arrays, Strings, endliche Zahlen, Booleans, null | Als plain JSON gespeichert — byte-identisch zu vorher. |
Date, Map, Set, bigint, Uint8Array | Round-trippen als echte Instanzen über Type-Tags. |
BidirectionalMap | Round-trippt als echte Instanz — eine von zwei Framework-Klassen mit eigenem Tag (#1035). Gespeichert wird nur die Vorwärtsrichtung; die Umkehrung wird beim Dekodieren rekonstruiert. |
BidirectionalMultiMap | Round-trippt als echte Instanz (#1037). Gespeichert als Vorwärts-Adjazenzliste; die Umkehrung wird beim Dekodieren rekonstruiert, und ein Teilnehmer ohne Partner wird gar nicht erst gespeichert. |
NaN, Infinity, -Infinity, -0 | Round-trippen exakt — kein null / 0 mehr (#889). |
RegExp (Source + Flags), URL | Round-trippen als echte Instanzen. |
Error (inkl. Subklassen, cause, AggregateError) | Round-trippt Name + Message + Cause — bewusst ohne Stack: ein persistierter Stack würde Dateisystempfade in langlebige Rows leaken. |
Typed Arrays (Int8Array … BigUint64Array), DataView, ArrayBuffer | Round-trippen byte-exakt. |
undefined-Objekt-Property | Wird verworfen, wie bei JSON.stringify — liest sich als undefined zurück. |
undefined in Arrays / Set / Map-Einträgen | Bleibt undefined erhalten (#889) — Plain-JSONs null wäre dort ein anderer Wert. |
new Number/String/Boolean-Wrapper | Entpacken zu ihrem Primitiv, wie bei JSON.stringify. |
| Klasseninstanzen | toJSON() wird respektiert; sonst als plain { ...fields } gespeichert — Methoden und instanceof sind nach dem Recovery weg. Nutze Plain-Data, einen Event-Adapter oder einen Per-Store-Serializer (unten). |
Funktionen, Symbols, zirkuläre Referenzen, Promise, WeakMap / WeakSet | Werfen zur Persist-Zeit (SerializationError — laut, statt die gespeicherte Row zu korrumpieren). |
Eigener Serializer pro Store
Abschnitt betitelt „Eigener Serializer pro Store“Jeder Store-Options-Builder nimmt withSerializer(serializer) — der
Weg, Klassenidentität zu erhalten oder ein Binärformat für Rows zu
nutzen. Siehe
einen eigenen Serializer für die Persistenz nutzen
für das Framing, das Mixed-History-Verhalten und die
In-Memory-Ausnahme.
CborSerializer trägt die gesamte Tabelle
oben, ein Wechsel dorthin aus Gründen der Row-Größe kostet dich aus
dieser Liste also nichts. Jeder andere Serializer trägt, was er
eben trägt — ein schemagetriebener (Avro, Protobuf) kennt nur die
Felder seines Schemas.
Rows aus früheren actor-ts-Versionen nutzten rohes
JSON.stringify; sie dekodieren unverändert weiter — die Tags
erscheinen nur in neu geschriebenen Rows, wo plain JSON den Wert
korrumpieren würde.
Häufige Stolperfallen
Abschnitt betitelt „Häufige Stolperfallen“Wie geht’s weiter
Abschnitt betitelt „Wie geht’s weiter“- Persistenz im Überblick — das größere Bild: PersistentActor vs. DurableStateActor.
- Snapshots — Replay-Fenster-Reduktion im Detail.
- Projektionen — Read-Side-Views über den Event-Stream.
- Journals — SQLite — Produktions-Journal für Single-Node.
- Migration im Überblick — Event-Schemas weiterentwickeln.
- Replicated Event Sourcing — Multi-Writer Event Sourcing für Cluster-Setups.
Die PersistentActor-API-Referenz
deckt die vollständige Basisklassen-Oberfläche ab.
