Zum Inhalt springen
Deutsch

DynamoDB

Das DynamoDB-Backend liefert alle drei Persistence-Komponenten gegen Amazon DynamoDB, über @aws-sdk/client-dynamodb:

  • DynamoDbJournal — das Event-Journal für PersistentActors.
  • DynamoDbSnapshotStore — Snapshots, um die Recovery zu begrenzen.
  • DynamoDbDurableStateStore — Key-Value-Durable-State für DurableStateActors.

Es hat die stärkste Optimistic Concurrency aller mitgelieferten Backends — siehe Concurrency-Modell — und ist vollständig serverless: keine Instanz zu betreiben, standardmäßig On-Demand-Kapazität.

@aws-sdk/client-dynamodb ist eine optionale Peer-Dependency:

Terminal-Fenster
bun add @aws-sdk/client-dynamodb

Es ist dieselbe SDK-Familie, die das S3-Object-Storage-Backend schon nutzt — wer das einsetzt, kennt die Runtime-Situation also bereits. Das Framework importiert sie lazy, erst wenn ein DynamoDB-Store zum ersten Mal genutzt wird.

Registriere Journal + Snapshot-Store an der PersistenceExtension und erhalte einen einsatzbereiten Durable-State-Store. Setz die Verbindung einmal am Composite, dann erben sie alle drei Komponenten:

import {
ActorSystem,
ActorSystemOptions,
} from 'actor-ts';
import {
DynamoDbDurableStateStoreOptions,
DynamoDbJournalOptions,
DynamoDbSnapshotStoreOptions,
PersistenceExtensionId,
RegisterDynamoDbPluginsOptions,
registerDynamoDbPlugins,
} from 'actor-ts/persistence';
const systemOptions = ActorSystemOptions.create()
// Die DynamoDB-Plugins als aktives Journal + Snapshot-Store auswählen.
.withConfig({
'actor-ts': {
persistence: {
journal: { plugin: 'actor-ts.persistence.journal.dynamodb' },
'snapshot-store': { plugin: 'actor-ts.persistence.snapshot-store.dynamodb' },
},
},
});
const system = ActorSystem.create('my-app', systemOptions);
const ext = system.extension(PersistenceExtensionId);
const dynamoDbSnapshotStoreOptions = DynamoDbSnapshotStoreOptions.create()
.withKeepN(3);
const registerOptions = RegisterDynamoDbPluginsOptions.create()
.withRegion('eu-central-1')
.withJournal(DynamoDbJournalOptions.create() /* .withEventsTable(...) */)
.withSnapshotStore(dynamoDbSnapshotStoreOptions)
.withDurableStateStore(DynamoDbDurableStateStoreOptions.create() /* .withTable(...) */);
const { durableStateStore } = registerDynamoDbPlugins(ext, registerOptions);

Credentials kommen aus der Standard-AWS-Provider-Kette (Umgebung, Shared Config, IAM-Rolle) — daran ist nichts actor-ts-spezifisch. Mit withClientConfig({ credentials }) überschreibst du das.

dynamodb-local braucht einen Endpoint-Override, und das SDK signiert keinen Request ohne Credentials, auch wenn der Emulator sie ignoriert:

const dynamoDbJournalOptions = DynamoDbJournalOptions.create()
.withRegion('eu-central-1')
.withEndpoint('http://localhost:8000')
.withClientConfig({ credentials: { accessKeyId: 'local', secretAccessKey: 'local' } });
  • Du bist auf AWS und willst keine Datenbank betreiben. Keine Instanz, kein Connection-Pool, kein Patchen.
  • Du willst die stärkste Append-Garantie, die zu haben ist. Der transaktionale Append ist atomar — das schaffen nicht einmal die relationalen Backends für einen Multi-Event-Batch unter Contention.
  • Dein Schreibmuster ist stoßweise. On-Demand-Billing braucht keine Kapazitätsplanung.
