Zum Inhalt springen
Deutsch

SQLite-Journal

SqliteJournal speichert Events in einer SQLite-Datenbank — eine einzelne Datei auf der Disk, durable, kein separater Server. Es ist der richtige Default für Single-Node-Produktion: auf Bun ist der Treiber eingebaut (bun:sqlite, keine Installation), auf Node ist es eine einzelne Peer-Dep (better-sqlite3). Überlebt Neustarts, schnell genug für die meisten Workloads.

import {
SqliteJournal,
SqliteJournalOptions,
SqliteSnapshotStore,
SqliteSnapshotStoreOptions,
PersistenceExtensionId,
ActorSystem,
} from 'actor-ts';
const system = ActorSystem.create('my-app');
const sqliteJournalOptions = SqliteJournalOptions.create()
.withPath('/var/lib/my-app/events.db')
.withWal(true);
const sqliteSnapshotStoreOptions = SqliteSnapshotStoreOptions.create().withPath('/var/lib/my-app/snapshots.db');
const persistence = system.extension(PersistenceExtensionId);
persistence.setJournal(new SqliteJournal(sqliteJournalOptions));
persistence.setSnapshotStore(new SqliteSnapshotStore(sqliteSnapshotStoreOptions));

Eine einzelne Datei pro System; das Actor-System schreibt durch SQLite in den OS-Page-Cache, der beim Commit auf Disk flusht.

type SqliteJournalOptions = {
path?: string; // Dateipfad, oder ":memory:" für ephemer
eventsTable?: string; // Default "events"
wal?: boolean; // WAL-Modus aktivieren (empfohlen)
busyTimeoutMs?: number; // Lock-Wartebudget, Default 1000; 0 = sofort scheitern
driver?: SqliteDriver; // expliziter Treiber-Override
};
const sqliteJournalOptions = SqliteJournalOptions.create().withPath('/var/lib/my-app/events.db');
new SqliteJournal(sqliteJournalOptions)

Die Datenbank-Datei. Absolute Pfade sind am besten — relative Pfade werden ab process.cwd() aufgelöst, was dich überraschen kann. Die Datei wird erstellt, wenn sie nicht existiert; bestehende Dateien werden wiederverwendet (Events appenden in place).

Für Tests verwende ':memory:' — eine SQLite-gestützte In-Memory-DB, die sich genau wie die Datei-Version verhält, aber mit dem Prozess verschwindet:

const sqliteJournalOptions = SqliteJournalOptions.create().withPath(':memory:');
new SqliteJournal(sqliteJournalOptions)

Default 'events'. Überschreibe das, wenn du willst, dass mehrere Systeme sich eine DB-Datei teilen (z. B. Dev-Rig):

const sqliteJournalOptions = SqliteJournalOptions.create()
.withPath('shared.db')
.withEventsTable('orders_events');
new SqliteJournal(sqliteJournalOptions)

Das Framework erstellt die Tabelle automatisch bei der ersten Verwendung, mit dem Schema:

CREATE TABLE events (
persistence_id TEXT NOT NULL,
sequence_nr INTEGER NOT NULL,
payload TEXT NOT NULL,
tags TEXT, -- legacy CSV-Tags (Rückwärtskompatibilität)
timestamp INTEGER NOT NULL,
PRIMARY KEY (persistence_id, sequence_nr)
);
CREATE INDEX idx_events_pid ON events (persistence_id);
CREATE TABLE events_tags (
persistence_id TEXT NOT NULL,
sequence_nr INTEGER NOT NULL,
tag TEXT NOT NULL,
timestamp INTEGER NOT NULL,
PRIMARY KEY (tag, timestamp, persistence_id, sequence_nr)
);
CREATE INDEX idx_events_tags_pid_seq ON events_tags (persistence_id, sequence_nr);

Das Dual-Table-Design (events + events_tags) lässt Tag-Queries einen Index treffen, statt CSV zu scannen.

const sqliteJournalOptions = SqliteJournalOptions.create()
.withPath('...')
.withWal(true);
new SqliteJournal(sqliteJournalOptions)

Aktiviert den Write-Ahead-Logging-Modus. Empfohlen für Produktion. WAL gibt dir:

  • Bessere Concurrency — Reader blockieren den Writer nicht.
  • Schnellere Commits — WAL-Writes sind sequenziell, dann wird der Checkpoint gebatched.
  • Sicherere Crashes — Recovery ist einfacher als im Rollback-Journal-Modus.

