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 { ActorSystem, ActorSystemOptions, type ActorRef } from 'actor-ts';import { PersistentActor } from 'actor-ts/persistence';import { InMemoryJournal, InMemorySnapshotStore } from 'actor-ts/persistence';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/persistence';
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/persistence';
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/persistence';
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.
tagsFor wird einmal pro Event gefragt, auch innerhalb eines
persistAll. Ein Batch, der Event-Arten mischt, wird also pro Art
getaggt, und eine Projektion, die auf eine davon filtert, sieht
genau ihre eigenen Events:
override tagsFor(event: Event): ReadonlyArray<string> | undefined { return event.kind === 'paymentCaptured' ? ['payment'] : ['order'];}
await this.persistAll([ { kind: 'orderPlaced' }, // → ['order'] { kind: 'paymentCaptured' }, // → ['payment']]);Der Batch wird weiterhin atomar geschrieben — Tags pro Event teilen ihn nicht in mehrere Appends auf.
Was ein Tag enthalten darf
Abschnitt betitelt „Was ein Tag enthalten darf“Wie eine Persistenz-ID ist ein Tag selten eine Konstante — meist
wird er aus dem Event gebaut (`tenant-${event.tenantId}`, ein
optionales Label). Und wie eine ID ist er mehr als ein Wert: jedes
Backend macht aus der Liste eine Storage-Form, und sie wählen
nicht dieselbe. journal.append() weist deshalb eine Liste
zurück, die nicht überall dasselbe bedeuten kann — bevor eine
einzige Zeile geschrieben ist:
| Zurückgewiesen | Warum |
|---|---|
| ein leerer Tag | er bedeutet in jedem Store etwas anderes — aus der Tag-Tabelle verworfen, aber in der kommaseparierten Spalte behalten, aus der die SQL-Journale zurücklesen; auf MongoDB als abfragbarer ''-Bucket indiziert; auf dem Cassandra-Tag-Index eine einzige heiße tag = ''-Partition; und auf DynamoDB ein abgelehntes Item, denn ein String-Set darf kein leeres Element enthalten. Die übliche Quelle ist eine Form [category, subCategory ?? ''], deren zweiter Platz leer geblieben ist |
| derselbe Tag zweimal an einem Event | dieselbe Spreizung: Cassandras set<text> und der idempotente Insert in die Tag-Tabelle falten ihn zusammen, MongoDB und die CSV-Spalte behalten ihn, und DynamoDB lässt den ganzen Append scheitern, weil ein String-Set kein Element wiederholen darf |
| länger als 255 Zeichen | ein Tag wird indiziert, und ein unbegrenzter bläht sowohl die Event-Zeile als auch jeden darüber gebauten Index auf |
| mehr als 64 Tags an einem Event | dieselbe Schranke, pro Event gezählt — ein persistAll über zehn Events darf je 64 tragen, nicht 64 insgesamt |
| ein Komma | die SQLite- und die relationalen Journale legen Tags in einer kommaseparierten Spalte ab; ein , darin würde beim Lesen in zusätzliche Tags zerfallen — ein Aufrufer könnte Einträge in die Tag-Liste eines Nachbar-Events schreiben |
| Control-Zeichen | Tags werden in Logzeilen interpoliert — ein Newline lässt einen Aufrufer Log-Einträge fälschen |
Alles andere ist erlaubt. Tags werden Byte für Byte verglichen:
'Order' und 'order' sind zwei Tags und keine Wiederholung, und
ein angehängtes Leerzeichen ergibt einen dritten.
Prüfe deine eigenen Tags gegen diese Regeln, ohne einen Actor zu starten:
import { assertValidTags } from 'actor-ts/persistence';
assertValidTags(tagsFor(event));Lesen wird nie verweigert. Nur append validiert — ein Stream,
der vor diesen Regeln geschrieben wurde und bereits einen leeren
oder wiederholten Tag trägt, spielt unverändert ab, und
journal.read(id, 1) liefert genau die Liste zurück, die gespeichert
wurde. Neu ist nur, dass ein Append mit einer solchen Liste jetzt
wirft — filtere sie also in tagsFor, statt das Journal entscheiden
zu lassen.
Einen solchen Stream zu kopieren zählt als Schreiben. Lesen und
Append sind die beiden Hälften, die die Regel oben trennt — und eine
Journal-zu-Journal-Kopie ist beides zugleich: migrateBetweenJournals
liest eine historische Tag-Liste und reicht sie direkt an das append
des Ziels weiter. Ein Alt-Stream spielt also für immer ab, lässt sich
aber so, wie er ist, nicht kopieren — der eine Fall, den die
Trennung allein nicht abdeckt.
Die Kopie sagt das, bevor sie irgendetwas schreibt. Sie geht die
Quelle in einem rein lesenden Vorlauf durch und wirft
MigrationTagError mit Persistence-ID und Sequenznummer, während Ziel
und Fortschrittsspeicher unberührt bleiben; ein Lauf, der bis zu
seinem ersten append kommt, läuft auch zu Ende. Zwei Wege nach
vorn, beide an der Kopie:
import { migrateBetweenJournals } from 'actor-ts/persistence';
// Selbst entscheiden, was die Alt-Liste hätte sein sollen …await migrateBetweenJournals(oldJournal, newJournal, { eventTransform: (e) => ({ ...e, tags: e.tags?.filter((tag) => tag.length > 0) }),});
// … oder die beiden Reparaturen nehmen, die kein Urteil erfordern.// Leere Einträge fallen weg, Wiederholungen werden zusammengefaltet.const result = await migrateBetweenJournals(oldJournal, newJournal, { invalidTags: 'sanitize',});console.log(`${result.eventsWithSanitizedTags} tag lists rewritten`);'sanitize' repariert nur diese beiden Formen. Ein Komma, ein
Control-Zeichen, ein zu langer Tag oder zu viele Tags werden auch
damit zurückgewiesen: Sie zu reparieren hieße, einen Tag zu erfinden
oder einen zu verwerfen, den der Aufrufer so gemeint hat — diese
Entscheidung gehört in eventTransform. Die ganze Kopier-Geschichte
steht in den Migrations-Rezepten.
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/persistence';
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.
Zwei Writer für eine Entity
Abschnitt betitelt „Zwei Writer für eine Entity“Sharding gibt dir genau eine Entity-Instanz pro Persistence-ID — bis es das nicht tut. Eine Netzwerkpartition plus ein Rebalance, oder ein Orchestrierungsfehler, der dieselbe Entity zweimal spawnt: schon recovern zwei Instanzen und nehmen beide Commands an.
Das Journal übersteht das: Appends sind an die Sequenznummer
gebunden, also verliert ein Writer mit JournalConcurrencyError.
Eine Voraussetzung trägt das alles: beide Writer müssen an
dasselbe Journal appenden. Zwei Knoten mit je eigener Datenbank
können nicht kollidieren — jeder Head-Check läuft gegen die eigene
Datei — weshalb Pro-Knoten-Storage im Cluster die Historie still
forkt, statt ein Rennen laut zu verlieren, und weshalb das
Framework davor warnt (Storage-Lokalität &
Identität).
Der Schaden liegt außerhalb des Journals. Bis der Verlierer es merkt,
hat er onCommand für das auslösende Command bereits ausgeführt — und
wenn dieser Handler eine Karte belastet, eine Mail verschickt oder
einen Downstream aufgerufen hat, macht das nichts rückgängig. Bis zu
seinem nächsten persist beantwortet er außerdem Lesezugriffe aus
State, über den der andere Writer längst hinaus ist.
Die Rückfalllinie: ein verlorenes Rennen stoppt den Actor
Abschnitt betitelt „Die Rückfalllinie: ein verlorenes Rennen stoppt den Actor“Ein JournalConcurrencyError wird als das behandelt, was er ist —
Beweis, dass diese Instanz ein Ownership-Rennen verloren hat — und der
Actor stoppt. Er startet nicht neu. Genau darauf kommt es an: Die
Default-Antwort der Supervision auf einen Fehler ist ein Restart, und
ein neu gestarteter Verlierer recovert den nun fremden Journal-Head
und macht weiter, als gehöre ihm die Entity.
Das kostet nichts und braucht keine Konfiguration. Was es nicht kann: den bereits gelaufenen Seiteneffekt rückgängig machen.
Der Zaun: eine Lease
Abschnitt betitelt „Der Zaun: eine Lease“Für Entities, deren Commands externe Effekte haben, binde Schreibzugriffe
an genau einen Halter — über lease():
class PaymentEntity extends PersistentActor<Command, Event, State> { readonly persistenceId: string;
constructor(entityId: string, private readonly entityLease: Lease) { super(); this.persistenceId = `payment-${entityId}`; }
override lease(): Lease | null { return this.entityLease; }
// ...}Die Lease wird in preStart erworben, vor der Recovery — eine
Instanz, der die Entity nicht gehört, liest deren Historie gar nicht
erst und kann in der Zwischenzeit also auch keine veralteten Antworten
geben. Der persist eines Nicht-Halters wirft sofort, statt den
Konflikt erst am Journal zu entdecken; genau das verhindert, dass der
doppelte Seiteneffekt überhaupt feuert.
Nutze this.isLeaseHolder, wenn du lieber verzweigst als fängst. Der
Verlust einer gehaltenen Lease macht den Actor zum Nicht-Halter und
ruft onLeaseLost(reason) — Default ist Stoppen, denn ein
PersistentActor, der nicht schreiben darf, ist selten sinnvoll am
Leben. Überschreibe es, um weiter Lesezugriffe zu bedienen.
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.
