Zum Inhalt springen
Deutsch

Persistenz im Überblick

Standardmäßig lebt Actor-State im Speicher. Wenn ein Actor abstürzt und neu startet, beginnt jedes Feld bei Null. Für State, der überleben soll — Benutzerkonten, Warenkörbe, Bestell-Workflows, alles jenseits des aktuellen Requests — brauchst du Persistenz.

actor-ts bietet zwei komplementäre Modelle:

ModellWas du persistierstWann
Event Sourcing (PersistentActor)Ein Log von Events — jede zustandsändernde Tatsache, die je beobachtet wurde.Audit-Trails, Time Travel, Projektionen, wenn “wie sind wir hierher gekommen” wichtig ist.
Durable State (DurableStateActor)Ein einzelner Snapshot — der aktuelle State, bei jedem Update überschrieben.Wenn der aktuelle Wert alles ist, was du brauchst, und die Historie nicht nützlich ist.

Beide spielen beim Actor-Start ab oder stellen wieder her, sodass der wiederbelebte Actor dort weitermacht, wo der letzte aufgehört hat.

DurableStateActor

PersistentActor

onCommand(command)

persist(event)

onEvent → State ändert sich

Journal

Append-only Event-Log

Snapshot Store

periodischer State

onCommand(command)

persist(newState)

revision++

Durable State Store

einzelner Wert

Das Journal und der Durable-State-Store sind austauschbar. Das Framework liefert mit:

BackendJournalDurable StateSnapshot StoreIndizierte Tag-QueryRuntimes
In-MemoryIn-Process-Scanalle
SQLite✓ (Join-Tabelle)alle
libSQL / Turso✓ (Join-Tabelle)alle
PostgreSQL— (Polling-Scan)alle
MariaDB / MySQL— (Polling-Scan)alle
Microsoft SQL Server— (Polling-Scan)alle
MongoDB✓ (Multikey-Index)alle
DynamoDB— (Polling-Scan)alle
Cloudflare D1✓ (Join-Tabelle)alle
CockroachDB, YugabyteDB— (Polling-Scan)alle
Cassandra✓ (Tag-Index-Tabelle)alle
Filesystem / S3 (Object Storage)n/aalle

CockroachDB und YugabyteDB sind keine eigenen Backends — sie sprechen das PostgreSQL-Wire-Protokoll, die Postgres-Stores bedienen sie also unverändert. Siehe wire-kompatible Datenbanken für das Zertifizierte und die jeweiligen Vorbehalte.

“Indizierte Tag-Query” beschreibt, was PersistenceQuery.currentEventsByTag in den Storage hinunterdrücken kann: ein Backend mit Tag-Index läuft diesen ab, die übrigen scannen das Journal und verfeinern im Speicher — korrekt in beiden Fällen, aber nur mit Index in den Kosten begrenzt. SQLite läuft auf jeder Runtime, seit der eingebaute node:sqlite-Treiber dazugekommen ist; better-sqlite3 brauchst du für die lokale Datei nur, wenn du es auf Node bevorzugst.

Plus einen Erweiterungspunkt — implementiere die Interfaces Journal / DurableStateStore / SnapshotStore für deinen eigenen Storage.

import { match } from 'ts-pattern';
import { Actor, PersistentActor, ActorSystem } from 'actor-ts';
type DepositCommand = { kind: 'deposit'; amount: number };
type WithdrawCommand = { kind: 'withdraw'; amount: number };
type Command = DepositCommand | WithdrawCommand;
type DepositedEvent = { kind: 'deposited'; amount: number; ts: number };
type WithdrawnEvent = { kind: 'withdrawn'; amount: number; ts: number };
type Event = DepositedEvent | WithdrawnEvent;
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. Wird während der Recovery abgespielt.
onEvent(state: State, e: Event): State {
return match(e)
.with({ kind: 'deposited' }, (ev) => ({ balance: state.balance + ev.amount }))
.with({ kind: 'withdrawn' }, (ev) => ({ balance: state.balance - ev.amount }))
.exhaustive();
}
// Validiert das Command, persistiert das Event, führt Seiteneffekte nach dem Persist aus.
onCommand(state: State, command: Command): void {
match(command)
.with({ kind: 'deposit' }, (c) => this.onDeposit(c))
.with({ kind: 'withdraw' }, (c) => this.onWithdraw(state, c))
.exhaustive();
}
private onDeposit(command: DepositCommand): void {
this.persist({ kind: 'deposited', amount: command.amount, ts: Date.now() },
(next) => { /* Seiteneffekte mit dem persistierten-und-angewendeten State */ });
}
private onWithdraw(state: State, command: WithdrawCommand): void {
if (state.balance < command.amount) {
// Ablehnen — nichts persistieren.
return;
}
this.persist({ kind: 'withdrawn', amount: command.amount, ts: Date.now() },
() => {});
}
}

