Zum Inhalt springen
Deutsch

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.

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.

Jede PersistentActor-Subklasse implementiert drei Methoden:

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 };
}

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 per onEvent angewendet ist. Seiteneffekte gehören hierher.
  • Antworten ohne Persist — für Read-only-Commands (z. B. { kind: 'get-balance' }) greifst du auf state zu und schickst die Antwort direkt per tell.
  • 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() },
() => {});
}
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:

  1. Das Event ist durable — wenn der Prozess danach abstürzt, hat das Journal es trotzdem. Die nächste Recovery nimmt es auf.
  2. Der State spiegelt es wider — onEvent ist gelaufen; this.state ist der neue State.
  3. 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.

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 verarbeiten

Wä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.

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.

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.

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.

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ückgewiesenWarum
leerjedes 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 Zeichendie 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-ZeichenIDs 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.

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.

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.

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ückgewiesenWarum
ein leerer Tager 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 Eventdieselbe 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 Zeichenein 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 Eventdieselbe Schranke, pro Event gezählt — ein persistAll über zehn Events darf je 64 tragen, nicht 64 insgesamt
ein Kommadie 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-ZeichenTags 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.

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.

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-InhaltRound-Trip-Verhalten
Plain-Objekte, Arrays, Strings, endliche Zahlen, Booleans, nullAls plain JSON gespeichert — byte-identisch zu vorher.
Date, Map, Set, bigint, Uint8ArrayRound-trippen als echte Instanzen über Type-Tags.
BidirectionalMapRound-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.
BidirectionalMultiMapRound-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, -0Round-trippen exakt — kein null / 0 mehr (#889).
RegExp (Source + Flags), URLRound-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, ArrayBufferRound-trippen byte-exakt.
undefined-Objekt-PropertyWird verworfen, wie bei JSON.stringify — liest sich als undefined zurück.
undefined in Arrays / Set / Map-EinträgenBleibt undefined erhalten (#889) — Plain-JSONs null wäre dort ein anderer Wert.
new Number/String/Boolean-WrapperEntpacken zu ihrem Primitiv, wie bei JSON.stringify.
KlasseninstanzentoJSON() 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 / WeakSetWerfen zur Persist-Zeit (SerializationError — laut, statt die gespeicherte Row zu korrumpieren).

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.

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.

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.

Die PersistentActor-API-Referenz deckt die vollständige Basisklassen-Oberfläche ab.