Der Default ist aus, um den Defaults von SQLite zu entsprechen; aktiviere es explizit, wenn du in Produktion gehst.

const sqliteJournalOptions = SqliteJournalOptions.create()
.withPath('...')
.withBusyTimeoutMs(1000);
new SqliteJournal(sqliteJournalOptions)

Wie lange ein Write auf das Datenbank-Lock wartet, bevor er mit SQLITE_BUSY aufgibt. Default 1000 ms. 0 schaltet das Warten ab — ein Write unter Contention scheitert beim ersten Versuch.

Diesen Wert setzt das Framework bewusst selbst, weil die Treiber sich nicht einig sind. Ihre eingebauten Defaults sind 0 ms bei bun:sqlite und node:sqlite, aber 5000 ms bei better-sqlite3 — ohne expliziten Wert würde dasselbe Journal auf Bun und Deno sofort scheitern und auf Node fünf Sekunden blockieren. Das Framework prägt jeder Verbindung, die es öffnet, seinen eigenen Wert auf; genau das lässt die drei Runtimes gleich verhalten.

Wähle einen eigenen Wert mit einer Sache im Kopf: die SQLite-Treiber sind synchron, das Warten ist also keine Leerlaufzeit — es blockiert die Event-Loop, und solange es dauert, läuft nichts anderes im Prozess, Cluster-Heartbeats eingeschlossen. Deshalb liegt der Default bei 1000 ms statt bei den 5000 ms von better-sqlite3: der Cluster-Failure-Detector erklärt einen Node nach 2000 ms für unerreichbar, ein Fünf-Sekunden-Stall wäre also lang genug, damit die eigenen Peers einen Node aufgeben. Erhöhe ihn nur, wenn du weißt, dass deine Contention kurzlebig ist und dein Node nicht im Cluster steht.

Ein negativer Wert wird abgelehnt — SQLite liest ihn als „ewig weiterversuchen“, was auf einem synchronen Treiber ein unbegrenztes Einfrieren bedeutet.

Das Framework erkennt den richtigen Treiber basierend auf der Runtime automatisch, sodass du driver normalerweise ungesetzt lässt:

  • Bunbun:sqlite (eingebaut).
  • Nodebetter-sqlite3, wenn installiert, sonst das eingebaute node:sqlite.
  • Denonode:sqlite (eingebaut, Deno >= 2.2).

Die Auto-Detection deckt jede unterstützte Runtime ab, und jede von ihnen hat einen Treiber, der keine Installation braucht. Die konkreten Treiber-Klassen sind nicht Teil der öffentlichen API — es gibt keinen Import-Pfad, um selbst einen zu konstruieren — daher ist der driver-Slot eine interne Naht, die die Tests des Frameworks verwenden, nichts, was du in Anwendungscode verdrahtest.

Terminal-Fenster
npm install better-sqlite3

Auf Node ist better-sqlite3 eine optionale Peer-Dependency: installiert bevorzugt das Framework es, ohne es übernimmt das eingebaute node:sqlite. Es lohnt sich, wenn du den zusätzlichen Durchsatz willst oder dein Deployment es schon einsetzt; andernfalls ist der Zero-Dependency-Pfad ein nativer Build weniger im Image.

Bun und Deno brauchen nichts: bun:sqlite und node:sqlite sind eingebaut.

Für eine entfernte SQLite-Datenbank siehe libSQL / Turso — gleiches Schema, über HTTP erreichbar.

Neben Journal und Snapshot-Store gibt es für SQLite einen DurableStateStore — das Modell „nur den aktuellen Wert halten”, ohne Event-Log und ohne Replay:

import { SqliteDurableStateStore, SqliteDurableStateStoreOptions } from 'actor-ts';
const stateOptions = SqliteDurableStateStoreOptions.create()
.withPath('./state.db');
const store = new SqliteDurableStateStore(stateOptions);

Um ein Datenbank-Handle über Journal, Snapshot-Store und Durable-State-Store zu teilen, öffne es selbst und übergib es — der Store überlässt das Schließen dann dir:

import { getSqliteDriver } from 'actor-ts';
const driver = await getSqliteDriver();
const database = driver.open('./app.db');
const sharedOptions = SqliteDurableStateStoreOptions.create()
.withDatabase(database);

