Zum Inhalt springen
Deutsch

MongoDB

Das MongoDB-Backend liefert alle vier Persistence-Komponenten gegen ein einzelnes MongoDB-Deployment, über den mongodb-Treiber:

  • MongoJournal — das Event-Journal für PersistentActors.
  • MongoSnapshotStore — Snapshots, um die Recovery zu begrenzen.
  • MongoDurableStateStore — Key-Value-Durable-State für DurableStateActors.
  • MongoQuery — ein indiziertes currentEventsByTag, das die relationalen Backends nicht haben.

Es ist das erste Document-Store-Backend und braucht kein Replica Set: siehe Keine Transaktionen nötig.

mongodb ist eine optionale Peer-Dependency. Pinne sie auf Version 6:

Terminal-Fenster
bun add mongodb@^6

Das Framework importiert den Treiber lazy, erst wenn ein MongoDB-Store zum ersten Mal genutzt wird — bis dahin bleibt er aus deinem Bundle heraus.

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.

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().

  • 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 currentEventsByTag in 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.
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.

CollectionShapeIndex
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
  • 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 SQLSTATE 23505.
  • Durable-State-CAS steckt die Revision in den Filter: updateOne({ _id, revision: expected }, …). Eine Abweichung matcht nichts, matchedCount === 0 heißt also, dass die gespeicherte Revision abgewichen ist. Ein Create ist insertOne, wo _id das Duplikat abweist.
  • Die Kompaktierungs-Marke ist monoton über $max, das nur schreibt, wenn der neue Wert größer ist — genau das, was GREATEST in den SQL-Dialekten tut.

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.

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.

  • 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.