MongoDB
Das MongoDB-Backend liefert alle vier Persistence-Komponenten gegen ein einzelnes
MongoDB-Deployment, über den
mongodb-Treiber:
MongoJournal— das Event-Journal fürPersistentActors.MongoSnapshotStore— Snapshots, um die Recovery zu begrenzen.MongoDurableStateStore— Key-Value-Durable-State fürDurableStateActors.MongoQuery— ein indiziertescurrentEventsByTag, das die relationalen Backends nicht haben.
Es ist das erste Document-Store-Backend und braucht kein Replica Set: siehe Keine Transaktionen nötig.
Installation
Abschnitt betitelt „Installation“mongodb ist eine optionale Peer-Dependency. Pinne sie auf Version 6:
bun add mongodb@^6Das Framework importiert den Treiber lazy, erst wenn ein MongoDB-Store zum ersten Mal genutzt wird — bis dahin bleibt er aus deinem Bundle heraus.
Einrichtung
Abschnitt betitelt „Einrichtung“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, MongoDurableStateStoreOptions, MongoJournalOptions, MongoSnapshotStoreOptions, PersistenceExtensionId, RegisterMongoPluginsOptions, registerMongoPlugins,} from 'actor-ts';
const systemOptions = ActorSystemOptions.create() // Die MongoDB-Plugins als aktives Journal + Snapshot-Store auswählen. .withConfig({ 'actor-ts': { persistence: { journal: { plugin: 'actor-ts.persistence.journal.mongodb' }, 'snapshot-store': { plugin: 'actor-ts.persistence.snapshot-store.mongodb' }, }, }, });const system = ActorSystem.create('my-app', systemOptions);
const ext = system.extension(PersistenceExtensionId);const mongoSnapshotStoreOptions = MongoSnapshotStoreOptions.create() .withKeepN(3);const registerOptions = RegisterMongoPluginsOptions.create() .withUrl('mongodb://user:pass@localhost:27017') .withDatabaseName('app') .withJournal(MongoJournalOptions.create() /* .withEventsCollection(...) */) .withSnapshotStore(mongoSnapshotStoreOptions) .withDurableStateStore(MongoDurableStateStoreOptions.create() /* .withCollection(...) */);const { durableStateStore } = registerMongoPlugins(ext, registerOptions);registerMongoPlugins registriert Journal + Snapshot-Store über die Extension
(ausgewählt durch die Plugin-IDs in der Config oben) und gibt den
Durable-State-Store zurück. PersistenceExtension hat keine
Durable-State-Registry — übergib durableStateStore direkt an die Settings
deines DurableStateActor.
Einen Client teilen
Abschnitt betitelt „Einen Client teilen“Ein MongoClient ist selbst schon ein Connection-Pool; einen zu übergeben ist
daher die effiziente Variante, wenn alle Komponenten dasselbe Deployment
adressieren:
import { MongoClient } from 'mongodb';
const client = new MongoClient('mongodb://localhost:27017');await client.connect();const registerMongoPluginsOptions = RegisterMongoPluginsOptions.create() .withClient(client) .withDatabaseName('app');registerMongoPlugins(ext, registerMongoPluginsOptions);Ein geteilter Client gehört dem Caller: kein Store schließt ihn, du schließt
ihn also selbst beim Shutdown. Übergib stattdessen url, dann baut jeder Store
seinen eigenen Client und schließt ihn bei close().
Wann du es einsetzt
Abschnitt betitelt „Wann du es einsetzt“- MongoDB ist schon deine Datenbank. Der größte Treiber im Ökosystem und derjenige, den viele Teams ohnehin betreiben.
- Du willst indizierte Tag-Queries. Nur MongoDB, SQLite/libSQL und Cassandra
drücken
currentEventsByTagin den Storage hinunter; die relationalen Backends pollen. - Du willst ein Deployment für Events und Read-Models. Projections können ihre Views in dieselbe Datenbank schreiben.
Konfiguration
Abschnitt betitelt „Konfiguration“type MongoConnection = { url?: string; // mongodb:// oder mongodb+srv:// databaseName?: string; // Default 'actor_ts' clientOptions?: Record<string, unknown>; // { tls, authSource, maxPoolSize, … } client?: MongoClientLike; // vorgebauter / geteilter Client};
interface MongoJournalOptions extends MongoConnection { eventsCollection?: string; // Default 'events'; die Mark-Collection ist '<it>_meta' autoCreateIndexes?: boolean; // Default true}interface MongoSnapshotStoreOptions extends MongoConnection { snapshotsCollection?: string; // Default 'snapshots' keepN?: number; // neueste N pro pid; Default 3, <=0 behält alle autoCreateIndexes?: boolean;}interface MongoDurableStateStoreOptions extends MongoConnection { collection?: string; // Default 'durable_state'}Die Optionen werden bei der Konstruktion des Stores validiert — ein falsches
URL-Schema oder ein Datenbankname, den MongoDB ablehnen würde (mit /, \, .,
" oder Leerzeichen), schlägt beim Verdrahten fehl, nicht erst beim ersten
Schreiben.
Collections und Indizes
Abschnitt betitelt „Collections und Indizes“| Collection | Shape | Index |
|---|---|---|
events | { persistenceId, sequenceNr, payload, tags?, timestamp } | unique (persistenceId, sequenceNr); (tags, timestamp) |
events_meta | { _id: persistenceId, deletedTo } | _id |
snapshots | { persistenceId, sequenceNr, payload, timestamp } | unique (persistenceId, sequenceNr) |
durable_state | { _id: persistenceId, revision, payload, timestamp } | _id |
Concurrency-Modell
Abschnitt betitelt „Concurrency-Modell“- Journal-Append liest den aktuellen Head und fügt dann ein. Der Unique-
Compound-Index weist einen konkurrierenden Writer mit Serverfehler 11000
ab, der in einen
JournalConcurrencyErrorübersetzt wird — dasselbe zweischichtige Schema wie bei den SQL-Backends (Head-Prüfung für den Normalfall, konditionaler Write für das Rennen), mit Fehler 11000 in der Rolle von SQLSTATE23505. - Durable-State-CAS steckt die Revision in den Filter:
updateOne({ _id, revision: expected }, …). Eine Abweichung matcht nichts,matchedCount === 0heißt also, dass die gespeicherte Revision abgewichen ist. Ein Create istinsertOne, wo_iddas Duplikat abweist. - Die Kompaktierungs-Marke ist monoton über
$max, das nur schreibt, wenn der neue Wert größer ist — genau das, wasGREATESTin den SQL-Dialekten tut.
Keine Transaktionen nötig
Abschnitt betitelt „Keine Transaktionen nötig“MongoDBs Multi-Document-Transaktionen brauchen ein Replica Set, und dieses
Backend nutzt sie bewusst nicht — es läuft daher auf einem Standalone-mongod.
Das ist begründet, nicht Glück. Appends sind vom Head aus lückenlos, zwei
Writer, die sich über den Head einig sind, versuchen also dieselbe erste
Sequenznummer: das insertMany des Verlierers scheitert an seinem allerersten
Dokument, und ordered: true bricht den Batch dort ab — er schreibt nichts. Ein
partieller Append ist über Contention also nicht erreichbar, und genau das wäre
das Einzige gewesen, was eine Transaktion gebracht hätte.
Tag-Queries
Abschnitt betitelt „Tag-Queries“MongoQuery gibt currentEventsByTag einen indizierten Pfad. Weil tags ein
Array ist, indiziert MongoDB es als Multikey-Index — ein Eintrag pro Tag pro
Event — eine Query auf einen Tag plus eine Timestamp-Untergrenze läuft damit
einen zusammenhängenden Bereich ab statt zu scannen:
import { MongoQuery, offsetStart } from 'actor-ts';
const query = new MongoQuery(journal);const tagged = await query.currentEventsByTag({ all: ['ledger'], not: ['audit'] }, offsetStart);Der Server vorfiltert auf einen Tag; all jenseits des ersten Tags,
Cross-Tag-any und not werden im Speicher verfeinert, genau wie in den
SQLite- und Cassandra-Pfaden. Ein Filter mit nur not hat nichts zum
Vorfiltern und fällt auf den Journal-Scan zurück.
Fallstricke
Abschnitt betitelt „Fallstricke“Wie geht’s weiter
Abschnitt betitelt „Wie geht’s weiter“- PostgreSQL — das relationale Gegenstück, mit transaktionalen Multi-Event-Appends.
- Cassandra-Journal — der andere verteilte Store mit indiziertem Tag-Pfad.
- Durable State — die State-orientierte Alternative zum Event-Sourcing.
- Snapshots — begrenzt den Recovery-Scan.