Drei Methoden erledigen die ganze Arbeit:

  • onCommand — validiert die Anfrage. Entscheidet, welche Events per this.persist(event, afterPersist) persistiert werden. Seiteneffekte gehören in afterPersist.
  • onEvent — reine Funktion von State + Event zum neuen State. Keine Seiteneffekte hier — diese Funktion läuft während der Recovery, um das Journal abzuspielen, möglicherweise viele Male.
  • initialState — wie der State aussieht, bevor irgendwelche Events da sind.

Beim Start liest das Framework jedes Event für account-42 aus dem Journal, spielt sie durch onEvent ab, und der resultierende State ist das, was onCommand sieht. Commands werden erst verarbeitet, wenn die Recovery abgeschlossen ist.

Siehe PersistentActor für die vollständige Oberfläche.

import { match } from 'ts-pattern';
import { DurableStateActor, DurableStateOptions, type ActorRef } from 'actor-ts';
type State = { items: string[]; };
type AddCommand = { kind: 'add'; sku: string };
type ViewCommand = { kind: 'view'; replyTo: ActorRef<State> };
type CartCommand = AddCommand | ViewCommand;
class Cart extends DurableStateActor<CartCommand, State> {
constructor(options: DurableStateOptions<State>) { super(options); }
override async onCommand(command: CartCommand): Promise<void> {
await match(command)
.with({ kind: 'add' }, (c) => this.onAdd(c))
.with({ kind: 'view' }, (c) => this.onView(c))
.exhaustive();
}
private async onAdd(command: AddCommand): Promise<void> {
const next: State = { items: [...this.state.items, command.sku] };
await this.persist(next); // überschreibt den gespeicherten State
}
private onView(command: ViewCommand): void {
command.replyTo.tell(this.state);
}
}

persist(newState) überschreibt den gespeicherten Snapshot. Bei einem Neustart lädt preStart ihn zurück; this.state spiegelt den geladenen Wert wider. Kein Event-Log; kein Replay; einfach “speichere den aktuellen State.”

Siehe DurableStateActor für die vollständige API.

Event Sourcing vs. Durable State — die richtige Wahl

Abschnitt betitelt „Event Sourcing vs. Durable State — die richtige Wahl“

Der ehrliche Entscheidungsbaum:

ja

nein

ja

nein

Brauchst du eine Historie

der Zustandsänderungen?

(Audit, Undo, Projektionen)

Ist der State einfach genug

und das Volumen klein genug,

dass das Neuschreiben des Ganzen

bei jeder Änderung okay ist?

PersistentActor

PersistentActor

(nur Änderungen werden angehängt)

DurableStateActor

Event Sourcing gewinnt, wenn:

  • Die Historie wichtig ist — Auditing, regulatorische Compliance, “zeig mir, wie wir hier gelandet sind”, Projektionen.
  • Der State groß ist, aber die Änderungen klein — ein 100-Byte-Event anzuhängen ist billiger als den ganzen State zu schreiben.
  • Du Projektionen willst — Read-Side-Views über den Event-Stream, siehe Projektionen.
  • Schema-Evolution ein langes Spiel ist — Event-Typen können unabhängig vom aktuellen State migriert werden.

