Zum Inhalt springen
Deutsch

Durable State

DurableStateActor<Command, S> ist das “Ich will einfach, dass der aktuelle Wert überlebt”-Persistenz-Modell. Kein Event-Log, kein Replay — nur ein Snapshot des States, bei jedem persist(newState) überschrieben.

DurableStateActor

onCommand(command)

this.state lesen

next = transform(...)

await this.persist(next)

revision++

Durable State Store

Verglichen mit PersistentActor:

AspektPersistentActorDurableStateActor
Was gespeichert wirdJedes je geschriebene EventDer aktuelle State-Snapshot
RecoveryEvents abspielenSnapshot laden
Storage-KostenWächst mit EventsKonstant pro Actor
HistorieJaNein
Audit / Time TravelJaNein
Gleichzeitige WritesSequenziellOptimistisch (Revisions-Check)

Wähle Durable State, wenn die Historie nicht nützlich ist — Feature Flags, last-known Configs, “aktuelle Cart-Inhalte” ohne den Audit-Trail.

import { DurableStateActor, DurableStateOptions, ActorSystem } from 'actor-ts';
import { InMemoryDurableStateStore } from 'actor-ts';
import { match } from 'ts-pattern';
type AddCommand = { kind: 'add'; sku: string };
type RemoveCommand = { kind: 'remove'; sku: string };
type ViewCommand = { kind: 'view'; replyTo: ActorRef<State> };
type CartCommand = AddCommand | RemoveCommand | ViewCommand;
type State = { items: string[]; };
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: 'remove' }, (c) => this.onRemove(c))
.with({ kind: 'view' }, (c) => this.onView(c))
.exhaustive();
}
private onAdd(c: AddCommand): Promise<void> {
return this.persist({ items: [...this.state.items, c.sku] });
}
private onRemove(c: RemoveCommand): Promise<void> {
return this.persist({ items: this.state.items.filter(s => s !== c.sku) });
}
private onView(c: ViewCommand): void {
c.replyTo.tell(this.state);
}
}
// Setup:
const system = ActorSystem.create('demo');
const store = new InMemoryDurableStateStore();
const durableStateOptions = DurableStateOptions.create<State>()
.withPersistenceId('cart-user-42')
.withStore(store)
.withEmptyState(() => ({ items: [] }));
const cart = system.spawn(
() => new Cart(durableStateOptions),
'cart',
);
cart.tell({ kind: 'add', sku: 'book-1' });
cart.tell({ kind: 'add', sku: 'book-2' });
// Nach einem Neustart: `this.state.items` ist wieder ['book-1', 'book-2'].
type DurableStateOptionsType<S> = {
persistenceId: string;
store: DurableStateStore;
emptyState: () => S;
};

Drei Felder:

  • persistenceId — der Schlüssel, unter dem der State gespeichert ist. Wie bei PersistentActor eine ID pro logischer Entity (cart-user-42, flags-region-eu, …) — und denselben Regeln unterworfen, was eine ID enthalten darf: nicht leer, höchstens 255 Zeichen, kein / oder \, keine Control-Zeichen. DurableStateOptionsValidator prüft sie im Konstruktor — früher als preStart, weil eine ID, die nie einen Eintrag adressieren kann, es wert ist, zurückgewiesen zu werden, bevor der Actor an einen Store gebunden wird — und meldet einen Verstoß als OptionsError auf dem Feld persistenceId.
  • store — die DurableStateStore-Implementierung (in-memory, SQLite, Object Storage, benutzerdefiniert).
  • emptyState() — Factory, die aufgerufen wird, wenn noch kein Eintrag existiert (erste Ausführung, gelöschter State). Stellt den Initialwert bereit.

Reiche sie durch die () => new Cart({...})-Factory. Die Settings können pro Actor-Inkarnation variieren (unterschiedliche IDs für unterschiedliche User, derselbe Store).

Innerhalb der Actor-Handler:

this.state // aktueller State-Wert — überall lesbar
this.revision // monoton wachsender Counter, wird bei jedem Persist erhöht
this.persist(s) // überschreibt den gespeicherten State mit `s`, gibt Promise<DurableStateRecord<S>> zurück

