Zum Inhalt springen
Deutsch

PersistentFSM

PersistentFSM ist die event-sourced Variante von FSM. Sie ist tabellengetrieben: Du deklarierst eine Übergangstabelle, und jedes Command, das einen Übergang auslöst, persistiert dessen Event(s). Beim Neustart spielt die FSM diese Events durch applyEvent ab und setzt im exakten Zustand fort, in dem sie war.

import { match } from 'ts-pattern';
import { PersistentFSM } from 'actor-ts';
import type { FsmStateData, FsmTransitionMap } from 'actor-ts';
type State = 'created' | 'submitted' | 'approved' | 'rejected';
type Data = { id: string; reviewer?: string; reason?: string };
type Command =
| { kind: 'submit' }
| { kind: 'approve'; reviewer: string }
| { kind: 'reject'; reason: string };
type Event =
| { kind: 'submitted' }
| { kind: 'approved'; reviewer: string }
| { kind: 'rejected'; reason: string };
class OrderApproval extends PersistentFSM<Command, Event, State, Data> {
readonly persistenceId: string;
constructor(private readonly orderId: string) {
super();
this.persistenceId = `order-${orderId}`;
}
initialFsmState(): State { return 'created'; }
initialData(): Data { return { id: this.orderId }; }
transitions: FsmTransitionMap<State, Command, Event, Data> = {
created: {
submit: { event: { kind: 'submitted' }, next: 'submitted' },
},
submitted: {
approve: {
event: (command): Event => ({ kind: 'approved', reviewer: command.reviewer }),
next: 'approved',
},
reject: {
event: (command): Event => ({ kind: 'rejected', reason: command.reason }),
next: 'rejected',
},
},
// `approved` und `rejected` sind terminal — keine Einträge.
};
applyEvent(state: State, data: Data, event: Event): FsmStateData<State, Data> {
return match(event)
.with({ kind: 'submitted' }, () => ({ state: 'submitted' as const, data }))
.with({ kind: 'approved' }, (e) => ({ state: 'approved' as const, data: { ...data, reviewer: e.reviewer } }))
.with({ kind: 'rejected' }, (e) => ({ state: 'rejected' as const, data: { ...data, reason: e.reason } }))
.exhaustive();
}
}

Die Typparameter sind PersistentFSM<Command, Event, SName, Data> — die eingehende Command-Union, die persistierte Event-Union, die Zustandsnamen-Union und die Domain-Daten. Ein Neustart spielt die persistierten Events durch applyEvent ab, sodass die FSM genau dort weitermacht, wo die vorherige Inkarnation aufgehört hat — einschließlich des aktuellen Zustandsnamens und der Daten.

Vier Member treiben die Maschine — kein when / goto / stay-DSL:

  • initialFsmState() — der Startzustandsname, wenn noch keine Events abgespielt wurden.
  • initialData() — die Startdaten.
  • transitions — die Übergangstabelle (state × command.kind → Eintrag). Als Klassenfeld deklariert, damit die Command-spezifische Typverengung an jeder Aufrufstelle funktioniert.
  • applyEvent(state, data, event) — der reine Fold, der das kombinierte { state, data } zurückgibt. Er läuft sowohl zur Persist-Zeit (vorwärts) als auch während der Recovery (Replay), muss also deterministisch und frei von Seiteneffekten sein.

persistenceId (geerbt von PersistentActor) benennt den Event-Stream.

Es gibt kein Framework-Wrapper-Event — das Journal speichert deine eigenen Domain-Event-Werte. Bei jedem Command tut die Basisklasse:

  1. Schlägt transitions[currentState][command.kind] nach. Gibt es keinen Eintrag, ist es ein ungültiger Übergang — auf warn geloggt, nichts persistiert, keine Zustandsänderung (überschreibe onInvalidTransition, um das anzupassen).
  2. Hat der Eintrag einen guard und gibt dieser false zurück, wird das Command verworfen — auf debug geloggt, nichts persistiert (überschreibe onGuardRejected, um das anzupassen).
  3. Andernfalls wertet sie das event des Eintrags aus, persistiert es via persistAll und wendet es über applyEvent an.

applyEvent ist die einzige Quelle der Wahrheit für Zustands- + Daten-Updates.

  1. preStart lädt den letzten Snapshot (falls vorhanden) und liest jedes nachfolgende Domain-Event für die persistenceId aus dem Journal.
  2. Spielt jedes Event der Reihe nach durch applyEvent ab und baut sowohl den Zustandsnamen als auch die Daten wieder auf.
  3. onRecoveryComplete feuert mit dem finalen abgespielten Zustand, woraufhin die FSM beginnt, Live-Commands zu verarbeiten.

Schlägt die Recovery fehl, stoppt die FSM, bevor ein Übergang läuft oder ein State-Timeout armiert wird — siehe Wenn die Recovery fehlschlägt.

Dieselbe Idiomatik wie PersistentActor — Replay baut deterministisch die exakte Position der Zustandsmaschine wieder auf. transitions und Guards werden nur für Live-Commands herangezogen (den nächsten Übergang entscheiden); Replay treibt den Zustand rein über applyEvent.

Jeder Eintrag hat ein event, ein Ziel next und einen optionalen guard:

transitions: FsmTransitionMap<State, Command, Event, Data> = {
submitted: {
approve: {
// Ein Guard überspringt den Übergang (kein Event, keine
// Zustandsänderung), wenn er false zurückgibt — auf debug geloggt.
guard: (command) => command.reviewer.length > 0,
event: (command): Event => ({ kind: 'approved', reviewer: command.reviewer }),
next: 'approved',
},
},
};