Ein Handle, das du selbst geöffnet hast, lässt der Store unangetastet — busyTimeoutMs eingeschlossen: der Store wendet es nur auf Verbindungen an, die er selbst öffnet, denn das Pragma gilt pro Verbindung, und ein geteiltes Handle nachzujustieren würde in jeden anderen Store darauf hineingreifen. Setze es auf einem eigenen Handle selbst:

database.exec('PRAGMA busy_timeout = 1000;');

Das Schema ist das des SQLite-Dialekts und damit identisch zu libSQL / Turso und Cloudflare D1 — eine Datenbank kann also ohne Migration zwischen einer lokalen Datei und einem der beiden wandern.

Ein Unterschied in die andere Richtung ist erwähnenswert: weil hier eine lokale Datei statt HTTP im Spiel ist, sind die Transaktionen ein echtes BEGIN IMMEDIATE … COMMIT. Die HTTP-basierten SQLite-Backends können nur einen atomaren Batch anbieten — genau deshalb legt SqlPool die Isolation als adapterdefiniert fest. Dieses Backend liefert mehr, als der Vertrag verlangt.

Eine entfernte URL wird bei der Konstruktion abgelehnt: withPath('libsql://…') wirft und verweist auf LibSqlDurableStateStore — der lokale Treiber kann sie nicht öffnen, und stillschweigend eine Datei mit diesem Namen anzulegen wäre das verwirrende Ergebnis.

Die Auto-Detection importiert better-sqlite3 lazy — nur wenn das Framework tatsächlich eine SQLite-Datenbank öffnen muss. Wenn du auf Bun bist und nie SQLite verwendest, spielt der fehlende Peer keine Rolle.

Grobe Zahlen (NVMe-Disk, Default-Settings):

  • Append-Durchsatz — 10 000-50 000 Events/sec für kleine Events. WAL-Modus hilft signifikant.
  • Read-Durchsatz — 100 000+ Events/sec für Recovery (sequenzieller Scan).
  • Gleichzeitige Reader — viele parallele Reader blockieren im WAL-Modus keine Writer.

Für eine Single-Node-App mit ein paar Tausend Actors, die Events pro Sekunde emittieren, ist SQLite reichlich schnell. Für Zehntausende von Events pro Sekunde dauerhaft, überlege:

  • SQLite-Pragmas tunen (synchronous = NORMAL, journal_mode = WAL, größerer Cache).
  • Über mehrere Journals sharden.
  • Zu Cassandra für Multi-Node-Verteilung wechseln.

Wenn ein PersistentActor startet:

  1. Den neuesten Snapshot aus dem Snapshot-Store laden (falls vorhanden).
  2. SELECT payload FROM events WHERE persistence_id = ? AND sequence_nr >= ? ausführen, um Events nach dem Snapshot zu streamen.
  3. Jedes Event über onEvent anwenden.

Für ein 100 000-Event-Journal ohne Snapshot liest die Recovery alle 100 000 Zeilen. Sequenzieller Scan mit einem Prepared Statement — Sub-Sekunde auf moderner Hardware, aber richte Snapshots ein für jeden Actor, der Events akkumuliert.

Siehe Snapshots.

Da das Journal eine einzelne SQLite-Datei ist:

Terminal-Fenster
# Backup (mit WAL — verwende SQLites Online-Backup):
sqlite3 events.db ".backup events-$(date +%F).db"
# Oder die App stoppen + cp:
systemctl stop my-app
cp events.db events.db.bak
systemctl start my-app

Online-Backup ist bevorzugt — keine Downtime, konsistenter Snapshot. Das Standard-SQLite-Tooling gilt.

Drei Signale, dass du dem Single-File-SQLite entwachsen bist:

  1. Multi-Node — du brauchst Actors auf N Nodes, die sich denselben Event-Stream teilen. SQLite pro Node funktioniert nicht; wechsle zu Cassandra.
  2. Anhaltend 100K+ Events/sec — SQLite kann das mit Tuning handhaben, aber du bist an den Rändern; spaltenorientierte / verteilte Engines sind dafür entworfen.
  3. Große Events (> 1 MB pro Stück) — SQLite speichert jedes Event als JSON-TEXT-Payload; die Read-Performance degradiert. Überlege Event-Compaction (Pointer zu externem Storage speichern) oder ein Journal, das für große Payloads entworfen ist.