Durable State gewinnt, wenn:

  • Die Historie nicht nützlich ist — der aktuelle Wert ist alles, was du brauchst.
  • Der State klein und einfach ist — Überschreiben ist billig.
  • Du optimistische Concurrency willst — Durable-State-Stores haben einen Revision Counter; gleichzeitige Writes lösen einen DurableStateConcurrencyError aus.

Viele Produktionssysteme mischen beides — Durable State für die Konfigurations-artigen “ein aktueller Wert”-Dinge, Event Sourcing für die Workflow-artigen “Historie-der-Entscheidungen”-Dinge.

100 000 Events beim Start abspielen ist langsam. Snapshots kürzen das Replay-Fenster:

class Account extends PersistentActor<Command, Event, State> {
// ...
override snapshotPolicy() { return everyNEvents(100); }
// Nach jeweils 100 Events wird der aktuelle State als Snapshot geschrieben.
}

Beim Start tut das Framework Folgendes:

  1. Lädt den neuesten Snapshot (falls vorhanden).
  2. Spielt Events ab nach der seqNr dieses Snapshots ab.

Ein 100-Event-Fenster ist schnell. Wähle das Snapshot-Intervall basierend auf deiner Event-Rate und akzeptablen Startzeit.

Siehe Snapshots für die Konfiguration und Per-Actor-Policy-Optionen.

Ein PersistentActor schreibt Events. Eine Projektion konsumiert sie und baut eine abgeleitete View, die für Queries zugeschnitten ist:

import { match } from 'ts-pattern';
import { ProjectionActor, ByTagProjectionOptions } from 'actor-ts';
const cartViewOptions = ByTagProjectionOptions.create<CartEvent>()
.withName('view-cart-summary')
.withTag('cart')
.withQuery(query)
.withHandle(async (event) => {
await match(event.event)
.with({ kind: 'added' }, () => db.execute('INSERT INTO cart_items ...'))
// ...
.otherwise(() => Promise.resolve());
});
const cartView = ProjectionActor.byTag<CartEvent>(system, cartViewOptions);

Die Projektion abonniert Events mit Tag 'cart' aus dem Journal, verarbeitet sie in Reihenfolge, persistiert ihren eigenen Fortschritt (sodass ein Neustart vom richtigen Offset weitermacht).

Das entkoppelt Writes (das Journal des PersistentActor) von Reads (die View der Projektion) — die Read-Seite kann für die Query-Muster, die sie bedient, denormalisiert werden.

Siehe Projektionen für das vollständige Muster.

Das Framework definiert drei Interfaces:

type Journal = {
// Events anhängen, Events lesen, nach Tag abfragen
};
type DurableStateStore = {
// Laden, mit Revision persistieren, löschen
};
type SnapshotStore = {
// Snapshot speichern, neuesten laden, ältere löschen
};

Eingebaute Implementierungen leben unter persistence/journals/* und persistence/snapshot-stores/*.

Du schreibst ein eigenes? Journal.read() ist die Methode, deren Vertrag der Replay einfordert: Die zurückgegebenen Events müssen nach Sequenznummer aufsteigen, lückenlos sein und im angefragten Fenster liegen. delete() kompaktiert ein Präfix, nie ein Loch in der Mitte — eine Lücke kann also nur ein Defekt sein: ein fehlendes ORDER BY, ein halb geschriebener Append, ein Store, in den auch andere schreiben können. Der Replay wirft dann JournalIntegrityError, statt zu falten; siehe was der Replay ablehnt.

Für Produktion deckt die SQLite-Kombination aus Journal + Snapshot Single-Node-Deployments ab; das Cassandra-Journal deckt Multi-Node-Cluster ab, in denen das Journal geteilt werden muss.

Jedes Backend speichert Payloads im selben getaggten JSON-Tree-Format, sodass Date / Map / Set / bigint / Uint8Array überall round-trippen — siehe was Events und State enthalten dürfen.

Die PersistentActor- und DurableStateActor-API-Referenzen decken die vollständige Basisklassen-Oberfläche ab.