type DynamoDbConnection = {
region?: string; // eu-central-1, …
endpoint?: string; // http(s)-URL — dynamodb-local, LocalStack
clientConfig?: Record<string, unknown>; // { credentials, maxAttempts, … }
operations?: DynamoDbOperations; // vorgebaute / geteilte Fassade
};
type DynamoDbTableProvisioning = {
autoCreateTables?: boolean; // Default true
billingMode?: 'PAY_PER_REQUEST' | 'PROVISIONED'; // Default On-Demand
provisionedThroughput?: { readCapacityUnits: number; writeCapacityUnits: number };
tableReadyTimeoutMs?: number; // Default 30_000
};
interface DynamoDbJournalOptions extends DynamoDbConnection, DynamoDbTableProvisioning {
eventsTable?: string; // Default 'actor_ts_events'
}
interface DynamoDbSnapshotStoreOptions extends DynamoDbConnection, DynamoDbTableProvisioning {
snapshotsTable?: string; // Default 'actor_ts_snapshots'
keepN?: number; // neueste N pro pid; Default 3, <=0 behält alle
}
interface DynamoDbDurableStateStoreOptions extends DynamoDbConnection, DynamoDbTableProvisioning {
table?: string; // Default 'actor_ts_durable_state'
}

Die Optionen werden bei der Konstruktion des Stores validiert. Zwei Regeln lohnen sich besonders: ein Tabellenname wird gegen DynamoDBs eigenen Zeichensatz und die Längengrenze geprüft, und Kapazitätseinheiten werden abgelehnt, solange billingMode nicht PROVISIONED ist — AWS ignoriert sie sonst still, was den Fehler unsichtbar macht.

TabellePartition KeySort KeyInhalt
actor_ts_eventspidseq (N)ein Item pro Event; seq = 0 ist die Kompaktierungs-Marke
actor_ts_snapshotspidseq (N)ein Item pro Snapshot
actor_ts_durable_statepid—ein Item pro persistenceId

Mit autoCreateTables (dem Default) legt jeder Store seine Tabelle beim ersten Zugriff an und wartet, bis sie ACTIVE ist — CreateTable kehrt zurück, während die Tabelle noch CREATING ist, und jede Operation darauf scheitert bis zum Umschalten. Das Warten passiert im Lazy-Init: der erste Aufrufer blockiert, alle dahinter bekommen eine funktionierende Tabelle.

  • Der Journal-Append ist eine atomare Transaktion. Alle Events gehen in ein einzelnes TransactWriteItems, jedes Put mit ConditionExpression: attribute_not_exists(pid) — „nur wenn dieses (pid, seq)-Item nicht existiert”. Ein konkurrierender Writer kann damit keinen partiellen Append gewinnen: die Transaktion ist ganz oder gar nicht, und ihr Abbruch wird zum JournalConcurrencyError. Der vorausgehende Head-Read ist nur eine Optimierung für die Sicherheit des Writes, die den häufigen veralteten Append zu einer billigen Query statt einer abgelehnten Transaktion macht — aber der Wert, den er zurückgibt, ist tragend, und darum ist es ein starker Read (nächster Punkt).
  • Jeder Read, von dem eine Recovery oder eine Sequenznummer-Entscheidung abhängt, ist ein starker Read. DynamoDB liefert eventually-consistent Reads, solange ein Request nicht ConsistentRead: true setzt; das Journal setzt es beim Head-Read, bei der Replay-Query und bei der Query nach den zu löschenden Keys in delete, der Snapshot-Store bei loadLatest und loadBefore. Ein veralteter Read ist hier nicht „etwas ältere Daten”: ein Head von einer nachhängenden Replica lässt einen intakten Stream zurückgespult aussehen, und eine veraltete Replay-Seite kürzt die Historie so, dass es weiter unten niemand merken kann — die Replay-Integritätsprüfungen erkennen ein Loch, eine falsche Reihenfolge und ein Event außerhalb des Fensters, aber nichts sagt, wo der Stream enden soll.
  • Durable-State-CAS ist ein nativer konditionaler Write — PutItem … attribute_not_exists(pid) zum Anlegen, UpdateItem … revision = :expected zum Aktualisieren — ohne Transaktion und ohne Rücklesen.
  • Die Kompaktierungs-Marke steigt nur. Sie wird mit attribute_not_exists(deletedTo) OR deletedTo < :value aktualisiert, also GREATEST als Bedingung formuliert; ein niedrigerer Wert wird abgelehnt, und diese Ablehnung ist das erwartete Ergebnis, kein Fehler.
  • MongoDB — das andere Nicht-SQL-Backend, mit indizierter Tag-Query.
  • PostgreSQL — wenn du doch eine relationale Datenbank betreiben willst.
  • Durable State — der Contract, zu dem DynamoDB am besten passt.
  • Snapshots — begrenzt den Recovery-Scan.