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 { 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.

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 wideronEvent 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';
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';
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';
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.

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.

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 (Int8ArrayBigUint64Array), 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.

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