event nimmt drei Formen an: ein Literal ({ kind: 'submitted' }), eine Funktion (command, data) => Event oder — für mehrere Events pro Command (#66) — ein Array oder eine Funktion, die eines zurückgibt. Array-Events persistieren atomar in einem einzigen persistAll, applyEvent läuft einmal pro Event, und nur der finale Zustand nach dem Replay wird gegen next geprüft. Ein leeres Array ist ein No-op (nichts persistiert, kein Übergang).

pay: {
event: (command): Event[] => [
{ kind: 'paid', amount: command.amount },
{ kind: 'audit-logged' },
],
next: 'paid',
},

next ist hauptsächlich informativ — applyEvent ist das, was den Übergang tatsächlich treibt — aber eine Diskrepanz zwischen den beiden wird auf warn geloggt, um ein Auseinanderdriften von Tabelle und Fold zu erkennen.

Ein Zustand kann einen _timeout-Eintrag deklarieren, der beim Eintritt in diesen Zustand einen einmaligen Timer scharf stellt. Verstreicht afterMs, bevor ein Command herausübergeht, feuert die FSM das Timeout-Event automatisch durch dieselbe Persist-dann-Apply-Pipeline:

authorized: {
capture: { event: { kind: 'captured' }, next: 'captured' },
// Die Autorisierung automatisch ablaufen lassen, wenn niemand
// rechtzeitig captured.
_timeout: {
afterMs: 15 * 60 * 1000,
event: { kind: 'expired' },
next: 'expired',
// Optionaler Guard — false bricht das Feuern still ab.
// guard: (data) => data.amount > 0,
},
},

Der Timer wird bei jedem Übergang neu scharf gestellt — auch bei einem, der im selben Zustand bleibt — und abgebrochen, wenn die FSM den Zustand verlässt (oder stoppt). Genau das lässt die Idle-Session-Form funktionieren:

active: {
// Each heartbeat renews the window instead of racing it.
heartbeat: { event: { kind: 'touched' }, next: 'active' },
_timeout: {
afterMs: 30 * 60 * 1000,
event: { kind: 'timedOut' },
next: 'expired',
},
},

Ein gefeuertes Timeout läuft wie jede andere Nachricht durch die Mailbox und kann dort deshalb bereits hinter einem noch nicht ausgeführten Command warten. Das erneute Scharfstellen entwertet ein so eingereihtes Feuern: Das Command läuft zuerst, erneuert das Fenster, und das überholte Feuern wird verworfen, statt die Session trotzdem ablaufen zu lassen (#143).

Bei der Recovery wird der Timer relativ zur Wall-Clock beim Recovery-Abschluss neu scharf gestellt — eine lange gestoppte FSM bekommt ein frisches afterMs-Fenster statt eines sofortigen „bereits abgelaufen”-Feuerns.

PersistentFSM hat keine onEnter / onExit / onTransition-Hooks — die gehören nur zur In-Memory-FSM. Bringe niemals Seiteneffekte in applyEvent unter: Es ist ein reiner Fold, der bei jedem Replay läuft, sodass ein Seiteneffekt dort bei jedem Neustart erneut feuern würde.

Bringe Seiteneffekte dort unter, wo sie nur für Live-Commands laufen — überschreibe onCommand, delegiere an super.onCommand und handle dann auf dem neuen Zustand. onCommand läuft während des Replays nie (die Recovery treibt den Zustand allein über applyEvent), sodass der Seiteneffekt einmal pro echtem Übergang feuert:

override async onCommand(curr: FsmStateData<State, Data>, command: Command): Promise<void> {
await super.onCommand(curr, command); // führt den tabellengetriebenen Übergang aus
if (this.currentFsmState === 'submitted') {
await this.notifyReviewer(this.currentData); // Seiteneffekt nur vorwärts
}
}

Für Arbeit, die einmal nach dem Wiederhochfahren der FSM laufen muss, verwende onRecoveryComplete(state).

Die Persistenz-Maschinerie wird von PersistentActor geerbt — überschreibe dieselben Hooks:

class OrderApproval extends PersistentFSM<Command, Event, State, Data> {
readonly persistenceId = 'order-42';
// Optionale Overrides (gleiche wie PersistentActor):
override snapshotPolicy() { return everyNEvents(50); }
override eventAdapter() { return undefined; }
override tagsFor(event: Event) { return undefined; }
// ... initialFsmState / initialData / transitions / applyEvent wie oben
}
  • persistenceId — der Event-Stream-Key.
  • snapshotPolicy — periodische Snapshots zur Replay-Begrenzung.
  • eventAdapter / snapshotAdapter — Schema-Migration.
  • tagsFor — Events für Projektions-Konsum taggen.

Zwei protected Getter legen den kombinierten Zustand offen (nach der Recovery verlässlich), praktisch innerhalb überschriebener Handler:

  • currentFsmState — der aktuelle Zustandsname.
  • currentData — die aktuellen Domain-Daten.
override snapshotPolicy() { return everyNEvents(50); }

Für lange laufende FSMs, die viele Übergänge akkumulieren, begrenzen Snapshots den Replay. Der Snapshot serialisiert das kombinierte { state, data } (FsmStateData) — ein kompakter Blob.

Wähle das Intervall basierend auf der Übergangs-Frequenz:

  • Wenige Übergänge pro Actor-Lebensdauer (~10) — kein Snapshot nötig.
  • Viele Übergänge (100+) — Snapshot alle 50-100 Events.

Siehe Snapshots für die allgemeine Policy-Guidance.

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