this.state ist synchron — das Framework lädt den State in preStart, und state gibt zurück, was gerade im Speicher steht. Vor dem ersten Persist (oder Load) gibt es emptyState() zurück.

this.persist(next) schreibt den neuen State in den Store mit der aktuellen Revision + 1. Kehrt zurück, sobald der Store bestätigt. Innerhalb von onCommand warte mit await darauf, bevor du den nächsten State als autoritativ behandelst.

State wird auf jedem Backend im getaggten JSON-Tree-Format gespeichert, sodass Date / Map / Set / bigint / Uint8Array als echte Instanzen round-trippen — siehe was Events und State enthalten dürfen.

try {
await this.persist(next);
} catch (e) {
if (e instanceof DurableStateConcurrencyError) {
// Ein anderer Writer war schneller — neu laden und erneut versuchen oder zum User durchreichen.
}
}

Wenn zwei Writer gleichzeitig dieselbe persistenceId aktualisieren, lehnt der Revisions-Check des Stores den zweiten mit DurableStateConcurrencyError ab. Strategien:

  • Das Problem vermeiden — stelle sicher, dass immer nur ein Actor zur selben Zeit in eine gegebene persistenceId schreibt. Das ist meistens trivial: jeder cart-user-42 hat einen Actor auf einem Node (per Sharding oder Singleton).
  • Neu laden und erneut versuchen — den Fehler fangen, State neu laden, den neuen Wert neu berechnen, erneut persistieren. Funktioniert, wenn die Operation idempotent ist.
  • Zum Aufrufer durchreichen — mit einem Fehler antworten; den Aufrufer entscheiden lassen, ob er erneut versuchen will.

Bei den meisten Actor-System-Mustern sollten gleichzeitige Writes nicht passieren — eine Entity pro persistenceId, über Routing oder Sharding adressiert. Wenn du Concurrency-Fehler in Produktion siehst, bedeutet das meist, dass zwei Actors denselben Key schreiben, was ein Routing-Bug ist.

Drei Signale, dass du das richtige Werkzeug gewählt hast:

  • Die State-Form ist einfach (ein einzelnes Objekt, eine kleine Map). Das Ganze zu überschreiben ist billig.
  • Du brauchst keinen Event-Stream — keine Projektionen, kein Audit-Log, keine “zeig mir, wie wir hier gelandet sind”- Anforderungen.
  • Reads dominieren Writes — jedes Read ist ein synchrones this.state, kein Replay.

Drei Signale, dass du stattdessen zu PersistentActor greifen solltest:

  • Die Historie ist wichtig — Auditing, regulatorisch, “zeig dem User ein Changelog.”
  • Der State ist groß und Änderungen sind klein — den gesamten State bei jeder Änderung zu schreiben ist verschwenderisch; kleine Events anzuhängen ist billiger.
  • Du willst Projektionen — Read-Side-Views, die den Event-Stream brauchen.

Wie bei PersistentActor unterstützt Durable State Schema-Evolution durch einen Adapter:

import { StateAdapter } from 'actor-ts';
const v1ToV2Adapter: StateAdapter<StateV2> = {
manifest: () => 'Cart',
toJournal: (state) => ({ manifest: 'Cart', version: 2, payload: state }),
fromJournal: (stored) => stored.version === 1
? migrate(stored.payload as StateV1)
: stored.payload as StateV2,
};
class Cart extends DurableStateActor<...> {
protected stateAdapter() { return v1ToV2Adapter; }
}

Der persistierte Eintrag wird in einen { _v, _t, _e }-Envelope verpackt; beim Laden läuft fromJournal des Adapters, um ältere Versionen zu migrieren. Siehe Migration im Überblick für die vollständige Story.

Per-Actor-Overrides sind verfügbar:

class Sensitive extends DurableStateActor<...> {
protected encryption() { return { algorithm: 'aes-gcm', keyId: 'k1' }; }
protected compression() { return { algorithm: 'gzip' }; }
}

Werden von Stores berücksichtigt, die sie implementieren (Object-Storage mit Verschlüsselung, etc.); ignoriert von Stores, die das nicht tun (In-Memory, SQLite). Siehe Object-Storage-Verschlüsselung für die Durable-State-Verschlüsselungs-Story.

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