diff --git a/.changeset/minidb-durability-hardening.md b/.changeset/minidb-durability-hardening.md
new file mode 100644
index 0000000000..1a3a441494
--- /dev/null
+++ b/.changeset/minidb-durability-hardening.md
@@ -0,0 +1,6 @@
+---
+"@moonshot-ai/minidb": patch
+"@moonshot-ai/kimi-code": patch
+---
+
+Harden the embedded key-value engine's durability: WAL compaction now always terminates under sustained write storms instead of chasing the tail forever, a committed write can no longer slip through a compaction rotation undetected, torn WAL tails no longer misplace later disk-mode value pointers, read-only opens never create or modify database files or compact under a live writer, corrupt index-definition files no longer force a full rebuild, stale compaction temp files are cleaned on open, and the process lock can no longer be taken over by several processes at once.
diff --git a/.changeset/minidb-perf-hardening.md b/.changeset/minidb-perf-hardening.md
new file mode 100644
index 0000000000..fbff2a2e20
--- /dev/null
+++ b/.changeset/minidb-perf-hardening.md
@@ -0,0 +1,6 @@
+---
+"@moonshot-ai/minidb": patch
+"@moonshot-ai/kimi-code": patch
+---
+
+Speed up the embedded key-value engine under stress: queries with skip/limit now stream candidates instead of decoding every match first, LRU eviction picks victims in O(1) instead of scanning every key, bursts of simultaneously expired TTL keys are drained within seconds, existence checks and size counting no longer read values when they only need metadata, and one oversized token can no longer poison the full-text index.
diff --git a/.changeset/minidb-reader-catchup.md b/.changeset/minidb-reader-catchup.md
new file mode 100644
index 0000000000..72f91bc451
--- /dev/null
+++ b/.changeset/minidb-reader-catchup.md
@@ -0,0 +1,6 @@
+---
+"@moonshot-ai/minidb": minor
+"@moonshot-ai/kimi-code": patch
+---
+
+Cluster readers of the embedded key-value engine now catch up incrementally by replaying only newly appended WAL frames after another process writes, instead of fully reopening the shard on every read; cross-process read latency drops by orders of magnitude at larger shard sizes, and readers still fall back to a full reopen after WAL rotation or truncation.
diff --git a/.changeset/minidb-review-hardening.md b/.changeset/minidb-review-hardening.md
new file mode 100644
index 0000000000..a85dbef338
--- /dev/null
+++ b/.changeset/minidb-review-hardening.md
@@ -0,0 +1,6 @@
+---
+"@moonshot-ai/minidb": patch
+"@moonshot-ai/kimi-code": patch
+---
+
+Keep the embedded key-value engine writable when a WAL compaction rotation fails mid-way instead of wedging it until reopen, stop a rolled-back write from erasing a concurrently committed value for the same key, let the RESP server survive aborted connections, recover after oversized requests, and answer each pipelined command independently, and keep the previous full-text index intact when a postings rebuild fails.
diff --git a/packages/agent-core-v2/test/persistence/backends/minidb/miniDbQueryStore.test.ts b/packages/agent-core-v2/test/persistence/backends/minidb/miniDbQueryStore.test.ts
index bc367ad8dd..fe02ef2ee3 100644
--- a/packages/agent-core-v2/test/persistence/backends/minidb/miniDbQueryStore.test.ts
+++ b/packages/agent-core-v2/test/persistence/backends/minidb/miniDbQueryStore.test.ts
@@ -161,18 +161,24 @@ describe('MiniDbQueryStore', () => {
}
});
- it('rebuilds a clean store after on-disk corruption', async () => {
+ it('preserves data and drops a corrupt index sidecar on reopen', async () => {
const first = build();
- await first.put(COLLECTION, 'a', { v: 1 });
+ await first.put(COLLECTION, 'a', { id: 'a', v: 1 });
await first.ensureIndex(COLLECTION, { kind: 'value', name: 'byV', field: 'v' });
await first.close();
disposeHost?.();
disposeHost = undefined;
+ // A corrupt index-definition sidecar holds only derived metadata. The
+ // opener must not wipe the database over it: the sidecar is dropped, the
+ // data is preserved, and the caller can re-register the definition.
const indexFile = join(homeDir, 'cache', 'query-store', 'db.indexes.json');
await fsp.writeFile(indexFile, '{ definitely not valid json');
const second = build();
- expect(await second.get(COLLECTION, 'a')).toBeUndefined();
+ expect(await second.get(COLLECTION, 'a')).toEqual({ id: 'a', v: 1 });
+ await second.ensureIndex(COLLECTION, { kind: 'value', name: 'byV', field: 'v' });
+ const page = await second.query<{ id: string; v: number }>(COLLECTION).where({ v: 1 }).execute();
+ expect(page.items).toEqual([{ id: 'a', v: 1 }]);
});
});
diff --git a/packages/minidb/DESIGN_NOTES.md b/packages/minidb/DESIGN_NOTES.md
index 984b2b479f..3590215141 100644
--- a/packages/minidb/DESIGN_NOTES.md
+++ b/packages/minidb/DESIGN_NOTES.md
@@ -111,7 +111,8 @@ See `src/wal.ts`.
Borrowed from Redis RDB/`BGREWRITEAOF` and Bitcask's merge. Compaction is
**non-blocking for writes**: the live WAL doubles as a Redis-style rewrite
buffer (`aof_rewrite_buf`), so the (slow) snapshot is written while writers
-keep appending, and they pause only for a brief final rotation.
+keep appending, and they pause only for the final rotation — bounded, but the
+pause scales with the remaining tail when a write storm outruns the pre-copy.
- When the WAL exceeds a size threshold, compact (rewrite state):
1. **Fence.** Flush the WAL and record `baseOffset = wal.size`. Every write
@@ -124,28 +125,40 @@ keep appending, and they pause only for a brief final rotation.
store while we iterate. The snapshot need not be point-in-time — the WAL
tail below repairs any fuzziness on replay.
3. **Pre-copy the tail.** Stream `WAL[baseOffset .. head]` into `db.wal.tmp`,
- looping until the remaining delta is small. Also non-blocking: post-fence
+ while the copy is CONVERGING: each pass must shrink the remaining delta
+ meaningfully, bounded by a small pass cap. Also non-blocking: post-fence
writes accumulate in the live WAL instead of a separate in-memory buffer.
- 4. **Rotate** (the only blocking phase, and brief): set `_rotateLock` so new
- writers park, flush, copy the tiny remaining tail delta, close the old
- WAL, `rename` the snapshot into place, `rename` the new WAL into place,
- and reopen it. The two renames are ordered **snapshot-first** so a crash
- between them pairs the new snapshot with the old full WAL — replaying the
- whole old WAL on top of the new snapshot is idempotent for pre-fence
- frames and correct for post-fence frames, so the state stays consistent.
- Reversing the order would pair an old snapshot with a truncated new WAL
- and lose pre-fence data.
+ Under sustained writes whose append rate approaches the copy rate the gap
+ stops shrinking, and looping until it was small enough would never
+ terminate — observed in stress testing as compactions stalling for as long
+ as a write storm lasted (WAL unbounded, disk amplification ~20x). Giving
+ up to the rotation phase is what keeps compaction live in that regime.
+ 4. **Rotate** (the only blocking phase): set `_rotateLock` so new writers
+ park, then **seal** the old WAL — post-seal appends fail fast with a
+ retryable error and the op re-runs against the new WAL — flush, and copy
+ the remaining tail in a drain loop. With the WAL sealed the head no
+ longer moves, so the loop provably terminates; the pause scales with the
+ tail the pre-copy could not drain (the same bounded end-of-rewrite pause
+ Redis accepts for its AOF diff flush). Finish by `rename`ing the snapshot
+ into place, `rename`ing the new WAL into place, and reopening it. The two
+ renames are ordered **snapshot-first** so a crash between them pairs the
+ new snapshot with the old full WAL — replaying the whole old WAL on top
+ of the new snapshot is idempotent for pre-fence frames and correct for
+ post-fence frames, so the state stays consistent. Reversing the order
+ would pair an old snapshot with a truncated new WAL and lose pre-fence
+ data.
- Reads are unaffected throughout. Writes pause only for the rotation critical
- section (one flush, a small copy, two renames + two `fsyncDir`s); the bulk
- snapshot + tail copy happen concurrently with writes. Recovery is always
- `load snapshot + replay WAL`, last-writer-wins, which is exactly what makes
- the non-point-in-time snapshot converge to the correct latest state.
-- A writer's gate check (`if (_rotateLock) await _rotateLock`) and its
- `wal.append()` are in the same synchronous segment, and `_rotateLock` is set
- synchronously before the rotation flush — so a writer either enqueued its
- frame before the lock (and is drained by the flush) or parks on the lock
- without appending. The event loop cannot interleave between the two, which
- is why the critical section is quiescent after the flush and cannot deadlock.
+ section (one flush, the remaining tail copy, two renames + two `fsyncDir`s);
+ the bulk snapshot + tail copy happen concurrently with writes. Recovery is
+ always `load snapshot + replay WAL`, last-writer-wins, which is exactly what
+ makes the non-point-in-time snapshot converge to the correct latest state.
+- The rotation cannot slip a committed write: a writer's gate check
+ (`if (_rotateLock) await _rotateLock`) and its `wal.append()` are NOT in the
+ same synchronous segment (memory-budget checks yield microtasks in between),
+ so an in-flight op could previously append between the final flush and the
+ close and lose a resolved write into the abandoned old file. Sealing the old
+ WAL before the final flush closes that hole: such an op fails fast against
+ the sealed WAL and transparently retries against the new one.
- The snapshot encoder runs on the main thread but **yields to the event loop
every N entries** (chunked), so large snapshots stay responsive without a
Worker. Offloading the encode to a `worker_thread` is the planned
diff --git a/packages/minidb/README.md b/packages/minidb/README.md
index adb55ea97c..977003848a 100644
--- a/packages/minidb/README.md
+++ b/packages/minidb/README.md
@@ -26,6 +26,8 @@ Written in **TypeScript**, with strict types and zero runtime dependencies.
backed by a Redis-style **skip list** with O(log N) rank/range; unique and
sparse supported; array fields indexed per element.
- **RESP server** (optional): talk to it with `redis-cli` / `ioredis`.
+- **ClusterDb** (optional sharding layer): multi-process concurrent read/write
+ over N sharded MiniDb directories, scaling write throughput with shard count.
- **Value codecs**: `buffer`, `string`, or `json`.
- **Zero dependencies**, pure `node:*` built-ins.
@@ -261,6 +263,14 @@ bound the metadata/index footprint; it does not delete value bytes from disk
because values are already stored outside RAM. `db.stats.evictions` and
`db.stats.maxMemoryRejections` track the behavior.
+The budget is tracked in **approximate logical bytes** (key + value + dt
+metadata), not real JS heap usage. Actual per-key overhead is higher — on the
+order of several hundred bytes per key for the Map entry, the ordered index
+node, and bookkeeping — so a database of many small keys needs far more RAM
+than `store.bytes` suggests. (Measured: ~0.6 KB of heap per key for tiny
+records, so 1M small keys ≈ 600 MB of heap.) For millions of small keys,
+prefer `valueMode: 'disk'` and/or budget accordingly.
+
### Online backup / restore
```js
@@ -350,6 +360,14 @@ sorting in memory. On 10k sessions in one workspace, paginating with
`compoundRange` is ~20–40× faster than "fetch-all + sort", and stays
sub-millisecond at any offset.
+Multi-process `ClusterDb` scaling across process counts × shard counts (the
+headline: shard-affinity writes scale ~linearly until the single-shard speed
+is reached; uniform cross-shard traffic pays lock handoffs):
+
+```bash
+pnpm bench:cluster # bench/cluster.ts — spawns real writer/reader processes
+```
+
## Testing
Three layers of tests, run with the built-in `node:test` runner (no deps):
@@ -378,6 +396,13 @@ long-run behavior:
| `boundary.test.js` | key-length limits, large values, many keys, empty db |
| `soak.test.js` | sustained ops + heap stability (opt-in: `SOAK=30 npm run test:e2e`) |
+The **cluster suite** (`test/cluster/*.test.ts`) covers the `ClusterDb`
+sharding layer: topology/routing, merged scans, lock contention and lease
+renewal in-process, cross-shard indexes/compaction, true multi-process
+scenarios (concurrent writers on disjoint and shared shards, live cross-process
+read visibility, read/write storms) and crash takeovers (`kill -9` → contiguous
+recovery + stale-lock handoff).
+
## Design in one paragraph
Log-structured engine: all writes append to a CRC-framed **WAL** (group
@@ -414,6 +439,49 @@ const r = await MiniDb.open({ dir: './data', readOnly: true });
For many clients, run the **RESP server** (single minidb process, many TCP
clients) — that is the intended concurrent-access model, like Redis.
+### ClusterDb: multi-process sharding
+
+When several **processes** must read and write the same logical database
+without a server, `ClusterDb` shards the key space over N ordinary minidb
+directories (each with its own WAL, snapshot, and `db.lock`):
+
+```js
+import { ClusterDb } from '@moonshot-ai/minidb/cluster';
+
+const db = await ClusterDb.open({ dir: './data', shardCount: 16, valueCodec: 'json' });
+await db.set('user:1', { name: 'alice' }); // routed by hash to one shard
+await db.mset([['a:1', v1], ['b:2', v2]]); // grouped per shard, atomic per shard
+const all = await db.scan({ prefix: 'user:' }); // merged over all shards
+await db.close();
+```
+
+- **Writes** never meet a single global writer: each process acquires shard
+ write locks on demand (with retry up to `lockAcquireTimeoutMs`), caches them
+ briefly (`lockPoolMaxShards`, LRU), and yields a held shard after
+ `lockHoldMs` (default 250ms) so other processes are never starved. Throughput
+ scales with the number of distinct shards being written — up to the
+ single-shard speed of MiniDb per shard.
+- **Reads** never take locks: they use the cached writer when the local
+ process holds the shard, else a read-only instance that is revalidated
+ against the shard files on every use, so a read that starts after another
+ process's commit always observes it.
+- **Consistency**: single-key and same-shard batch ops are strongly
+ consistent (single writer per shard, atomic WAL frames). Cross-shard
+ `mset`/`mdel`/`batch` are best-effort (atomic per shard, not globally;
+ `crossShard: 'none'` rejects them instead). Scans merge per-shard snapshots,
+ so entries from different shards may reflect different points in time.
+- **Indexes** (`createIndex`/`createTextIndex`) are recorded in a cluster-wide
+ registry and applied by every shard writer on open; `findEq`/`findRange`/
+ `search` merge per-shard results (text scores are per-shard). Index
+ management acquires every shard writer — run it from one process, off the
+ hot path.
+- Crash recovery is per shard: a lock left by a dead PID is taken over by the
+ next opener, exactly like single MiniDb.
+
+`crossShard: '2pc'` is reserved for a future two-phase commit and is rejected
+today. Performance numbers across process/shard counts: run
+`pnpm bench:cluster` (see `bench/cluster.ts`).
+
For a **rebuildable cache**, use `openOrRebuild`: a corrupt cache is discarded
and reopened empty, while a live-locked db is never destroyed:
@@ -435,11 +503,20 @@ const db = await MiniDb.openOrRebuild(
cold, very large postings list can briefly block the event loop.
- Compaction is **non-blocking for writes**: the WAL itself acts as a
`BGREWRITEAOF`-style rewrite buffer, so the (slow) snapshot is written while
- writers keep appending. Writes pause only for a brief final rotation (a
- flush, a small tail copy, and two atomic renames).
+ writers keep appending. Writes pause only for the final rotation (a flush, a
+ tail copy, and two atomic renames). A pre-copy drains most of the tail
+ beforehand when writes are slow enough; under sustained writes that outrun
+ the pre-copy, the rotation absorbs a larger tail — the same bounded
+ end-of-rewrite pause Redis accepts for its AOF diff flush — so compaction
+ always terminates. Mid-compaction crashes leave `db.*.tmp` files behind,
+ which the next writer open removes automatically.
- Snapshot encoding runs on the main thread (chunked + yielding); offloading to
a `worker_thread` is a planned optimization.
-- Single process / single writer.
+- Single minidb directory = single process / single writer. For multi-process
+ access use `ClusterDb` (above): sharding scales writes, but hash routing
+ means whole-range scans fan out to all shards, and uniform cross-shard write
+ patterns pay shard-lock handoff costs (`lockHoldMs` per yield) — workloads
+ with per-process shard affinity scale best.
## Credits
diff --git a/packages/minidb/bench/cluster-worker.ts b/packages/minidb/bench/cluster-worker.ts
new file mode 100644
index 0000000000..75c5863d8a
--- /dev/null
+++ b/packages/minidb/bench/cluster-worker.ts
@@ -0,0 +1,91 @@
+// bench/cluster-worker.ts
+//
+// Worker process for the cluster concurrency benchmark. Spawned by
+// bench/cluster.ts; prints one JSON report line and exits.
+//
+// write
+// Writes n keys `${prefix}:${i}` (json values). `allow` is either 'all'
+// or a comma-separated shard-id list; keys routing outside the allowed
+// shards are skipped (shard-affinity workloads).
+// read
+
+import { ClusterDb } from '../src/cluster/index.js';
+import { LockError } from '../src/lockfile.js';
+
+const [, , mode, ...rest] = process.argv;
+
+function out(report: Record): void {
+ process.stdout.write(JSON.stringify(report) + '\n');
+}
+
+function sleep(ms: number): Promise {
+ return new Promise((resolve) => setTimeout(resolve, ms));
+}
+
+async function main(): Promise {
+ const [dir, shards, prefix, n, valueBytes = '64', lockHoldMs = '250', allow = 'all'] = rest;
+ const shardCount = Number(shards);
+ const allowed = allow === 'all' ? null : new Set(allow.split(',').map(Number));
+ const want = Number(n);
+
+ let retries = 0;
+ const withRetry = async (fn: () => Promise): Promise => {
+ for (;;) {
+ try {
+ return await fn();
+ } catch (e) {
+ if ((e as { code?: string }).code !== 'ELOCKED' && !(e instanceof LockError)) throw e;
+ retries++;
+ await sleep(15 + Math.floor(Math.random() * 45));
+ }
+ }
+ };
+
+ if (mode === 'write') {
+ const db = await ClusterDb.open({
+ dir: dir!,
+ shardCount,
+ valueCodec: 'json',
+ fsyncPolicy: 'everysec',
+ lockHoldMs: Number(lockHoldMs),
+ });
+ const pad = 'x'.repeat(Number(valueBytes));
+ let written = 0;
+ const t0 = performance.now();
+ for (let i = 0; written < want; i++) {
+ const key = `${prefix}:${i}`;
+ if (allowed && !allowed.has(db.shardOf(key))) continue;
+ await withRetry(() => db.set(key, { p: prefix, i, pad }));
+ written++;
+ }
+ const ms = performance.now() - t0;
+ out({ ok: 1, mode, n: written, ms, retries, lockWaits: db.stats().lockWaits });
+ await db.close();
+ return;
+ }
+
+ if (mode === 'read') {
+ const db = await ClusterDb.open({ dir: dir!, shardCount, valueCodec: 'json', readOnly: true });
+ let found = 0;
+ const t0 = performance.now();
+ for (let i = 0; found < want; i++) {
+ if (i > want * shardCount * 8 + 1000) throw new Error(`read found only ${found}/${want} keys`);
+ const key = `${prefix}:${i}`;
+ if (allowed && !allowed.has(db.shardOf(key))) continue;
+ const v = (await db.get(key)) as { i?: number } | undefined;
+ if (v !== undefined && v.i === i) found++;
+ }
+ const ms = performance.now() - t0;
+ out({ ok: 1, mode, n: want, found, ms, retries });
+ await db.close();
+ return;
+ }
+
+ out({ ok: 0, error: `unknown mode: ${mode}` });
+ process.exit(1);
+}
+
+main().catch((e) => {
+ out({ ok: 0, mode, error: String(e && (e as Error).stack ? (e as Error).stack : e) });
+ process.exit(1);
+});
diff --git a/packages/minidb/bench/cluster.ts b/packages/minidb/bench/cluster.ts
new file mode 100644
index 0000000000..9b42fa370d
--- /dev/null
+++ b/packages/minidb/bench/cluster.ts
@@ -0,0 +1,236 @@
+// bench/cluster.ts
+//
+// Multi-process concurrency benchmark for ClusterDb.
+//
+// For every (processes P, shards S) cell of the matrix:
+// WRITE-AFFINITY P processes, each confined to its own block of shards
+// (best case: measure sharding scale-out with zero lock
+// contention while P <= S).
+// WRITE-SCATTER P processes writing uniformly random keys across all
+// shards (worst case: every lock handoff costs up to the
+// hold window).
+// READ P read-only processes, each reading back another
+// process's keyspace (cross-process, mostly cross-shard).
+// A raw single-process MiniDb baseline is measured first for reference.
+//
+// Run: pnpm --filter @moonshot-ai/minidb bench:cluster
+// Env: CLUSTER_BENCH_PROCESSES=1,2,4,8 CLUSTER_BENCH_SHARDS=1,4,16
+// CLUSTER_BENCH_KEYS=3000 CLUSTER_BENCH_VALUE_BYTES=64
+// CLUSTER_BENCH_HOLD_MS=250
+
+import { spawn } from 'node:child_process';
+import fs from 'node:fs/promises';
+import os from 'node:os';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { MiniDb } from '../src/index.js';
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const WORKER = path.join(__dirname, 'cluster-worker.ts');
+
+const PROCESSES = (process.env.CLUSTER_BENCH_PROCESSES ?? '1,2,4,8').split(',').map(Number);
+const SHARDS = (process.env.CLUSTER_BENCH_SHARDS ?? '1,4,16').split(',').map(Number);
+const KEYS = Number(process.env.CLUSTER_BENCH_KEYS ?? 3000); // per process
+const VALUE_BYTES = Number(process.env.CLUSTER_BENCH_VALUE_BYTES ?? 64);
+const HOLD_MS = Number(process.env.CLUSTER_BENCH_HOLD_MS ?? 250);
+
+const fmt = (n: number) => n.toLocaleString('en-US', { maximumFractionDigits: 0 });
+
+interface Report {
+ ok: number;
+ mode: string;
+ n: number;
+ ms: number;
+ found?: number;
+ retries: number;
+ lockWaits?: number;
+ error?: string;
+}
+
+function runWorker(args: string[]): Promise {
+ return new Promise((resolve, reject) => {
+ const child = spawn(process.execPath, ['--import', 'tsx', WORKER, ...args], { stdio: ['ignore', 'pipe', 'pipe'] });
+ let stdout = '';
+ let stderr = '';
+ child.stdout.on('data', (d) => (stdout += d));
+ child.stderr.on('data', (d) => (stderr += d));
+ child.on('error', reject);
+ child.on('exit', (code) => {
+ const lines = stdout.trim().split('\n').filter((l) => l.startsWith('{'));
+ if (code !== 0 || lines.length === 0) {
+ reject(new Error(`worker failed (code=${code}) args=${args.join(' ')}\n${stderr}\n${stdout}`));
+ return;
+ }
+ const report = JSON.parse(lines[lines.length - 1]!) as Report;
+ if (!report.ok) reject(new Error(`worker error args=${args.join(' ')}: ${report.error}`));
+ else resolve(report);
+ });
+ });
+}
+
+async function rmrf(dir: string): Promise {
+ await fs.rm(dir, { recursive: true, force: true });
+}
+
+/** Partition S shards into P contiguous blocks (overlapping when P > S). */
+function shardBlock(p: number, procs: number, shards: number): string {
+ const s = Math.floor((p * shards) / procs);
+ const e = Math.max(s + 1, Math.floor(((p + 1) * shards) / procs));
+ const ids: number[] = [];
+ for (let i = s; i < Math.min(e, shards); i++) ids.push(i);
+ return ids.join(',') || 'all';
+}
+
+interface CellResult {
+ procs: number;
+ shards: number;
+ affinityOps: number;
+ affinityPerProc: number;
+ scatterOps: number;
+ scatterPerProc: number;
+ scatterRetries: number;
+ readOps: number;
+ readPerProc: number;
+}
+
+async function benchCell(procs: number, shards: number): Promise {
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'minidb-cluster-bench-'));
+ try {
+ // ---- phase 1: affinity writes ------------------------------------------
+ const affinityArgs = Array.from({ length: procs }, (_, p) => [
+ 'write',
+ dir,
+ String(shards),
+ `w${p}`,
+ String(KEYS),
+ String(VALUE_BYTES),
+ String(HOLD_MS),
+ shardBlock(p, procs, shards),
+ ]);
+ const affinity = await Promise.all(affinityArgs.map((args) => runWorker(args)));
+ const affinityTotal = affinity.reduce((s, r) => s + r.n, 0);
+ const affinityWall = Math.max(...affinity.map((r) => r.ms));
+
+ // ---- phase 2: scatter writes -------------------------------------------
+ const scatterArgs = Array.from({ length: procs }, (_, p) => [
+ 'write',
+ dir,
+ String(shards),
+ `s${p}`,
+ String(KEYS),
+ String(VALUE_BYTES),
+ String(HOLD_MS),
+ 'all',
+ ]);
+ const scatter = await Promise.all(scatterArgs.map((args) => runWorker(args)));
+ const scatterTotal = scatter.reduce((s, r) => s + r.n, 0);
+ const scatterWall = Math.max(...scatter.map((r) => r.ms));
+ const scatterRetries = scatter.reduce((s, r) => s + (r.lockWaits ?? r.retries), 0);
+
+ // ---- phase 3: cross-process reads of the scatter keyspace ---------------
+ const readArgs = Array.from({ length: procs }, (_, p) => [
+ 'read',
+ dir,
+ String(shards),
+ `s${(p + 1) % procs}`,
+ String(KEYS),
+ String(VALUE_BYTES),
+ String(HOLD_MS),
+ 'all',
+ ]);
+ const reads = await Promise.all(readArgs.map((args) => runWorker(args)));
+ for (const r of reads) {
+ if (r.found !== r.n) throw new Error(`read verification failed: found ${r.found}/${r.n}`);
+ }
+ const readTotal = reads.reduce((s, r) => s + r.found!, 0);
+ const readWall = Math.max(...reads.map((r) => r.ms));
+
+ const agg = (total: number, wall: number) => (total / wall) * 1000;
+ const perProc = (reports: Report[]) => (KEYS / (reports.reduce((s, r) => s + r.ms, 0) / reports.length)) * 1000;
+ return {
+ procs,
+ shards,
+ affinityOps: agg(affinityTotal, affinityWall),
+ affinityPerProc: perProc(affinity),
+ scatterOps: agg(scatterTotal, scatterWall),
+ scatterPerProc: perProc(scatter),
+ scatterRetries,
+ readOps: agg(readTotal, readWall),
+ readPerProc: perProc(reads),
+ };
+ } finally {
+ await rmrf(dir);
+ }
+}
+
+async function baseline(): Promise<{ writeOps: number; readOps: number }> {
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'minidb-bench-baseline-'));
+ try {
+ const db = await MiniDb.open({ dir, valueCodec: 'json', fsyncPolicy: 'everysec' });
+ const pad = 'x'.repeat(VALUE_BYTES);
+ let t0 = performance.now();
+ for (let i = 0; i < KEYS; i++) await db.set(`w0:${i}`, { p: 'w0', i, pad });
+ const writeMs = performance.now() - t0;
+ t0 = performance.now();
+ let found = 0;
+ for (let i = 0; i < KEYS; i++) {
+ const v = db.get(`w0:${i}`) as { i: number } | undefined;
+ if (v?.i === i) found++;
+ }
+ const readMs = performance.now() - t0;
+ if (found !== KEYS) throw new Error(`baseline verification failed ${found}/${KEYS}`);
+ await db.close();
+ return { writeOps: (KEYS / writeMs) * 1000, readOps: (KEYS / readMs) * 1000 };
+ } finally {
+ await rmrf(dir);
+ }
+}
+
+function printMatrix(title: string, cells: CellResult[], pick: (c: CellResult) => number): void {
+ console.log(`\n${title}`);
+ const header = ['P \\ S', ...SHARDS.map((s) => String(s).padStart(12))].join(' | ');
+ console.log(` ${header}`);
+ for (const p of PROCESSES) {
+ const row = [
+ String(p).padEnd(5),
+ ...SHARDS.map((s) => fmt(pick(cells.find((c) => c.procs === p && c.shards === s)!)).padStart(12)),
+ ].join(' | ');
+ console.log(` ${row}`);
+ }
+}
+
+async function main(): Promise {
+ console.log(
+ `\nClusterDb concurrency benchmark (keys/proc=${fmt(KEYS)}, value=${VALUE_BYTES}B pad, codec=json, fsync=everysec, lockHoldMs=${HOLD_MS}, node ${process.version})`,
+ );
+
+ const base = await baseline();
+ console.log(`baseline raw MiniDb (1 process, in-process, awaited writes):`);
+ console.log(` write ${fmt(base.writeOps)} ops/s | read ${fmt(base.readOps)} ops/s`);
+
+ const cells: CellResult[] = [];
+ for (const shards of SHARDS) {
+ for (const procs of PROCESSES) {
+ process.stdout.write(`running P=${procs} S=${shards} ... `);
+ const cell = await benchCell(procs, shards);
+ cells.push(cell);
+ console.log(
+ `affinity ${fmt(cell.affinityOps)} (${fmt(cell.affinityPerProc)}/proc) | ` +
+ `scatter ${fmt(cell.scatterOps)} (${fmt(cell.scatterPerProc)}/proc, lockWaits=${fmt(cell.scatterRetries)}) | ` +
+ `read ${fmt(cell.readOps)} (${fmt(cell.readPerProc)}/proc)`,
+ );
+ }
+ }
+
+ console.log(`\nall numbers are aggregate ops/s across processes (spawn/teardown excluded)`);
+ printMatrix('WRITE-AFFINITY', cells, (c) => c.affinityOps);
+ printMatrix('WRITE-SCATTER ', cells, (c) => c.scatterOps);
+ printMatrix('READ ', cells, (c) => c.readOps);
+ printMatrix('SCATTER lock waits', cells, (c) => c.scatterRetries);
+ console.log('');
+}
+
+main().catch((e) => {
+ console.error(e);
+ process.exit(1);
+});
diff --git a/packages/minidb/bench/reader-catchup.ts b/packages/minidb/bench/reader-catchup.ts
new file mode 100644
index 0000000000..e6aa6c3738
--- /dev/null
+++ b/packages/minidb/bench/reader-catchup.ts
@@ -0,0 +1,237 @@
+// bench/reader-catchup.ts
+//
+// Reader-side catch-up benchmark for ClusterDb: one writer child process
+// hammers a single hot shard while this process reads keys on that shard
+// through a second, read-only ClusterDb. Measures per-read latency and how
+// the reader pool keeps its cached shard instance current.
+//
+// BEFORE (full reopen per WAL change):
+// every read whose fingerprint check notices a WAL append closes the
+// cached reader and replays snapshot+WAL from scratch — O(shard size).
+// AFTER (incremental WAL catch-up):
+// only the appended WAL frames are scanned and applied — O(delta).
+//
+// Run: node --import tsx bench/reader-catchup.ts
+// Env: READER_BENCH_KEYS=10000,50000 READER_BENCH_WINDOW_MS=8000
+// READER_BENCH_VALUE_BYTES=150 READER_BENCH_SHARDS=4
+// READER_BENCH_PACE_MS=5 (writer sleep between ops ~190 ops/s; the WAL
+// then grows steadily enough that a full-reopen-per-read reader can
+// never catch a quiet fingerprint, which pins the disputed cost)
+//
+// ---------------------------------------------------------------------------
+// RESULTS (this machine, node v24.15.0, valueBytes=150, poll=20ms, window
+// 8000ms, 4 shards, hot shard = 1, writer pace=5ms):
+//
+// BEFORE (2026-07-17, pre-change; run: node --import tsx bench/reader-catchup.ts):
+// keys | build | reads | qps | p50 ms | p95 ms | p99 ms | reopens | catchups | frames
+// 10,000 | 1,054ms | 52 | 6/s | 130.5 | 157.2 | 238.7 | 52 | 0 | 0
+// 50,000 | 5,054ms | 14 | 2/s | 532.2 | 860.8 | 860.8 | 14 | 0 | 0
+// Every 20ms poll observes fresh WAL appends, so every read pays a full
+// replay of the whole shard (snapshot + rebuild of all derived indexes):
+// read QPS collapses to ~1/reopen-cost and p50 = the reopen cost itself.
+//
+// AFTER (same command, with incremental catch-up):
+// keys | build | reads | qps | p50 ms | p95 ms | p99 ms | reopens | catchups | frames
+// 10,000 | 868ms | 374 | 47/s | 0.4 | 1.3 | 5.7 | 0 | 374 | 1,388
+// 50,000 | 5,671ms | 368 | 46/s | 0.4 | 2.7 | 6.2 | 0 | 368 | 1,345
+// Every poll applies only the appended WAL frames (catchupFrames == the
+// writer's ops) and never reopens: p50 drops ~330x (@10k) / ~1,330x (@50k);
+// read QPS rises 6→47/s and 2→46/s (bounded only by the 20ms poll grid).
+// ---------------------------------------------------------------------------
+
+import { spawn } from 'node:child_process';
+import fs from 'node:fs/promises';
+import os from 'node:os';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { ClusterDb, shardDirName } from '../src/cluster/index.js';
+import { shardFor } from '../src/cluster/utils.js';
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const WORKER = path.join(__dirname, 'reader-worker.ts');
+
+const SIZES = (process.env.READER_BENCH_KEYS ?? '10000,50000').split(',').map(Number);
+const WINDOW_MS = Number(process.env.READER_BENCH_WINDOW_MS ?? 8000);
+const VALUE_BYTES = Number(process.env.READER_BENCH_VALUE_BYTES ?? 150);
+const SHARDS = Number(process.env.READER_BENCH_SHARDS ?? 4);
+const PACE_MS = Number(process.env.READER_BENCH_PACE_MS ?? 5);
+const POLL_MS = Number(process.env.READER_BENCH_POLL_MS ?? 20);
+const HOT_SHARD = 1;
+
+const fmt = (n: number) => n.toLocaleString('en-US', { maximumFractionDigits: 0 });
+
+interface Report {
+ ok: number;
+ mode: string;
+ n: number;
+ ms: number;
+ compactMs?: number;
+ error?: string;
+}
+
+function spawnWorker(args: string[]): { child: ReturnType; done: Promise } {
+ const child = spawn(process.execPath, ['--import', 'tsx', WORKER, ...args], { stdio: ['ignore', 'pipe', 'pipe'] });
+ let stdout = '';
+ let stderr = '';
+ child.stdout.on('data', (d) => (stdout += d));
+ child.stderr.on('data', (d) => (stderr += d));
+ const done = new Promise((resolve, reject) => {
+ child.on('error', reject);
+ child.on('close', (code) => {
+ const lines = stdout.trim().split('\n').filter((l) => l.startsWith('{'));
+ if (code !== 0 || lines.length === 0) {
+ reject(new Error(`worker failed (code=${code}) args=${args.join(' ')}\n${stderr}`));
+ return;
+ }
+ const report = JSON.parse(lines[lines.length - 1]!) as Report;
+ if (!report.ok) reject(new Error(`worker error args=${args.join(' ')}: ${report.error}`));
+ else resolve(report);
+ });
+ });
+ return { child, done };
+}
+
+async function runWorker(args: string[]): Promise {
+ const { done } = spawnWorker(args);
+ return done;
+}
+
+function sleep(ms: number): Promise {
+ return new Promise((r) => setTimeout(r, ms));
+}
+
+function percentile(sorted: number[], p: number): number {
+ if (!sorted.length) return NaN;
+ const idx = Math.min(sorted.length - 1, Math.ceil((p / 100) * sorted.length) - 1);
+ return sorted[Math.max(0, idx)]!;
+}
+
+/** The exact preload key list (same generator as reader-worker 'preload'). */
+function preloadKeys(n: number): string[] {
+ const keys: string[] = [];
+ for (let seq = 0; keys.length < n; seq++) {
+ const key = `pre:${seq}`;
+ if (shardFor(key, SHARDS) === HOT_SHARD) keys.push(key);
+ }
+ return keys;
+}
+
+interface Row {
+ keys: number;
+ buildMs: number;
+ reads: number;
+ qps: number;
+ p50: number;
+ p95: number;
+ p99: number;
+ writerOps: number;
+ readerReopens: number;
+ incrementalCatchups: number;
+ catchupFramesApplied: number;
+}
+
+async function benchSize(n: number): Promise {
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'minidb-reader-bench-'));
+ const keys = preloadKeys(n);
+ let db: ClusterDb | null = null;
+ try {
+ // Build the shard (writes + compaction), then start the hot writer.
+ const t0 = performance.now();
+ await runWorker(['preload', dir, String(SHARDS), String(HOT_SHARD), String(n), String(VALUE_BYTES)]);
+ const buildMs = performance.now() - t0;
+
+ db = await ClusterDb.open({ dir, readOnly: true });
+ // Warm the reader cache (this first open pays the full replay once).
+ for (let i = 0; i < 3; i++) await db.get(keys[i]!);
+ const stats0 = db.stats();
+
+ const hammer = spawnWorker(['hammer', dir, String(SHARDS), String(HOT_SHARD), String(VALUE_BYTES), String(PACE_MS)]);
+ // The hammer's own shard open replays the (large) shard first, so it only
+ // starts appending hundreds of ms after spawn. Wait for the WAL to grow
+ // once before measuring, or the early window samples a quiet file.
+ const walPath = path.join(dir, shardDirName(HOT_SHARD, SHARDS), 'db.wal');
+ const walBase = await fs.stat(walPath).then((s) => s.size, () => -1);
+ for (let waited = 0; ; waited += 50) {
+ const cur = await fs.stat(walPath).then((s) => s.size, () => -1);
+ if (cur !== walBase || waited > 15_000) break;
+ await sleep(50);
+ }
+
+ // Poll at a fixed interval rather than busy-looping: with a hot writer the
+ // WAL changes between any two polls, so every poll pays exactly one
+ // refresh — a full reopen before, an incremental catch-up after. A busy
+ // loop instead degenerates into a scheduling lottery between the two
+ // processes and measures mostly stale-fingerprint fast paths.
+ const lat: number[] = [];
+ const w0 = performance.now();
+ const deadline = w0 + WINDOW_MS;
+ let i = 0;
+ while (performance.now() < deadline) {
+ await sleep(POLL_MS);
+ const t = performance.now();
+ await db.get(keys[i % keys.length]!);
+ lat.push(performance.now() - t);
+ i++;
+ }
+ const windowMs = performance.now() - w0;
+
+ hammer.child.kill('SIGTERM');
+ const report = await Promise.race([
+ hammer.done,
+ sleep(10_000).then(() => {
+ hammer.child.kill('SIGKILL');
+ return hammer.done;
+ }),
+ ]);
+
+ lat.sort((a, b) => a - b);
+ const stats1 = db.stats();
+ return {
+ keys: n,
+ buildMs,
+ reads: lat.length,
+ qps: (lat.length / windowMs) * 1000,
+ p50: percentile(lat, 50),
+ p95: percentile(lat, 95),
+ p99: percentile(lat, 99),
+ writerOps: report.n,
+ readerReopens: stats1.readerReopens - stats0.readerReopens,
+ incrementalCatchups: (stats1.incrementalCatchups ?? 0) - (stats0.incrementalCatchups ?? 0),
+ catchupFramesApplied: (stats1.catchupFramesApplied ?? 0) - (stats0.catchupFramesApplied ?? 0),
+ };
+ } finally {
+ if (db) await db.close().catch(() => {});
+ await fs.rm(dir, { recursive: true, force: true });
+ }
+}
+
+async function main(): Promise {
+ console.log(
+ `\nClusterDb reader catch-up benchmark (value=${VALUE_BYTES}B pad, window=${WINDOW_MS}ms, poll=${POLL_MS}ms, S=${SHARDS}, hot shard=${HOT_SHARD}, writer pace=${PACE_MS}ms, fsync=no, node ${process.version})`,
+ );
+ const rows: Row[] = [];
+ for (const n of SIZES) {
+ process.stdout.write(`building shard with ${fmt(n)} keys ... `);
+ const row = await benchSize(n);
+ rows.push(row);
+ console.log(
+ `built in ${fmt(row.buildMs)}ms; reads=${fmt(row.reads)} (${fmt(row.qps)}/s), ` +
+ `p50=${row.p50.toFixed(1)}ms p95=${row.p95.toFixed(1)}ms p99=${row.p99.toFixed(1)}ms, ` +
+ `writerOps=${fmt(row.writerOps)}, fullReopens=${fmt(row.readerReopens)}, ` +
+ `incrementalCatchups=${fmt(row.incrementalCatchups)}, catchupFrames=${fmt(row.catchupFramesApplied)}`,
+ );
+ }
+
+ console.log(`\n ${'keys'.padStart(8)} | ${'build'.padStart(8)} | ${'reads'.padStart(7)} | ${'qps'.padStart(8)} | ${'p50 ms'.padStart(8)} | ${'p95 ms'.padStart(8)} | ${'p99 ms'.padStart(8)} | ${'reopens'.padStart(8)} | ${'catchups'.padStart(9)} | ${'frames'.padStart(9)}`);
+ for (const r of rows) {
+ console.log(
+ ` ${fmt(r.keys).padStart(8)} | ${fmt(r.buildMs).padStart(8)} | ${fmt(r.reads).padStart(7)} | ${fmt(r.qps).padStart(8)} | ${r.p50.toFixed(1).padStart(8)} | ${r.p95.toFixed(1).padStart(8)} | ${r.p99.toFixed(1).padStart(8)} | ${fmt(r.readerReopens).padStart(8)} | ${fmt(r.incrementalCatchups).padStart(9)} | ${fmt(r.catchupFramesApplied).padStart(9)}`,
+ );
+ }
+ console.log('');
+}
+
+main().catch((e) => {
+ console.error(e);
+ process.exit(1);
+});
diff --git a/packages/minidb/bench/reader-worker.ts b/packages/minidb/bench/reader-worker.ts
new file mode 100644
index 0000000000..e26819f29b
--- /dev/null
+++ b/packages/minidb/bench/reader-worker.ts
@@ -0,0 +1,104 @@
+// bench/reader-worker.ts
+//
+// Child-process entrypoint for bench/reader-catchup.ts. Not run directly.
+//
+// preload
+// Write n keys (`pre:`, all routed to ) then compact, so the
+// shard holds a large snapshot + a nearly-empty WAL when the reader
+// benchmark starts.
+// hammer
+// Overwrite `hot:` keys on as fast as possible until SIGTERM,
+// keeping the shard's WAL hot so the parent's reads never see a quiet
+// fingerprint.
+
+import { ClusterDb } from '../src/cluster/index.js';
+import { shardFor } from '../src/cluster/utils.js';
+
+const [, , mode, ...rest] = process.argv;
+
+function out(report: Record): void {
+ process.stdout.write(JSON.stringify(report) + '\n');
+}
+
+/** Keys `${seed}:${n}` (n = 0,1,2,...) that route to `shard`, in order. */
+function* keysOnShard(seed: string, shard: number, shards: number): Generator {
+ for (let n = 0; ; n++) {
+ const key = `${seed}:${n}`;
+ if (shardFor(key, shards) === shard) yield key;
+ }
+}
+
+function value(i: number, valueBytes: number): { p: string; i: number; pad: string } {
+ return { p: 'b', i, pad: 'x'.repeat(valueBytes) };
+}
+
+async function main(): Promise {
+ if (mode === 'preload') {
+ const [dir, shards, shard, n, valueBytes] = rest;
+ const db = await ClusterDb.open({
+ dir: dir!,
+ shardCount: Number(shards),
+ valueCodec: 'json',
+ fsyncPolicy: 'no',
+ autoCompact: false,
+ });
+ const gen = keysOnShard('pre', Number(shard), Number(shards));
+ const t0 = performance.now();
+ const count = Number(n);
+ for (let i = 0; i < count; i++) {
+ await db.set(gen.next().value!, value(i, Number(valueBytes)));
+ }
+ const writeMs = performance.now() - t0;
+ const t1 = performance.now();
+ await db.compact();
+ const compactMs = performance.now() - t1;
+ out({ ok: 1, mode, n: count, ms: writeMs, compactMs });
+ await db.close();
+ return;
+ }
+
+ if (mode === 'hammer') {
+ const [dir, shards, shard, valueBytes, paceMs = '5'] = rest;
+ const db = await ClusterDb.open({
+ dir: dir!,
+ shardCount: Number(shards),
+ valueCodec: 'json',
+ fsyncPolicy: 'no',
+ // No compaction during the measured window: rotation costs are covered
+ // by tests, mixing one into a latency sample only muddies the numbers.
+ autoCompact: false,
+ // Keep the shard lock held: the default 250ms hold window would force a
+ // writer reopen (full WAL replay) several times a second, which would
+ // throttle the WAL drip this benchmark is meant to produce.
+ lockHoldMs: 0,
+ });
+ let stop = false;
+ process.on('SIGTERM', () => {
+ stop = true;
+ });
+ // A small yield between writes paces the WAL into a steady drip: un-paced,
+ // the writer's own group commit coalesces hundreds of frames into one
+ // writev, so a reader only observes the file change a few times a second
+ // instead of on (almost) every read.
+ const pace = Number(paceMs);
+ const gen = keysOnShard('hot', Number(shard), Number(shards));
+ let i = 0;
+ const t0 = performance.now();
+ while (!stop) {
+ await db.set(gen.next().value!, value(i, Number(valueBytes)));
+ i++;
+ if (pace > 0) await new Promise((r) => setTimeout(r, pace));
+ }
+ out({ ok: 1, mode, n: i, ms: performance.now() - t0 });
+ await db.close();
+ return;
+ }
+
+ out({ ok: 0, error: `unknown mode: ${mode}` });
+ process.exit(1);
+}
+
+main().catch((e) => {
+ out({ ok: 0, mode, error: String(e && (e as Error).stack ? (e as Error).stack : e) });
+ process.exit(1);
+});
diff --git a/packages/minidb/package.json b/packages/minidb/package.json
index 0113961e54..1974c8466f 100644
--- a/packages/minidb/package.json
+++ b/packages/minidb/package.json
@@ -33,6 +33,10 @@
".": {
"types": "./src/index.ts",
"default": "./src/index.ts"
+ },
+ "./cluster": {
+ "types": "./src/cluster/index.ts",
+ "default": "./src/cluster/index.ts"
}
},
"scripts": {
@@ -40,6 +44,7 @@
"typecheck": "tsc -p tsconfig.json --noEmit",
"test": "vitest run",
"bench": "node --import tsx bench/bench.ts",
+ "bench:cluster": "node --import tsx bench/cluster.ts",
"clean": "rm -rf dist"
}
}
diff --git a/packages/minidb/src/cluster/coordinator.ts b/packages/minidb/src/cluster/coordinator.ts
new file mode 100644
index 0000000000..54a70abe63
--- /dev/null
+++ b/packages/minidb/src/cluster/coordinator.ts
@@ -0,0 +1,101 @@
+// src/cluster/coordinator.ts
+//
+// Cross-shard write coordination. Ops are grouped by shard; groups are
+// executed in ascending shard-id order (the fixed lock-acquisition order that
+// avoids deadlocks between processes). Within one shard the group commits as
+// a single MiniDb batch (one WAL frame, all-or-nothing); across shards the
+// semantics depend on the configured CrossShardMode.
+
+import type { BatchInputOp, MiniDb } from '../index.js';
+import type { CrossShardMode } from './types.js';
+import type { Router } from './router.js';
+
+export class Coordinator {
+ constructor(
+ private readonly router: Router,
+ private readonly runOnShard: (shardId: number, fn: (db: MiniDb) => T | Promise) => Promise,
+ private readonly mode: CrossShardMode,
+ ) {}
+
+ private checkMode(shardIds: number[]): void {
+ if (shardIds.length <= 1) return;
+ if (this.mode === 'none') {
+ throw new Error(`operation spans ${shardIds.length} shards but crossShard mode is 'none'`);
+ }
+ if (this.mode === '2pc') {
+ throw new Error("crossShard mode '2pc' is reserved but not implemented yet");
+ }
+ }
+
+ /** Group items by their key's shard, shards in ascending id order. */
+ private group(items: readonly T[], keyOf: (item: T) => string): [number, T[]][] {
+ const byShard = new Map();
+ for (const item of items) {
+ const id = this.router.shardFor(keyOf(item));
+ const arr = byShard.get(id);
+ if (arr) arr.push(item);
+ else byShard.set(id, [item]);
+ }
+ return [...byShard.entries()].sort((a, b) => a[0] - b[0]);
+ }
+
+ private async runGroups(
+ groups: [number, T[]][],
+ run: (shardId: number, items: T[]) => Promise,
+ opName: string,
+ ): Promise {
+ this.checkMode(groups.map(([id]) => id));
+ const errors: unknown[] = [];
+ for (const [id, items] of groups) {
+ try {
+ await run(id, items);
+ } catch (e) {
+ // best-effort: earlier groups may be committed already; report, don't hide.
+ errors.push(e);
+ }
+ }
+ if (errors.length > 0) {
+ throw new AggregateError(errors, `${opName} failed on ${errors.length}/${groups.length} shard(s); partial writes possible`);
+ }
+ }
+
+ /** Multi-shard mset. Atomic within each shard (single WAL batch frame). */
+ async mset(entries: readonly (readonly [string, V])[]): Promise {
+ const groups = this.group(entries, ([key]) => key);
+ await this.runGroups(
+ groups,
+ (id, items) =>
+ this.runOnShard(id, (db) => db.batch(items.map(([key, value]) => ({ op: 'set' as const, key, value })))),
+ 'mset',
+ );
+ }
+
+ /** Multi-shard delete. Returns the number of keys that actually existed. */
+ async mdel(keys: readonly string[]): Promise {
+ const groups = this.group(keys, (k) => k);
+ this.checkMode(groups.map(([id]) => id));
+ let removed = 0;
+ const errors: unknown[] = [];
+ for (const [id, ks] of groups) {
+ try {
+ removed += await this.runOnShard(id, async (db) => {
+ const existing = ks.filter((k) => db.has(k));
+ if (existing.length > 0) await db.batch(existing.map((key) => ({ op: 'del' as const, key })));
+ return existing.length;
+ });
+ } catch (e) {
+ errors.push(e);
+ }
+ }
+ if (errors.length > 0) {
+ throw new AggregateError(errors, `mdel failed on ${errors.length}/${groups.length} shard(s); partial writes possible`);
+ }
+ return removed;
+ }
+
+ /** Multi-shard atomic-per-shard batch (set/del ops with optional ttl/dt). */
+ async batch(ops: readonly BatchInputOp[]): Promise {
+ const groups = this.group(ops, (op) => op.key);
+ await this.runGroups(groups, (id, items) => this.runOnShard(id, (db) => db.batch(items)), 'batch');
+ }
+}
diff --git a/packages/minidb/src/cluster/index.ts b/packages/minidb/src/cluster/index.ts
new file mode 100644
index 0000000000..c16b867dd4
--- /dev/null
+++ b/packages/minidb/src/cluster/index.ts
@@ -0,0 +1,581 @@
+// src/cluster/index.ts
+//
+// ClusterDb: a logically single MiniDb database sharded into N independent
+// MiniDb directories so multiple processes can read and write concurrently.
+//
+// - Placement is a pure hash (see router.ts): every process agrees on the
+// shard for a key without coordination.
+// - Each shard keeps minidb's single-writer model (its own db.lock), so
+// concurrency scales with the number of distinct shards being written.
+// - Writes route through a per-process writer pool (lock-pool.ts): cached
+// shard writers hold their lock and renew its timestamp; acquisition
+// retries live holders up to lockAcquireTimeoutMs.
+// - Reads never take write locks: they use the cached writer when this
+// process holds the shard, else a read-only MiniDb revalidated against
+// the shard files' fingerprint on every use.
+// - Index definitions live in a cluster-wide registry (cluster.indexes.json)
+// as the source of truth; every shard writer applies missing definitions
+// right after opening, and create/drop operations fan out to all shards.
+//
+// Consistency: single-key and same-shard batch ops are strongly consistent
+// (single writer per shard, atomic WAL frames). Cross-shard mset/mdel/batch
+// are best-effort (atomic per shard, not globally). scan/prefix results are a
+// per-shard snapshot merged globally, so entries from different shards may
+// reflect different points in time.
+
+import fs from 'node:fs/promises';
+import path from 'node:path';
+import type { BatchInputOp, IndexDef, IndexInfo, MiniDb, ScanEntry, SetOptions } from '../index.js';
+import { LockError } from '../lockfile.js';
+import { Coordinator } from './coordinator.js';
+import { ShardLockPool } from './lock-pool.js';
+import { Router } from './router.js';
+import { Topology } from './topology.js';
+import type {
+ ClusterIndexRegistry,
+ ClusterOpenOptions,
+ ClusterStats,
+ CompactResult,
+ ScanOptions,
+} from './types.js';
+import { CLUSTER_INDEX_FILE, sleep } from './utils.js';
+
+export type {
+ ClusterIndexRegistry,
+ ClusterMeta,
+ ClusterOpenOptions,
+ ClusterStats,
+ CompactResult,
+ CrossShardMode,
+ ScanOptions,
+} from './types.js';
+export { Router } from './router.js';
+export { Topology } from './topology.js';
+export { LockError } from '../lockfile.js';
+export type { ShardOpenOptions } from './shard.js';
+export { shardDirName, shardFor, stableHash32 } from './utils.js';
+
+/** Compare ScanEntry keys by UTF-8 byte order, matching the per-shard store
+ * ordering so a globally sorted merge is consistent with local scans. */
+function compareEntries(a: ScanEntry, b: ScanEntry): number {
+ return Buffer.compare(Buffer.from(a.key, 'utf8'), Buffer.from(b.key, 'utf8'));
+}
+
+export class ClusterDb {
+ private closed = false;
+
+ private constructor(
+ private readonly topology: Topology,
+ private readonly router: Router,
+ private readonly pool: ShardLockPool,
+ private readonly coordinator: Coordinator,
+ private readonly indexPath: string,
+ readonly readOnly: boolean,
+ ) {}
+
+ static async open(opts: ClusterOpenOptions): Promise> {
+ if (!opts || !opts.dir) throw new TypeError('ClusterDb.open: opts.dir is required');
+ if ((opts.crossShard ?? 'best-effort') === '2pc') {
+ throw new Error("crossShard: '2pc' is reserved for a future release and is not implemented yet");
+ }
+ const topology = await Topology.open(opts.dir, opts);
+ await topology.ensureShardDirs();
+ const router = new Router(opts.dir, topology.meta);
+ const readOnly = !!opts.readOnly;
+ const indexPath = path.join(opts.dir, CLUSTER_INDEX_FILE);
+
+ const pool = new ShardLockPool({
+ writerOpts: {
+ valueCodec: topology.meta.valueCodec,
+ fsyncPolicy: topology.meta.fsyncPolicy,
+ valueMode: opts.valueMode,
+ compactThresholdBytes: opts.compactThresholdBytes,
+ autoCompact: opts.autoCompact,
+ activeExpireIntervalMs: opts.activeExpireIntervalMs,
+ recovery: opts.recovery,
+ maxMemoryBytes: opts.maxMemoryBytes,
+ maxMemoryPolicy: opts.maxMemoryPolicy,
+ },
+ readerOpts: {
+ valueCodec: topology.meta.valueCodec,
+ valueMode: opts.valueMode,
+ recovery: opts.recovery,
+ },
+ lockRenewMs: opts.lockRenewMs ?? 10_000,
+ lockAcquireTimeoutMs: opts.lockAcquireTimeoutMs ?? 30_000,
+ lockHoldMs: opts.lockHoldMs ?? 250,
+ maxWriters: opts.lockPoolMaxShards ?? 16,
+ maxReaders: opts.readersMaxShards ?? topology.shardCount,
+ readOnly,
+ applyDefs: async (db) => {
+ const reg = await ClusterDb.loadRegistry(indexPath);
+ for (const { name, def } of reg.indexes) {
+ if (!db.listIndexes().some((i) => i.name === name)) await db.createIndex(name, def);
+ }
+ for (const { name, fields } of reg.textIndexes) {
+ try {
+ await db.createTextIndex(name, { fields: fields ?? undefined });
+ } catch (e) {
+ // Idempotent apply: the def may already exist on this shard.
+ if (!(e instanceof Error) || !e.message.includes('already exists')) throw e;
+ }
+ }
+ },
+ });
+ const coordinator = new Coordinator(
+ router,
+ (shardId, fn) => pool.withWriter(shardId, router.shardDir(shardId), (db) => fn(db as MiniDb)),
+ opts.crossShard ?? 'best-effort',
+ );
+ return new ClusterDb(topology, router, pool, coordinator, indexPath, readOnly);
+ }
+
+ private ensureOpen(): void {
+ if (this.closed) throw new Error('ClusterDb is closed');
+ }
+
+ get dir(): string {
+ return this.topology.dir;
+ }
+
+ get shardCount(): number {
+ return this.router.shardCount;
+ }
+
+ /** Which shard a key lives on. */
+ shardOf(key: string): number {
+ return this.router.shardFor(key);
+ }
+
+ private writer(shardId: number, fn: (db: MiniDb) => T | Promise): Promise {
+ return this.pool.withWriter(shardId, this.router.shardDir(shardId), (db) => fn(db as MiniDb));
+ }
+
+ private reader(shardId: number, fn: (db: MiniDb) => T | Promise): Promise {
+ return this.pool.withReader(shardId, this.router.shardDir(shardId), (db) => fn(db as MiniDb));
+ }
+
+ // ---- single-key ops -------------------------------------------------------
+
+ async get(key: string): Promise {
+ this.ensureOpen();
+ return this.reader(this.router.shardFor(key), (db) => db.get(key));
+ }
+
+ async set(key: string, value: V, opts?: SetOptions): Promise {
+ this.ensureOpen();
+ await this.writer(this.router.shardFor(key), (db) => db.set(key, value, opts));
+ }
+
+ async del(key: string): Promise {
+ this.ensureOpen();
+ return this.writer(this.router.shardFor(key), (db) => db.del(key));
+ }
+
+ async has(key: string): Promise {
+ this.ensureOpen();
+ return this.reader(this.router.shardFor(key), (db) => db.has(key));
+ }
+
+ /** Remaining TTL in ms; -2 when the key does not exist, -1 when it has no TTL. */
+ async ttl(key: string): Promise {
+ this.ensureOpen();
+ return this.reader(this.router.shardFor(key), (db) => db.ttl(key));
+ }
+
+ async expire(key: string, ttlMs: number): Promise {
+ this.ensureOpen();
+ return this.writer(this.router.shardFor(key), (db) => db.expire(key, ttlMs));
+ }
+
+ // ---- multi-key ops --------------------------------------------------------
+
+ async mget(keys: readonly string[]): Promise<(V | undefined)[]> {
+ this.ensureOpen();
+ const out = Array.from({ length: keys.length });
+ const groups = new Map();
+ keys.forEach((key, idx) => {
+ const id = this.router.shardFor(key);
+ const group = groups.get(id);
+ if (group) group.push({ key, idx });
+ else groups.set(id, [{ key, idx }]);
+ });
+ for (const [id, items] of groups) {
+ await this.reader(id, (db) => {
+ for (const { key, idx } of items) out[idx] = db.get(key);
+ });
+ }
+ return out;
+ }
+
+ /** Atomic per shard; best-effort across shards (see CrossShardMode). */
+ async mset(entries: readonly (readonly [string, V])[]): Promise {
+ this.ensureOpen();
+ await this.coordinator.mset(entries);
+ }
+
+ /** Returns the number of keys that existed and were deleted. */
+ async mdel(keys: readonly string[]): Promise {
+ this.ensureOpen();
+ return this.coordinator.mdel(keys);
+ }
+
+ /** Atomic per shard (single WAL batch frame); best-effort across shards. */
+ async batch(ops: readonly BatchInputOp[]): Promise {
+ this.ensureOpen();
+ await this.coordinator.batch(ops);
+ }
+
+ // ---- scans ----------------------------------------------------------------
+
+ /** Merged scan over all shards, sorted by key bytes. Hash sharding means
+ * every range scan fans out to all shards; entries are materialized,
+ * merged, then limited. */
+ async scan(opts: ScanOptions = {}): Promise[]> {
+ this.ensureOpen();
+ // A reverse scan must see the tail per shard, so per-shard limits only
+ // apply to forward scans; slicing happens after the global merge either way.
+ const perShardLimit = opts.reverse ? Infinity : (opts.limit ?? Infinity);
+ const range = { gte: opts.gte, gt: opts.gt, lte: opts.lte, lt: opts.lt, count: perShardLimit };
+ const usePrefix = opts.prefix !== undefined;
+ const all: ScanEntry[] = [];
+ for (const id of this.router.shardIds()) {
+ const entries = await this.reader(id, (db) =>
+ usePrefix ? db.prefix(opts.prefix!, perShardLimit) : db.scan(range),
+ );
+ for (const e of entries) all.push(e);
+ }
+ all.sort(compareEntries);
+ if (opts.reverse) all.reverse();
+ const limit = opts.limit ?? Infinity;
+ return limit === Infinity ? all : all.slice(0, limit);
+ }
+
+ async prefix(p: string, limit = Infinity): Promise[]> {
+ return this.scan({ prefix: p, limit });
+ }
+
+ // ---- secondary indexes ------------------------------------------------------
+
+ private static async loadRegistry(file: string): Promise {
+ try {
+ const raw = JSON.parse(await fs.readFile(file, 'utf8')) as Partial;
+ return { indexes: raw.indexes ?? [], textIndexes: raw.textIndexes ?? [] };
+ } catch (e) {
+ if ((e as NodeJS.ErrnoException).code === 'ENOENT') return { indexes: [], textIndexes: [] };
+ throw e;
+ }
+ }
+
+ private static async saveRegistry(file: string, reg: ClusterIndexRegistry): Promise {
+ const tmp = `${file}.tmp-${process.pid}`;
+ await fs.writeFile(tmp, JSON.stringify(reg, null, 2));
+ await fs.rename(tmp, file);
+ }
+
+ /** Compare two index definitions by their effective values (defaults
+ * applied), so a raced create of the same definition is a no-op while a
+ * genuinely different one keeps the "already exists" error. */
+ private static sameIndexDef(a: IndexDef, b: IndexDef): boolean {
+ return (
+ a.field === b.field &&
+ (a.type ?? 'equality') === (b.type ?? 'equality') &&
+ !!a.unique === !!b.unique &&
+ (a.sparse ?? true) === (b.sparse ?? true)
+ );
+ }
+
+ /** Per-process serialization of registry read-modify-publish cycles, keyed
+ * by registry file. Cross-process safety comes from the CAS loop in
+ * mutateRegistry; this only keeps ClusterDb instances in THIS process from
+ * interleaving their load/save/verify steps. */
+ private static readonly registryLocks = new Map>();
+
+ private static async withRegistryLock(file: string, fn: () => Promise): Promise {
+ const prev = ClusterDb.registryLocks.get(file) ?? Promise.resolve();
+ let release!: () => void;
+ const current = new Promise((resolve) => {
+ release = resolve;
+ });
+ const tail = prev.then(() => current);
+ ClusterDb.registryLocks.set(file, tail);
+ await prev;
+ try {
+ return await fn();
+ } finally {
+ release();
+ if (ClusterDb.registryLocks.get(file) === tail) ClusterDb.registryLocks.delete(file);
+ }
+ }
+
+ /** Publish one registry mutation as a compare-and-swap loop: every attempt
+ * re-loads the registry, re-applies the mutation idempotently
+ * (append-if-absent for creates, remove-if-present for drops; mutate
+ * returns false when the effect is already there, ending the loop),
+ * writes via tmp+rename, and accepts the write only when a post-save
+ * re-read still equals what was published. A concurrent rename from
+ * another process fails that check, so the attempt is retried with
+ * jittered backoff. */
+ private async mutateRegistry(mutate: (reg: ClusterIndexRegistry) => boolean): Promise {
+ for (let attempt = 0; ; attempt++) {
+ const done = await ClusterDb.withRegistryLock(this.indexPath, async () => {
+ const reg = await ClusterDb.loadRegistry(this.indexPath);
+ if (!mutate(reg)) return true; // The effect is already published.
+ const published = JSON.stringify(reg);
+ await ClusterDb.saveRegistry(this.indexPath, reg);
+ const reread = await ClusterDb.loadRegistry(this.indexPath);
+ return JSON.stringify(reread) === published;
+ });
+ if (done) return;
+ if (attempt >= 19) throw new Error('cluster index registry update keeps losing write races; retry the operation');
+ await sleep(10 + Math.floor(Math.random() * 41));
+ }
+ }
+
+ private requireJsonCodec(what: string): void {
+ if (this.topology.meta.valueCodec !== 'json') {
+ throw new Error(`${what} require valueCodec: "json"`);
+ }
+ }
+
+ /** Run fn against a writer of every shard in ascending id order. Index
+ * management needs this so definitions stay consistent cluster-wide; it
+ * waits (up to lockAcquireTimeoutMs per shard) for shards held by other
+ * processes and throws LockError when they cannot be acquired in time. */
+ private async forEachShardWriter(fn: (db: MiniDb, shardId: number) => void | Promise): Promise {
+ for (const id of this.router.shardIds()) {
+ await this.writer(id, (db) => fn(db, id));
+ }
+ }
+
+ /** Best-effort cleanup after a failed index fan-out: run fn on the shards
+ * the fan-out had completed on, swallowing errors (a shard that cannot be
+ * re-acquired is left as-is). */
+ private async rollbackShards(shardIds: number[], fn: (db: MiniDb) => void | Promise): Promise {
+ for (const id of shardIds) {
+ try {
+ await this.writer(id, fn);
+ } catch {
+ // Best effort only; the original fan-out error is what propagates.
+ }
+ }
+ }
+
+ /** Create a secondary index on every shard and record it in the cluster
+ * registry. Applies to existing data (per-shard backfill) and is picked up
+ * by future shard opens via the registry. */
+ async createIndex(name: string, def: IndexDef): Promise {
+ this.ensureOpen();
+ this.requireJsonCodec('secondary indexes');
+ const reg = await ClusterDb.loadRegistry(this.indexPath);
+ if (reg.indexes.some((i) => i.name === name)) throw new Error(`index "${name}" already exists`);
+ const createdOn: number[] = [];
+ try {
+ await this.forEachShardWriter(async (db, shardId) => {
+ if (!db.listIndexes().some((i) => i.name === name)) {
+ await db.createIndex(name, def);
+ createdOn.push(shardId);
+ }
+ });
+ } catch (e) {
+ // Roll back the partial fan-out: drop the index from exactly the shards
+ // this call created it on, so no shard keeps enforcing an index the
+ // registry never recorded.
+ await this.rollbackShards(createdOn, async (db) => {
+ await db.dropIndex(name);
+ });
+ throw e;
+ }
+ await this.mutateRegistry((current) => {
+ const existing = current.indexes.find((i) => i.name === name);
+ if (existing) {
+ // A raced create of the same definition already published.
+ if (ClusterDb.sameIndexDef(existing.def, def)) return false;
+ throw new Error(`index "${name}" already exists`);
+ }
+ current.indexes.push({ name, def });
+ return true;
+ });
+ }
+
+ async dropIndex(name: string): Promise {
+ this.ensureOpen();
+ const reg = await ClusterDb.loadRegistry(this.indexPath);
+ const existed = reg.indexes.some((i) => i.name === name);
+ await this.forEachShardWriter(async (db) => {
+ if (db.listIndexes().some((i) => i.name === name)) await db.dropIndex(name);
+ });
+ if (!existed) return false;
+ await this.mutateRegistry((current) => {
+ if (!current.indexes.some((i) => i.name === name)) return false;
+ current.indexes = current.indexes.filter((i) => i.name !== name);
+ return true;
+ });
+ return true;
+ }
+
+ /** Cluster-wide index definitions from the registry (source of truth). */
+ async listIndexes(): Promise {
+ this.ensureOpen();
+ const reg = await ClusterDb.loadRegistry(this.indexPath);
+ return reg.indexes.map(({ name, def }) => ({
+ name,
+ field: def.field,
+ type: def.type ?? 'equality',
+ unique: !!def.unique,
+ sparse: !!def.sparse,
+ }));
+ }
+
+ async findEq(name: string, value: unknown): Promise<{ key: string; value: V | undefined }[]> {
+ this.ensureOpen();
+ await this.requireIndex(name);
+ const out: { key: string; value: V | undefined }[] = [];
+ for (const id of this.router.shardIds()) {
+ const rows = await this.reader(id, (db) =>
+ db.listIndexes().some((i) => i.name === name) ? db.findEq(name, value) : [],
+ );
+ out.push(...rows);
+ }
+ out.sort((a, b) => compareEntries({ key: a.key, value: a.value }, { key: b.key, value: b.value }));
+ return out;
+ }
+
+ async findRange(
+ name: string,
+ opts: Parameters['findRange']>[1],
+ ): Promise<{ key: string; value: V | undefined; field: number }[]> {
+ this.ensureOpen();
+ await this.requireIndex(name);
+ // Only the numeric bounds go to the shards; offset/count/reverse must
+ // apply to the globally merged result, not per shard.
+ const bounds = { min: opts?.min, max: opts?.max, minExclusive: opts?.minExclusive, maxExclusive: opts?.maxExclusive };
+ const out: { key: string; value: V | undefined; field: number }[] = [];
+ for (const id of this.router.shardIds()) {
+ const rows = await this.reader(id, (db) =>
+ db.listIndexes().some((i) => i.name === name) ? db.findRange(name, bounds) : [],
+ );
+ out.push(...rows);
+ }
+ out.sort((a, b) => a.field - b.field || compareEntries({ key: a.key, value: a.value }, { key: b.key, value: b.value }));
+ if (opts?.reverse) out.reverse();
+ const offset = opts?.offset ?? 0;
+ const sliced = offset > 0 ? out.slice(offset) : out;
+ return opts?.count === undefined ? sliced : sliced.slice(0, opts.count);
+ }
+
+ private async requireIndex(name: string): Promise {
+ const reg = await ClusterDb.loadRegistry(this.indexPath);
+ if (!reg.indexes.some((i) => i.name === name)) throw new Error(`no such index: ${name}`);
+ }
+
+ // ---- full-text search -------------------------------------------------------
+
+ async createTextIndex(name: string, opts: { fields?: readonly string[] } = {}): Promise {
+ this.ensureOpen();
+ this.requireJsonCodec('text indexes');
+ const reg = await ClusterDb.loadRegistry(this.indexPath);
+ if (reg.textIndexes.some((t) => t.name === name)) throw new Error(`text index "${name}" already exists`);
+ const createdOn: number[] = [];
+ try {
+ await this.forEachShardWriter(async (db, shardId) => {
+ try {
+ await db.createTextIndex(name, opts);
+ createdOn.push(shardId);
+ } catch (e) {
+ if (!(e instanceof Error) || !e.message.includes('already exists')) throw e;
+ }
+ });
+ } catch (e) {
+ // Roll back the partial fan-out: drop the text index only from the
+ // shards this call created it on (see createIndex).
+ await this.rollbackShards(createdOn, async (db) => {
+ await db.dropTextIndex(name);
+ });
+ throw e;
+ }
+ const fields = opts.fields ?? null;
+ await this.mutateRegistry((current) => {
+ if (current.textIndexes.some((t) => t.name === name)) return false; // A raced create already published.
+ current.textIndexes.push({ name, fields });
+ return true;
+ });
+ }
+
+ async dropTextIndex(name: string): Promise {
+ this.ensureOpen();
+ const reg = await ClusterDb.loadRegistry(this.indexPath);
+ const existed = reg.textIndexes.some((t) => t.name === name);
+ await this.forEachShardWriter(async (db) => {
+ try {
+ await db.dropTextIndex(name);
+ } catch (e) {
+ if (!(e instanceof Error) || !e.message.includes('no such text index')) throw e;
+ }
+ });
+ if (!existed) return false;
+ await this.mutateRegistry((current) => {
+ if (!current.textIndexes.some((t) => t.name === name)) return false;
+ current.textIndexes = current.textIndexes.filter((t) => t.name !== name);
+ return true;
+ });
+ return existed;
+ }
+
+ /** Search every shard and merge by score (desc), key (asc). Scores are
+ * computed per shard (per-shard idf), so global ranking is approximate. */
+ async search(name: string, q: string, opts: { op?: 'AND' | 'OR'; limit?: number } = {}): Promise<{ key: string; value: V | undefined; score: number }[]> {
+ this.ensureOpen();
+ const reg = await ClusterDb.loadRegistry(this.indexPath);
+ if (!reg.textIndexes.some((t) => t.name === name)) throw new Error(`no such text index: ${name}`);
+ const out: { key: string; value: V | undefined; score: number }[] = [];
+ for (const id of this.router.shardIds()) {
+ const rows = await this.reader(id, (db) => {
+ try {
+ return db.search(name, q, opts);
+ } catch (e) {
+ if (e instanceof Error && e.message.includes('no such text index')) return [];
+ throw e;
+ }
+ });
+ out.push(...rows);
+ }
+ out.sort((a, b) => b.score - a.score || compareEntries({ key: a.key, value: a.value }, { key: b.key, value: b.value }));
+ return opts.limit === undefined ? out : out.slice(0, opts.limit);
+ }
+
+ // ---- maintenance ------------------------------------------------------------
+
+ /** Compact every shard this process can acquire. Shards whose write lock is
+ * held elsewhere (beyond lockAcquireTimeoutMs) are skipped, not errored. */
+ async compact(): Promise {
+ this.ensureOpen();
+ const compacted: number[] = [];
+ const skipped: number[] = [];
+ for (const id of this.router.shardIds()) {
+ try {
+ await this.writer(id, (db) => db.compact());
+ compacted.push(id);
+ } catch (e) {
+ if (e instanceof LockError) skipped.push(id);
+ else throw e;
+ }
+ }
+ return { compacted, skipped };
+ }
+
+ stats(): ClusterStats {
+ return {
+ shardCount: this.router.shardCount,
+ writersCached: this.pool.writersCached,
+ readersCached: this.pool.readersCached,
+ ...this.pool.stats,
+ };
+ }
+
+ async close(): Promise {
+ if (this.closed) return;
+ await this.pool.closeAll();
+ this.closed = true;
+ }
+}
diff --git a/packages/minidb/src/cluster/lock-pool.ts b/packages/minidb/src/cluster/lock-pool.ts
new file mode 100644
index 0000000000..236046362e
--- /dev/null
+++ b/packages/minidb/src/cluster/lock-pool.ts
@@ -0,0 +1,379 @@
+// src/cluster/lock-pool.ts
+//
+// Per-process cache of opened shards.
+//
+// Writers: MiniDb instances holding the shard's db.lock. While a process
+// holds a shard's write lock it is by definition the only writer of that
+// shard, so the cached writer's in-memory view is authoritative and current.
+// Cached writers are LRU-evicted (only when idle) down to a soft cap.
+//
+// Readers: read-only MiniDb instances used for keys whose shard this process
+// does not currently hold. A MiniDb reader replays snapshot+WAL only at open
+// time and would go stale afterwards, so every reader use is guarded by a
+// cheap file fingerprint (mtime+size of the shard's WAL, snapshot and index
+// definition files). A change refreshes the reader first:
+// - when only the WAL changed as pure appends on the same inode (tracked by
+// a {dev, ino, size} watermark), the appended frames are scanned and
+// applied incrementally (MiniDb.catchUpFromWal) — O(delta);
+// - anything else (rotation, truncation, snapshot/index-def changes, an
+// offset that turns out not to be a frame boundary) falls back to a close
+// + full reopen — O(shard size).
+// Because a writer's WAL append is complete before its set() resolves, a read
+// that starts after another process's write resolved always observes it.
+
+import fs from 'node:fs/promises';
+import path from 'node:path';
+import type { MiniDb } from '../index.js';
+import { LockError } from '../lockfile.js';
+import { ShardHandle } from './shard.js';
+import type { ShardOpenOptions } from './shard.js';
+import { sleep } from './utils.js';
+
+export interface LockPoolOptions {
+ writerOpts: ShardOpenOptions;
+ readerOpts: ShardOpenOptions;
+ lockRenewMs: number;
+ lockAcquireTimeoutMs: number;
+ lockHoldMs: number;
+ maxWriters: number;
+ maxReaders: number;
+ /** Cluster-wide read-only: withWriter rejects, withReader never uses cached writers. */
+ readOnly: boolean;
+ /** Apply cluster-wide index definitions to a freshly opened writer. */
+ applyDefs: (db: MiniDb) => Promise;
+}
+
+interface WriterEntry {
+ handle: ShardHandle;
+ lastUsedAt: number;
+ busy: number;
+ /** Set once the hold window expired while busy; closed at the next idle point. */
+ retire: boolean;
+ /** When the process should yield this shard's lock (Infinity: never by time). */
+ expiresAt: number;
+}
+
+interface ReaderEntry {
+ handle: ShardHandle;
+ fingerprint: string;
+ /** Per-file fingerprint parts, in FINGERPRINT_FILES order. */
+ fpParts: string[];
+ /** WAL watermark the instance's data represents: the {dev, ino} anchor of
+ * the WAL inode recovery (or the last catch-up) read, and the byte offset
+ * up to which frames were applied. null when the shard has no WAL yet. */
+ walMark: { dev: number; ino: number; size: number } | null;
+ lastUsedAt: number;
+ busy: number;
+}
+
+async function statFingerprint(file: string): Promise {
+ try {
+ const s = await fs.stat(file);
+ return `${s.mtimeMs}:${s.size}`;
+ } catch {
+ return '-';
+ }
+}
+
+/** Cheap change detector for a shard directory. WAL appends change size (and
+ * usually mtime); compaction swaps both snapshot and WAL; index definition
+ * changes rewrite their JSON files. */
+const FINGERPRINT_FILES = ['db.wal', 'db.snapshot', 'db.indexes.json', 'db.textindexes.json'] as const;
+
+async function shardFingerprint(dir: string): Promise {
+ return Promise.all(FINGERPRINT_FILES.map((f) => statFingerprint(path.join(dir, f))));
+}
+
+/** WAL watermark for a freshly opened reader: the exact inode recovery
+ * scanned and the offset up to which frames were replayed — never an offset
+ * beyond the replayed state, so frames appended during/after the open are
+ * picked up by a later incremental catch-up instead of being skipped. */
+function readerWalMark(handle: ShardHandle): ReaderEntry['walMark'] {
+ const ri = handle.db.recoveryInfo;
+ if (!ri || !ri.walIno) return null;
+ return { dev: ri.walDev, ino: ri.walIno, size: ri.walScanEnd };
+}
+
+export class ShardLockPool {
+ private readonly writers = new Map();
+ private readonly readers = new Map();
+ private readonly openingWriters = new Map>();
+ private readonly openingReaders = new Map>();
+ private closed = false;
+
+ readonly stats = {
+ writerOpens: 0,
+ readerOpens: 0,
+ readerReopens: 0,
+ incrementalCatchups: 0,
+ catchupFramesApplied: 0,
+ lockWaits: 0,
+ evictions: 0,
+ };
+
+ constructor(private readonly opts: LockPoolOptions) {}
+
+ get writersCached(): number {
+ return this.writers.size;
+ }
+ get readersCached(): number {
+ return this.readers.size;
+ }
+
+ /** Run fn against the shard's writer, opening it (with lock retry) if this
+ * process does not hold it yet. The writer cannot be evicted while busy. */
+ async withWriter(shardId: number, dir: string, fn: (db: MiniDb) => T | Promise): Promise {
+ if (this.opts.readOnly) throw new Error('ClusterDb is open in read-only mode');
+ if (this.closed) throw new Error('ClusterDb is closed');
+ const entry = await this.acquireWriter(shardId, dir);
+ entry.busy++;
+ try {
+ return await fn(entry.handle.db);
+ } finally {
+ entry.busy--;
+ entry.lastUsedAt = Date.now();
+ if (entry.retire && entry.busy === 0) {
+ // The hold window expired while ops were in flight: yield the lock so
+ // other processes can take the shard over.
+ if (this.writers.get(shardId) === entry) this.writers.delete(shardId);
+ await entry.handle.close().catch(() => {});
+ }
+ await this.evictWriters();
+ }
+ }
+
+ /** Run fn against the best available read view of the shard: the cached
+ * writer when this process holds the shard (current and lock-free), else a
+ * fingerprint-revalidated read-only instance. */
+ async withReader(shardId: number, dir: string, fn: (db: MiniDb) => T | Promise): Promise {
+ if (this.closed) throw new Error('ClusterDb is closed');
+ if (!this.opts.readOnly) {
+ const w = this.writers.get(shardId);
+ if (w) {
+ w.busy++;
+ try {
+ return await fn(w.handle.db);
+ } finally {
+ w.busy--;
+ w.lastUsedAt = Date.now();
+ }
+ }
+ }
+ const entry = await this.acquireReader(shardId, dir);
+ entry.busy++;
+ try {
+ return await fn(entry.handle.db);
+ } finally {
+ entry.busy--;
+ entry.lastUsedAt = Date.now();
+ await this.evictReaders();
+ }
+ }
+
+ async closeAll(): Promise {
+ if (this.closed) return;
+ this.closed = true;
+ const writers = [...this.writers.values()];
+ const readers = [...this.readers.values()];
+ this.writers.clear();
+ this.readers.clear();
+ const results = await Promise.allSettled([...writers, ...readers].map((e) => e.handle.close()));
+ const failed = results.find((r): r is PromiseRejectedResult => r.status === 'rejected');
+ if (failed) throw failed.reason;
+ }
+
+ // ---- writers ------------------------------------------------------------
+
+ private async acquireWriter(shardId: number, dir: string): Promise {
+ const cached = this.writers.get(shardId);
+ if (cached) {
+ if (cached.expiresAt > Date.now()) {
+ cached.lastUsedAt = Date.now();
+ return cached;
+ }
+ // Hold window expired: yield. If ops are in flight, serve this one too
+ // (it is short) and close at the next idle point.
+ if (cached.busy > 0) {
+ cached.retire = true;
+ return cached;
+ }
+ if (this.writers.get(shardId) === cached) this.writers.delete(shardId);
+ await cached.handle.close().catch(() => {});
+ }
+ const inflight = this.openingWriters.get(shardId);
+ if (inflight) return inflight;
+ const opening = this.openWriter(shardId, dir).finally(() => this.openingWriters.delete(shardId));
+ this.openingWriters.set(shardId, opening);
+ return opening;
+ }
+
+ private async openWriter(shardId: number, dir: string): Promise {
+ const deadline = Date.now() + this.opts.lockAcquireTimeoutMs;
+ let delay = 10;
+ for (;;) {
+ try {
+ const handle = await ShardHandle.openWriter(shardId, dir, this.opts.writerOpts, this.opts.lockRenewMs);
+ this.stats.writerOpens++;
+ try {
+ await this.opts.applyDefs(handle.db);
+ } catch (e) {
+ await handle.close().catch(() => {});
+ throw e;
+ }
+ const entry: WriterEntry = {
+ handle,
+ lastUsedAt: Date.now(),
+ busy: 0,
+ retire: false,
+ expiresAt: this.opts.lockHoldMs > 0 ? Date.now() + this.opts.lockHoldMs : Infinity,
+ };
+ if (this.opts.lockHoldMs > 0) {
+ // Proactively yield the lock at the end of the hold window even if
+ // this process goes idle, so a waiting process is never starved by
+ // a holder that stopped writing but kept the process alive.
+ const timer = setTimeout(() => {
+ if (entry.busy > 0) {
+ entry.retire = true;
+ return;
+ }
+ if (this.writers.get(shardId) === entry) this.writers.delete(shardId);
+ void entry.handle.close().catch(() => {});
+ }, this.opts.lockHoldMs);
+ timer.unref();
+ }
+ this.writers.set(shardId, entry);
+ return entry;
+ } catch (e) {
+ // Apply-time failures (e.g. a unique index that does not backfill) are
+ // permanent; only lock contention is retried, until the deadline.
+ if (!(e instanceof LockError) || Date.now() + delay > deadline) throw e;
+ this.stats.lockWaits++;
+ await sleep(delay + Math.floor(Math.random() * delay));
+ delay = Math.min(delay * 2, 250);
+ }
+ }
+ }
+
+ private async evictWriters(): Promise {
+ while (this.writers.size > this.opts.maxWriters) {
+ let victim: WriterEntry | null = null;
+ for (const entry of this.writers.values()) {
+ if (entry.busy > 0) continue;
+ if (!victim || entry.lastUsedAt < victim.lastUsedAt) victim = entry;
+ }
+ if (!victim) return; // everything busy: soft cap exceeded temporarily
+ this.writers.delete(victim.handle.shardId);
+ this.stats.evictions++;
+ await victim.handle.close().catch(() => {});
+ }
+ }
+
+ // ---- readers ------------------------------------------------------------
+
+ private async acquireReader(shardId: number, dir: string): Promise {
+ const inflight = this.openingReaders.get(shardId);
+ if (inflight) return inflight;
+ const opening = this.refreshReader(shardId, dir).finally(() => this.openingReaders.delete(shardId));
+ this.openingReaders.set(shardId, opening);
+ return opening;
+ }
+
+ private async refreshReader(shardId: number, dir: string): Promise {
+ const cached = this.readers.get(shardId);
+ const parts = await shardFingerprint(dir);
+ const fp = parts.join('|');
+ if (cached && cached.fingerprint === fp) {
+ cached.lastUsedAt = Date.now();
+ return cached;
+ }
+ if (cached) {
+ // Something in the shard changed. When the change is confined to WAL
+ // appends on the same inode, apply just those frames instead of paying
+ // for a full replay (fallback: a clean full reopen below).
+ if (parts[1] === cached.fpParts[1] && parts[2] === cached.fpParts[2] && parts[3] === cached.fpParts[3]) {
+ if (await this.tryCatchUpReader(cached, dir, parts)) return cached;
+ }
+ // A change the watermark cannot advance over (rotation, truncation,
+ // snapshot/index-def rewrite, broken boundary): reopen to see it. Wait
+ // for any in-flight user of the stale instance to drain first.
+ while (cached.busy > 0) await sleep(5);
+ this.readers.delete(shardId);
+ this.stats.readerReopens++;
+ await cached.handle.close().catch(() => {});
+ }
+ // Retry a few times: a compaction by another process briefly swaps files,
+ // which can make an otherwise-fine open race with the rotation.
+ let lastErr: unknown;
+ for (let attempt = 0; attempt < 3; attempt++) {
+ try {
+ const handle = await ShardHandle.openReader(shardId, dir, this.opts.readerOpts);
+ this.stats.readerOpens++;
+ const openedParts = await shardFingerprint(dir);
+ const entry: ReaderEntry = {
+ handle,
+ fingerprint: openedParts.join('|'),
+ fpParts: openedParts,
+ walMark: readerWalMark(handle),
+ lastUsedAt: Date.now(),
+ busy: 0,
+ };
+ this.readers.set(shardId, entry);
+ return entry;
+ } catch (e) {
+ lastErr = e;
+ await sleep(25);
+ }
+ }
+ throw lastErr;
+ }
+
+ /** Incremental reader refresh: apply the WAL frames appended after the
+ * cached reader's watermark. Only safe when the fingerprint change is
+ * WAL-only (caller checked), the WAL is still the inode the watermark is
+ * anchored to, and it never shrank below the applied offset. Returns false
+ * on every divergence, leaving the cached reader untouched for the caller
+ * to fully reopen. */
+ private async tryCatchUpReader(cached: ReaderEntry, dir: string, parts: string[]): Promise {
+ const mark = cached.walMark;
+ if (!mark) return false;
+ const st = await fs.stat(path.join(dir, 'db.wal')).catch(() => null);
+ if (!st || st.dev !== mark.dev || st.ino !== mark.ino || st.size < mark.size) return false;
+ // Serialize against in-flight users of the cached instance (the reopen
+ // path does the same drain) and hold it busy so eviction skips it.
+ while (cached.busy > 0) await sleep(5);
+ cached.busy++;
+ let res: { offset: number; appliedFrames: number } | null = null;
+ try {
+ res = await cached.handle.db.catchUpFromWal(mark.size);
+ } catch {
+ res = null; // raced a rotation/truncation: caller falls back to a full reopen
+ } finally {
+ cached.busy--;
+ }
+ if (res === null) return false;
+ // Advance the watermark only to what was actually applied — a frame
+ // appended after this stat (or after catch-up scanned) sits beyond
+ // res.offset and is picked up by the next fingerprint miss.
+ cached.walMark = { dev: st.dev, ino: st.ino, size: res.offset };
+ cached.fpParts = parts;
+ cached.fingerprint = parts.join('|');
+ cached.lastUsedAt = Date.now();
+ this.stats.incrementalCatchups++;
+ this.stats.catchupFramesApplied += res.appliedFrames;
+ return true;
+ }
+
+ private async evictReaders(): Promise {
+ while (this.readers.size > this.opts.maxReaders) {
+ let victim: ReaderEntry | null = null;
+ for (const entry of this.readers.values()) {
+ if (entry.busy > 0) continue;
+ if (!victim || entry.lastUsedAt < victim.lastUsedAt) victim = entry;
+ }
+ if (!victim) return;
+ this.readers.delete(victim.handle.shardId);
+ this.stats.evictions++;
+ await victim.handle.close().catch(() => {});
+ }
+ }
+}
diff --git a/packages/minidb/src/cluster/router.ts b/packages/minidb/src/cluster/router.ts
new file mode 100644
index 0000000000..7d8a4b879f
--- /dev/null
+++ b/packages/minidb/src/cluster/router.ts
@@ -0,0 +1,31 @@
+// src/cluster/router.ts
+//
+// Key -> shard routing. Pure functions of (key, shardCount) so every process
+// agrees on placement without any coordination.
+
+import path from 'node:path';
+import type { ClusterMeta } from './types.js';
+import { shardDirName, shardFor } from './utils.js';
+
+export class Router {
+ constructor(
+ private readonly baseDir: string,
+ private readonly meta: ClusterMeta,
+ ) {}
+
+ get shardCount(): number {
+ return this.meta.shardCount;
+ }
+
+ shardFor(key: string): number {
+ return shardFor(key, this.meta.shardCount);
+ }
+
+ shardDir(shardId: number): string {
+ return path.join(this.baseDir, shardDirName(shardId, this.meta.shardCount));
+ }
+
+ shardIds(): number[] {
+ return Array.from({ length: this.meta.shardCount }, (_, i) => i);
+ }
+}
diff --git a/packages/minidb/src/cluster/shard.ts b/packages/minidb/src/cluster/shard.ts
new file mode 100644
index 0000000000..bbd5ea7f14
--- /dev/null
+++ b/packages/minidb/src/cluster/shard.ts
@@ -0,0 +1,65 @@
+// src/cluster/shard.ts
+//
+// A single shard: one MiniDb instance in either writer mode (holding the
+// shard's db.lock, with a lease timer refreshing the lock timestamp) or
+// reader mode (read-only, no lock, coexisting with another process's writer).
+
+import { MiniDb } from '../index.js';
+import type { OpenOptions } from '../index.js';
+
+/** MiniDb options for a shard open; everything except the identity fields. */
+export type ShardOpenOptions = Omit;
+
+export class ShardHandle {
+ private leaseTimer: NodeJS.Timeout | null = null;
+
+ private constructor(
+ readonly shardId: number,
+ readonly dir: string,
+ readonly db: MiniDb,
+ readonly writer: boolean,
+ ) {}
+
+ /** Open the shard for writing: acquires db.lock via MiniDb.open and starts
+ * refreshing the lock timestamp every renewMs (0 disables renewal). Throws
+ * LockError if the lock is held by a live process. */
+ static async openWriter(
+ shardId: number,
+ dir: string,
+ opts: ShardOpenOptions,
+ renewMs: number,
+ ): Promise {
+ const db = await MiniDb.open({ ...opts, dir });
+ const handle = new ShardHandle(shardId, dir, db as MiniDb, true);
+ if (renewMs > 0) {
+ handle.leaseTimer = setInterval(() => {
+ void db.renewLock().catch(() => {});
+ }, renewMs);
+ // Never keep a worker process alive just for lease renewal.
+ handle.leaseTimer.unref();
+ }
+ return handle;
+ }
+
+ /** Open the shard read-only. Does not touch db.lock, never fsyncs, and
+ * never auto-compacts (a read-only open must not rewrite a live writer's
+ * directory). */
+ static async openReader(shardId: number, dir: string, opts: ShardOpenOptions): Promise {
+ const db = await MiniDb.open({
+ ...opts,
+ dir,
+ readOnly: true,
+ autoCompact: false,
+ fsyncPolicy: 'no',
+ });
+ return new ShardHandle(shardId, dir, db as MiniDb, false);
+ }
+
+ async close(): Promise {
+ if (this.leaseTimer) {
+ clearInterval(this.leaseTimer);
+ this.leaseTimer = null;
+ }
+ await this.db.close();
+ }
+}
diff --git a/packages/minidb/src/cluster/topology.ts b/packages/minidb/src/cluster/topology.ts
new file mode 100644
index 0000000000..675e75b1d5
--- /dev/null
+++ b/packages/minidb/src/cluster/topology.ts
@@ -0,0 +1,94 @@
+// src/cluster/topology.ts
+//
+// Cluster topology: the cluster.meta.json file that pins shardCount (and the
+// codec/fsync defaults) for every process that opens the database. Creation
+// races are resolved with O_EXCL: exactly one process writes the meta file,
+// everyone else loads and validates against it.
+
+import fs from 'node:fs/promises';
+import path from 'node:path';
+import { CLUSTER_META_FILE, shardDirName } from './utils.js';
+import type { ClusterMeta, ClusterOpenOptions } from './types.js';
+
+const META_VERSION = 1;
+const DEFAULT_SHARD_COUNT = 16;
+
+type TopologyOpts = Pick;
+
+export class Topology {
+ private constructor(
+ readonly dir: string,
+ readonly meta: ClusterMeta,
+ ) {}
+
+ /** Create the cluster if dir has no meta file yet, otherwise load and
+ * validate the existing topology. Caller-specified values must match what
+ * is on disk; unspecified values inherit the disk topology. */
+ static async open(dir: string, opts: TopologyOpts): Promise {
+ if (!dir) throw new TypeError('Topology.open: dir is required');
+ if (opts.shardCount !== undefined && (!Number.isInteger(opts.shardCount) || opts.shardCount < 1)) {
+ throw new RangeError(`shardCount must be a positive integer, got ${opts.shardCount}`);
+ }
+ await fs.mkdir(dir, { recursive: true });
+ const metaPath = path.join(dir, CLUSTER_META_FILE);
+
+ const requested: ClusterMeta = {
+ version: META_VERSION,
+ shardCount: opts.shardCount ?? DEFAULT_SHARD_COUNT,
+ createdAt: new Date().toISOString(),
+ valueCodec: opts.valueCodec ?? 'buffer',
+ fsyncPolicy: opts.fsyncPolicy ?? 'everysec',
+ };
+ // Atomic creation race: write to a temp file, then hard-link it into
+ // place. link(2) fails with EEXIST if another process won the race, and
+ // unlike the O_EXCL-create-then-write pattern, readers can never observe
+ // a meta file whose content has not been fully written yet.
+ const tmpPath = `${metaPath}.tmp-${process.pid}`;
+ try {
+ await fs.writeFile(tmpPath, JSON.stringify(requested, null, 2));
+ await fs.link(tmpPath, metaPath);
+ return new Topology(dir, requested);
+ } catch (e) {
+ if ((e as NodeJS.ErrnoException).code !== 'EEXIST') throw e;
+ } finally {
+ await fs.unlink(tmpPath).catch(() => {});
+ }
+
+ const loaded = JSON.parse(await fs.readFile(metaPath, 'utf8')) as ClusterMeta;
+ if (loaded.version !== META_VERSION) {
+ throw new Error(`unsupported cluster meta version ${loaded.version} in ${metaPath}`);
+ }
+ if (!Number.isInteger(loaded.shardCount) || loaded.shardCount < 1) {
+ throw new Error(`invalid shardCount in ${metaPath}`);
+ }
+ if (opts.shardCount !== undefined && opts.shardCount !== loaded.shardCount) {
+ throw new RangeError(`cluster was created with shardCount=${loaded.shardCount}, got ${opts.shardCount}`);
+ }
+ if (opts.valueCodec !== undefined && opts.valueCodec !== loaded.valueCodec) {
+ throw new RangeError(`cluster was created with valueCodec=${loaded.valueCodec}, got ${opts.valueCodec}`);
+ }
+ if (opts.fsyncPolicy !== undefined && opts.fsyncPolicy !== loaded.fsyncPolicy) {
+ throw new RangeError(`cluster was created with fsyncPolicy=${loaded.fsyncPolicy}, got ${opts.fsyncPolicy}`);
+ }
+ return new Topology(dir, loaded);
+ }
+
+ get shardCount(): number {
+ return this.meta.shardCount;
+ }
+
+ shardDir(shardId: number): string {
+ return path.join(this.dir, shardDirName(shardId, this.meta.shardCount));
+ }
+
+ allShardDirs(): string[] {
+ return Array.from({ length: this.meta.shardCount }, (_, i) => this.shardDir(i));
+ }
+
+ /** Create any missing shard directories. Runs on every open so a cluster
+ * interrupted between meta creation and directory creation heals on the
+ * next open. */
+ async ensureShardDirs(): Promise {
+ await Promise.all(this.allShardDirs().map((d) => fs.mkdir(d, { recursive: true })));
+ }
+}
diff --git a/packages/minidb/src/cluster/types.ts b/packages/minidb/src/cluster/types.ts
new file mode 100644
index 0000000000..9897be641c
--- /dev/null
+++ b/packages/minidb/src/cluster/types.ts
@@ -0,0 +1,122 @@
+// src/cluster/types.ts
+//
+// Public option and result types for ClusterDb, the sharded multi-process
+// layer on top of MiniDb. See plan/minidb-cluster-plan.md for the design.
+
+import type { ValueCodecName } from '../index.js';
+import type { IndexDef } from '../index-manager.js';
+import type { FsyncPolicy } from '../wal.js';
+
+/** Cross-shard write semantics.
+ * - 'best-effort': shard groups are written one by one in shard-id order;
+ * a failure may leave earlier shards written and later ones untouched.
+ * - 'none': an operation spanning more than one shard throws.
+ * - '2pc': reserved for a future two-phase-commit implementation; rejected
+ * at open time for now. */
+export type CrossShardMode = 'best-effort' | '2pc' | 'none';
+
+export interface ClusterOpenOptions {
+ dir: string;
+ /** Shard count; only used when creating a new cluster (default 16). An
+ * existing cluster's count is read from cluster.meta.json and an explicit
+ * mismatching value is rejected. */
+ shardCount?: number;
+ valueCodec?: ValueCodecName;
+ fsyncPolicy?: FsyncPolicy;
+ valueMode?: 'memory' | 'disk' | 'auto';
+ compactThresholdBytes?: number;
+ autoCompact?: boolean;
+ activeExpireIntervalMs?: number;
+ recovery?: 'resync' | 'strict';
+ maxMemoryBytes?: number;
+ maxMemoryPolicy?: 'reject' | 'evict-lru';
+
+ /** Open the whole cluster without ever acquiring a write lock. Writes
+ * throw; reads always go through revalidated read-only shard instances. */
+ readOnly?: boolean;
+
+ /** How long a lock timestamp may go unrefreshed before the lock would be
+ * considered abandoned (default 30000). Reserved: the underlying LockFile
+ * currently takes a lock over only when the recorded owner PID is dead,
+ * never merely because it is old; lockRenewMs keeps the timestamp fresh
+ * for observability and for a future lease-based check. */
+ lockLeaseMs?: number;
+ /** How often a cached shard writer refreshes its lock timestamp
+ * (default 10000). Set to 0 to disable renewal. */
+ lockRenewMs?: number;
+ /** Maximum number of shard write locks cached in this process; least
+ * recently used, non-busy shards are evicted beyond the cap (default 16). */
+ lockPoolMaxShards?: number;
+ /** How long a shard writer may stay cached before this process yields the
+ * lock so other processes get a chance to write that shard (default 250ms;
+ * 0 holds the lock until pool eviction or close). Yielding costs a shard
+ * reopen (WAL replay) but prevents a continuously-writing process from
+ * starving everyone else on a hot shard. */
+ lockHoldMs?: number;
+ /** Maximum number of read-only shard instances cached in this process
+ * (default: shardCount). */
+ readersMaxShards?: number;
+ /** Maximum time to wait for a contended shard write lock before throwing
+ * LockError (default 30000). */
+ lockAcquireTimeoutMs?: number;
+ /** Cross-shard write semantics (default 'best-effort'). */
+ crossShard?: CrossShardMode;
+}
+
+export interface ClusterMeta {
+ version: number;
+ shardCount: number;
+ createdAt: string;
+ valueCodec: ValueCodecName;
+ fsyncPolicy: FsyncPolicy;
+}
+
+export interface ScanOptions {
+ gte?: string;
+ gt?: string;
+ lte?: string;
+ lt?: string;
+ /** When set, range bounds are ignored and a pure prefix scan runs. */
+ prefix?: string;
+ limit?: number;
+ /** Return entries in descending key order (after global merge). */
+ reverse?: boolean;
+}
+
+/** Registry of cluster-wide index definitions (cluster.indexes.json). The
+ * registry is the source of truth; every shard writer applies missing
+ * definitions right after it is opened, so a shard that was never opened
+ * when an index was created catches up later. */
+export interface ClusterIndexRegistry {
+ indexes: { name: string; def: IndexDef }[];
+ textIndexes: { name: string; fields: readonly string[] | null }[];
+}
+
+export interface CompactResult {
+ /** Shard ids that were compacted. */
+ compacted: number[];
+ /** Shard ids whose write lock could not be acquired (held elsewhere). */
+ skipped: number[];
+}
+
+export interface ClusterStats {
+ shardCount: number;
+ writersCached: number;
+ readersCached: number;
+ /** Total shard writer opens performed by this process. */
+ writerOpens: number;
+ /** Total shard reader opens performed by this process. */
+ readerOpens: number;
+ /** Reader cache reopens triggered by on-disk changes from other processes
+ * (the full-replay path; contrast incrementalCatchups). */
+ readerReopens: number;
+ /** Reader refreshes satisfied by applying only the appended WAL frames
+ * instead of a full reopen. */
+ incrementalCatchups: number;
+ /** Total WAL frames applied by incremental reader catch-ups. */
+ catchupFramesApplied: number;
+ /** Times a write-lock acquisition had to wait/retry on a live holder. */
+ lockWaits: number;
+ /** LRU evictions of cached writers/readers. */
+ evictions: number;
+}
diff --git a/packages/minidb/src/cluster/utils.ts b/packages/minidb/src/cluster/utils.ts
new file mode 100644
index 0000000000..e220b835ac
--- /dev/null
+++ b/packages/minidb/src/cluster/utils.ts
@@ -0,0 +1,70 @@
+// src/cluster/utils.ts
+//
+// Hashing, shard directory naming, and shared small helpers.
+
+export const CLUSTER_META_FILE = 'cluster.meta.json';
+export const CLUSTER_INDEX_FILE = 'cluster.indexes.json';
+const SHARD_DIR_PREFIX = 'shard-';
+
+/** Zero-padded shard directory name, e.g. shard-03 for shardCount <= 100. The
+ * width grows with the shard count so directory listings stay sorted. */
+export function shardDirName(shardId: number, shardCount: number): string {
+ const width = Math.max(2, String(Math.max(shardCount - 1, 0)).length);
+ return `${SHARD_DIR_PREFIX}${String(shardId).padStart(width, '0')}`;
+}
+
+/** MurmurHash3 x86 32-bit: stable, fast, and with a proper avalanche so the
+ * low bits (which is all `hash % shardCount` uses) stay uniform even for
+ * sequential or highly-similar keys. Zero dependencies. */
+export function stableHash32(key: string, seed = 0): number {
+ const data = Buffer.from(key, 'utf8');
+ const len = data.length;
+ let h1 = seed >>> 0;
+ const c1 = 0xcc9e2d51;
+ const c2 = 0x1b873593;
+
+ const roundedEnd = len & ~3;
+ for (let i = 0; i < roundedEnd; i += 4) {
+ let k1 = data.readUInt32LE(i);
+ k1 = Math.imul(k1, c1);
+ k1 = (k1 << 15) | (k1 >>> 17);
+ k1 = Math.imul(k1, c2);
+ h1 ^= k1;
+ h1 = (h1 << 13) | (h1 >>> 19);
+ h1 = (Math.imul(h1, 5) + 0xe6546b64) >>> 0;
+ }
+
+ let k1 = 0;
+ const tail = len & 3;
+ if (tail === 3) k1 ^= data[roundedEnd + 2]! << 16;
+ if (tail >= 2) k1 ^= data[roundedEnd + 1]! << 8;
+ if (tail >= 1) {
+ k1 ^= data[roundedEnd]!;
+ k1 = Math.imul(k1, c1);
+ k1 = (k1 << 15) | (k1 >>> 17);
+ k1 = Math.imul(k1, c2);
+ h1 ^= k1;
+ }
+
+ h1 ^= len;
+ // fmix32 finalizer.
+ h1 ^= h1 >>> 16;
+ h1 = Math.imul(h1, 0x85ebca6b);
+ h1 ^= h1 >>> 13;
+ h1 = Math.imul(h1, 0xc2b2ae35);
+ h1 ^= h1 >>> 16;
+ return h1 >>> 0;
+}
+
+/** Route a key to its shard. Pure function of (key, shardCount), so every
+ * process agrees on placement without coordination. */
+export function shardFor(key: string, shardCount: number): number {
+ if (!(shardCount >= 1) || !Number.isInteger(shardCount)) {
+ throw new RangeError(`shardCount must be a positive integer, got ${shardCount}`);
+ }
+ return stableHash32(key) % shardCount;
+}
+
+export function sleep(ms: number): Promise {
+ return new Promise((resolve) => setTimeout(resolve, ms));
+}
diff --git a/packages/minidb/src/codec.ts b/packages/minidb/src/codec.ts
index 77488af4aa..623205869e 100644
--- a/packages/minidb/src/codec.ts
+++ b/packages/minidb/src/codec.ts
@@ -393,15 +393,17 @@ function findMagicSync(fd: number, start: number, size: number): number {
return -1;
}
-/** Scan an open snapshot/WAL fd into frame refs without copying values. */
+/** Scan an open snapshot/WAL fd into frame refs without copying values.
+ * `startOffset` restricts the scan to [startOffset, EOF) — used by replica
+ * catch-up, which resumes at a known frame boundary. */
export function scanFrameRefsFd(
fd: number,
- { onCorrupt = 'resync' }: { onCorrupt?: 'resync' | 'strict' } = {},
+ { onCorrupt = 'resync', startOffset = 0 }: { onCorrupt?: 'resync' | 'strict'; startOffset?: number } = {},
): ScanFrameRefsResult {
const size = fs.fstatSync(fd).size;
const frames: FrameRef[] = [];
const corruptRanges: [number, number][] = [];
- let pos = 0;
+ let pos = startOffset;
while (pos < size) {
const r = readFrameRefAt(fd, pos, size);
diff --git a/packages/minidb/src/compaction.ts b/packages/minidb/src/compaction.ts
index 88db531b86..3da409fb16 100644
--- a/packages/minidb/src/compaction.ts
+++ b/packages/minidb/src/compaction.ts
@@ -5,8 +5,8 @@
// This is a NON-BLOCKING variant, modelled on Redis's BGREWRITEAOF and
// Bitcask's merge: while the (potentially large) snapshot is being written,
// writers keep appending to the live WAL — the WAL itself acts as the
-// "rewrite buffer". Writes are blocked only for a short *rotation* critical
-// section at the very end (a flush + a tiny tail copy + two renames).
+// "rewrite buffer". Writes are blocked only for the *rotation* critical
+// section at the very end (a flush + a bounded tail copy + two renames).
//
// Phases:
// 1. fence — flush the WAL, record baseOffset = wal.size. Every write
@@ -19,13 +19,20 @@
// tail copied below is replayed last-writer-wins on top of
// it, repairing any fuzziness.
// 2.5 pre-copy — stream WAL[baseOffset .. head] into db.wal.tmp, draining
-// the bulk of the post-fence tail. NON-BLOCKING. Loops
-// until the remaining delta is small, so the critical
-// section stays bounded.
-// 3. rotation — SHORT BLOCKING critical section: set _rotateLock so new
-// writers park, flush, copy the tiny remaining tail delta,
-// close the old WAL, rename snapshot then WAL (crash-safe
-// order), reopen the new WAL.
+// the bulk of the post-fence tail. NON-BLOCKING. Loops while
+// the copy is CONVERGING (the remaining delta shrinks fast
+// enough) and gives up after a few passes otherwise —
+// chasing a tail under writes that append as fast as the
+// copy drains would otherwise never terminate, stalling
+// compaction for as long as the write storm lasts.
+// 3. rotation — BLOCKING critical section: set _rotateLock so new writers
+// park, seal the old WAL (post-seal appends fail fast and
+// are retried by the op against the new WAL), flush, then
+// copy the remaining tail. With the WAL sealed and writers
+// parked the head no longer moves, so this copy provably
+// finishes; the pause scales with the tail the pre-copy did
+// not drain — the same bounded end-of-rewrite pause Redis
+// accepts for its AOF diff flush.
// 4. bookkeeping — stats + onCompacted() (rebuild derived text postings).
//
// Crash safety: recovery is `load db.snapshot` + `replay db.wal`, last-writer
@@ -40,6 +47,7 @@ import fs from 'node:fs/promises';
import type { FileHandle } from 'node:fs/promises';
import path from 'node:path';
import { WAL } from './wal.js';
+import { renameReplace } from './rename-replace.js';
import { writeSnapshot } from './snapshot.js';
import type { Store, ValueLoc } from './store.js';
import type { FsyncPolicy } from './wal.js';
@@ -58,10 +66,11 @@ export interface CompactionTarget {
* Null outside rotation, so the snapshot phase is fully non-blocking. */
_rotateLock: Promise | null;
lastCompactError: unknown;
- stats: { compactions: number; walBytesWritten: number; walFsyncs: number; snapshotBytesWritten: number };
+ stats: { compactions: number; walBytesWritten: number; walFsyncs: number; snapshotBytesWritten: number; compactErrors?: number };
/** Reader for disk-backed values; reopened after snapshot/WAL rotation so
- * remapped value pointers read from the new files. */
- valueReader?: { reopenBoth(): void };
+ * remapped value pointers read from the new files. On Windows it is also
+ * closed before the rotation renames (see rotateReplace). */
+ valueReader?: { reopenBoth(): void; close?(): void };
/** Optional hook invoked after the snapshot + WAL rotation succeeds, so the
* owner can rewrite derived on-disk state (e.g. text postings) against the
* new live set. */
@@ -77,6 +86,17 @@ const COPY_CHUNK = 1 << 20; // 1 MiB read/write coalescing
// the rotation critical section, so the pre-copy loop stops draining.
const SMALL_DELTA = 64 * 1024; // 64 KiB
+// Windows cannot rename over an open destination; rotation uses the shared
+// retrying replace helper (see rename-replace.ts).
+const rotateReplace = (src: string, dst: string): Promise => renameReplace(src, dst);
+// Pre-copy convergence bounds: each pass costs roughly `gap / copyRate` and
+// appends `gap * (appendRate / copyRate)` new bytes during the copy. Give up
+// when a pass fails to shrink the gap meaningfully (appendRate ≳ copyRate),
+// or after this many passes regardless — the rotation critical section (with
+// the WAL sealed and writers parked) then absorbs the remaining tail.
+const MAX_PRECOPY_PASSES = 5;
+const CONVERGE_RATIO = 0.7;
+
export async function fsyncDir(dir: string): Promise {
let fh: FileHandle | null = null;
try {
@@ -139,10 +159,13 @@ export async function compact(db: CompactionTarget): Promise {
db._compactDone = (async () => {
try {
await runCompaction(db);
+ // The onCompacted hook is part of the compaction: a run whose hook
+ // throws is counted as a compactError, not a successful compaction.
+ db.onCompacted?.();
db.stats.compactions++;
db.lastCompactError = null;
- db.onCompacted?.();
} catch (err) {
+ db.stats.compactErrors = (db.stats.compactErrors ?? 0) + 1;
db.lastCompactError = err;
throw err;
} finally {
@@ -171,63 +194,127 @@ async function runCompaction(db: CompactionTarget): Promise {
db.stats.snapshotBytesWritten += snapRes.bytes;
// Phase 2.5: pre-copy the post-fence WAL tail into db.wal.tmp. NON-BLOCKING.
- // Drain until the remaining delta is small enough to copy inside the critical
- // section. Each iteration flushes to get a stable `head`, then copies the
- // bytes that landed since the previous iteration. The loop converges because
- // sequential copy is far faster than fsync-bound writes; if writes ever
- // outrun the copy, the critical section simply absorbs a larger delta (still
- // correct, just a longer pause — no worse than the old stop-the-world).
+ // Each pass flushes to get a stable `head`, then copies the bytes that landed
+ // since the previous pass. The loop only continues while it is CONVERGING:
+ // under sustained writes whose append rate approaches the copy rate the gap
+ // stops shrinking, and looping until it was small enough would never
+ // terminate (stalling compaction for the whole write storm — observed in the
+ // field as compactions=0 forever while the WAL grew unboundedly). Give up to
+ // the rotation critical section instead, which finishes because the sealed
+ // WAL + parked writers freeze the head.
let copiedUpTo = baseOffset;
let appended = false;
- for (;;) {
+ let prevGap = Number.POSITIVE_INFINITY;
+ for (let pass = 0; pass < MAX_PRECOPY_PASSES; pass++) {
await db.wal.flush();
const head = db.wal.size;
- if (head - copiedUpTo <= SMALL_DELTA) break;
+ const gap = head - copiedUpTo;
+ if (gap <= SMALL_DELTA) break;
+ if (pass > 0 && gap > prevGap * CONVERGE_RATIO) break; // not converging: rotate with a parked writer set
await copyFileRange(db.walPath, walTmp, copiedUpTo, head, { append: appended });
appended = true;
copiedUpTo = head;
+ prevGap = gap;
}
- // Phase 3: rotation. SHORT BLOCKING critical section.
+ // Phase 3: rotation. BLOCKING critical section.
//
- // Setting _rotateLock is synchronous and happens-before the flush below. A
- // writer's gate check (`if (_rotateLock) await _rotateLock`) and its
- // wal.append() are in the same synchronous segment, so either the writer saw
- // the old (null) lock and already enqueued its frame (→ drained by flush), or
- // it sees the new lock and parks without appending. The event loop cannot
- // interleave between the two, so after flush() the queue is quiescent and
- // endOffset is final.
+ // Setting _rotateLock is synchronous and happens-before the seal below. New
+ // writers park on the lock; an in-flight writer that passed the gate check
+ // just before the lock landed cannot have its append slip between the final
+ // flush and close(), because seal() makes any post-seal append fail fast
+ // (the op retries against the new WAL once the rotation is done). With the
+ // old WAL sealed, its head no longer moves after this drain loop, so the
+ // loop provably terminates — at the cost of a write pause proportional to
+ // the tail the pre-copy could not drain.
+ //
+ // Recovery: the seal is one-way and the old WAL object is single-use, so a
+ // failure anywhere between the seal and the new WAL's open would leave every
+ // later write hitting WAL_SEALED/'WAL is closed' forever. The catch below
+ // rolls the db forward to a writable state by swapping in a FRESH WAL on
+ // db.walPath (it appends at the real EOF of whatever file the path now
+ // holds). `rotated` tracks the commit point: before the WAL rename the old
+ // full WAL is still at db.walPath and the old store pointers stay valid (a
+ // renamed-in new snapshot paired with the old WAL is consistent — see the
+ // crash-safety note above); past it, the new layout is on disk and recovery
+ // must additionally apply the store-pointer remap + reader reopen. If the
+ // rollback itself also fails (e.g. persistent EMFILE), the db stays
+ // unwritable but the on-disk snapshot/WAL pair is consistent either way, so
+ // the next process open still recovers.
let releaseRotation!: () => void;
db._rotateLock = new Promise((resolve) => {
releaseRotation = resolve;
});
+ let rotated = false;
+ let remapped = false;
+ // Remap disk-backed value pointers to the new snapshot/WAL files. Guarded
+ // against double application: the wal-offset shift is NOT idempotent.
+ const remap = (): void => {
+ if (remapped) return;
+ const snapLocs = snapRes.locs;
+ db.store.remapLocs((k: string, loc: ValueLoc) => {
+ if (loc.file === 'wal' && loc.off >= baseOffset) {
+ return { file: 'wal', off: loc.off - baseOffset, len: loc.len };
+ }
+ return snapLocs.get(k);
+ });
+ remapped = true;
+ };
try {
- await db.wal.flush();
- const endOffset = db.wal.size;
- await copyFileRange(db.walPath, walTmp, copiedUpTo, endOffset, { append: appended });
+ db.wal.seal();
+ for (;;) {
+ await db.wal.flush();
+ const endOffset = db.wal.size;
+ // `!appended` guarantees the (possibly empty) new WAL file is created
+ // even when there is no post-fence tail to copy.
+ if (endOffset === copiedUpTo && appended) break;
+ await copyFileRange(db.walPath, walTmp, copiedUpTo, endOffset, { append: appended });
+ appended = true;
+ copiedUpTo = endOffset;
+ }
await db.wal.close();
+ // Windows cannot rename over an open destination, so our own ValueReader
+ // must let go of the old snapshot/WAL before the renames below. POSIX
+ // keeps the handles across the rotation (old fd reads the unlinked old
+ // inode) — no close needed there; after the remap segment below,
+ // reopenBoth() re-attaches both handles on every platform.
+ if (process.platform === 'win32') db.valueReader?.close?.();
+
// Snapshot first, then WAL — see the crash-safety note in the file header.
- await fs.rename(tmp, snap);
+ await rotateReplace(tmp, snap);
await fsyncDir(db.dir);
- await fs.rename(walTmp, db.walPath);
+ await rotateReplace(walTmp, db.walPath);
+ rotated = true;
await fsyncDir(db.dir);
- db.wal = new WAL(db.walPath, { fsyncPolicy: db.fsyncPolicy, stats: db.stats });
- await db.wal.open();
+ const fresh = new WAL(db.walPath, { fsyncPolicy: db.fsyncPolicy, stats: db.stats });
+ db.wal = fresh;
+ await fresh.open();
- // Remap disk-backed value pointers to the new snapshot/WAL files. Do this in
- // the same synchronous segment as the fd reopen, so synchronous readers can
- // never observe a new pointer against an old fd or vice versa.
- const snapLocs = snapRes.locs;
- db.store.remapLocs((k: string, loc: ValueLoc) => {
- if (loc.file === 'wal' && loc.off >= baseOffset) {
- return { file: 'wal', off: loc.off - baseOffset, len: loc.len };
- }
- return snapLocs.get(k);
- });
+ // Commit the in-memory view to the new files. Do this in the same
+ // synchronous segment as the fd reopen, so synchronous readers can never
+ // observe a new pointer against an old fd or vice versa.
+ remap();
db.valueReader?.reopenBoth();
+ } catch (err) {
+ try {
+ // Swap the sealed/closed WAL for a fresh handle on db.walPath. The swap
+ // comes first: it both restores appendability and stops late in-flight
+ // writers from publishing old-file value pointers against the fresh WAL.
+ await db.wal.close().catch(() => {});
+ const fresh = new WAL(db.walPath, { fsyncPolicy: db.fsyncPolicy, stats: db.stats });
+ await fresh.open();
+ db.wal = fresh;
+ if (rotated) {
+ remap();
+ db.valueReader?.reopenBoth();
+ }
+ } catch {
+ // Best-effort recovery only — on-disk state is consistent regardless.
+ }
+ throw err;
} finally {
releaseRotation();
db._rotateLock = null;
diff --git a/packages/minidb/src/index.ts b/packages/minidb/src/index.ts
index 2f52afef30..a6ea101928 100644
--- a/packages/minidb/src/index.ts
+++ b/packages/minidb/src/index.ts
@@ -13,7 +13,7 @@ import { Store } from './store.js';
import type { StoreRecord, ValueLoc } from './store.js';
import { WAL } from './wal.js';
import { ValueReader } from './value-reader.js';
-import { recover } from './recovery.js';
+import { recover, catchUpWal, frameToOps } from './recovery.js';
import { compact, shouldCompact } from './compaction.js';
import { IndexManager, UniqueViolationError } from './index-manager.js';
import { DtIndex } from './dt-index.js';
@@ -22,9 +22,9 @@ import { CompoundIndexManager } from './compound-index.js';
import { getPath, match, project } from './query.js';
import { LockFile, LockError } from './lockfile.js';
import { encodeFrame, encodeBatchOps, scanBatchOpRefs, HEADER_SIZE, TYPE_SET, TYPE_DEL, TYPE_BATCH } from './codec.js';
-import type { BatchOp as EncodedBatchOp } from './codec.js';
+import type { BatchOp as EncodedBatchOp, FrameRef } from './codec.js';
import type { FsyncPolicy } from './wal.js';
-import type { RecoveryMode, RecoveryInfo, ValueMode } from './recovery.js';
+import type { RecoveryMode, RecoveryInfo, ValueMode, RecoveredOp } from './recovery.js';
import type { IndexDef, IndexInfo } from './index-manager.js';
import type { CompoundIndexDef, CompoundIndexInfo } from './compound-index.js';
import type { DtRangeEntry } from './dt-index.js';
@@ -35,6 +35,8 @@ export { LockError } from './lockfile.js';
export type { RecoveryInfo } from './recovery.js';
export type { IndexDef, IndexInfo, IndexType } from './index-manager.js';
export type { CompoundIndexDef, CompoundIndexInfo } from './compound-index.js';
+// ClusterDb (the multi-process sharding layer) lives at the './cluster'
+// subpath export to keep this module free of import cycles.
export type ValueCodecName = 'buffer' | 'string' | 'json';
@@ -91,6 +93,13 @@ function canonRange(opts: RangeOptions): RangeOptions {
if (out.lt !== undefined) out.lt = toKStr(out.lt);
return out;
}
+
+/** Lazy one-shot candidate filter — keeps query pipelines streaming so a
+ * bounded query stops after `skip + limit` matches instead of materializing
+ * every candidate. */
+function* filterKeys(keys: Iterable, pred: (k: string) => boolean): Generator {
+ for (const k of keys) if (pred(k)) yield k;
+}
function normDt(dt?: Record | null): Record | null {
if (!dt) return null;
const out: Record = {};
@@ -112,6 +121,14 @@ async function fileSize(file: string): Promise {
}
}
+/** Write a small metadata file atomically (tmp + rename), so a crash cannot
+ * leave a torn definition file that would force openers into error/rebuild. */
+async function writeFileAtomic(file: string, data: string): Promise {
+ const tmp = `${file}.tmp`;
+ await fs.writeFile(tmp, data, 'utf8');
+ await fs.rename(tmp, file);
+}
+
async function resolveValueMode(mode: ValueModeSetting, dir: string, maxMemoryBytes: number | null): Promise {
if (mode !== 'auto') return mode;
if (maxMemoryBytes === null) return 'memory';
@@ -203,6 +220,10 @@ export class MiniDb {
fsyncPolicy: FsyncPolicy = 'everysec';
private closed = false;
recoveryInfo: RecoveryInfo | null = null;
+ /** Continuation watermark for catchUpFromWal: the WAL inode + applied
+ * offset as advanced by the last successful catch-up (recoveryInfo's scan
+ * endpoint anchors the first call). */
+ private walTail: { dev: number; ino: number; size: number } | null = null;
readOnly = false;
private lock: LockFile | null = null;
@@ -217,11 +238,11 @@ export class MiniDb {
lastCompactError: unknown = null;
maxMemoryBytes: number | null = null;
maxMemoryPolicy: 'reject' | 'evict-lru' = 'reject';
- private access = new Map(); // pk -> last access seq, for LRU eviction
- private accessSeq = 0;
+ private access = new Set(); // pk, insertion-ordered by last touch (Map/Set iteration order): front = LRU
private uniqueWriteLock: Promise = Promise.resolve();
readonly stats = {
compactions: 0,
+ compactErrors: 0,
walBytesWritten: 0,
walFsyncs: 0,
snapshotBytesWritten: 0,
@@ -277,6 +298,29 @@ export class MiniDb {
}
}
+ // Remove stale temp files left behind by an interrupted previous run (a
+ // compaction's snapshot/WAL temps, sidecar-definition temps). Only the
+ // sole writer may delete them — a read-only opener must never touch a live
+ // writer's in-flight temps.
+ if (!db.readOnly) {
+ for (const tmp of [
+ 'db.snapshot.tmp',
+ 'db.wal.tmp',
+ 'db.indexes.json.tmp',
+ 'db.textindexes.json.tmp',
+ 'db.compound-indexes.json.tmp',
+ ]) {
+ await fs.rm(path.join(db.dir, tmp), { force: true });
+ }
+ // A failed postings rebuild orphans `db.text-*.postings.tmp` (its atomic
+ // rename never ran). Postings are pure derived state — rebuilt from the
+ // Store on open and after compaction — so such temps are always safe to
+ // delete, for any index name.
+ for (const f of await fs.readdir(db.dir)) {
+ if (/^db\.text-.*\.postings\.tmp$/.test(f)) await fs.rm(path.join(db.dir, f), { force: true });
+ }
+ }
+
db.store = new Store({
activeExpireIntervalMs: opts.activeExpireIntervalMs ?? 100,
onExpire: (k, rec) => db.onStoreExpire(k, rec),
@@ -287,7 +331,11 @@ export class MiniDb {
});
try {
db.wal = new WAL(db.walPath, { fsyncPolicy: db.fsyncPolicy, stats: db.stats });
- await db.wal.open();
+ // A read-only instance must not create or modify any file: the WAL is
+ // constructed but never opened (opening with 'a' would create db.wal on
+ // disk). Writes are already rejected by ensureWritable, and the unopened
+ // WAL's size stays 0, so shouldCompact never fires for it.
+ if (!db.readOnly) await db.wal.open();
db.recoveryInfo = await recover({
dir: db.dir,
@@ -296,8 +344,19 @@ export class MiniDb {
truncate: !db.readOnly,
valueMode: db.valueMode,
});
- db.valueReader = new ValueReader(db.dir);
- db.valueReader.open();
+ // Recovery may have truncated a torn WAL tail behind the WAL's back;
+ // re-sync its size bookkeeping so later appends (and their disk-mode
+ // value pointers) are computed against the real, truncated file size.
+ if (db.recoveryInfo.truncatedWal) await db.wal.refreshSize();
+ // Disk-backed values need the positioned reader; in valueMode 'memory'
+ // no record ever carries a disk loc, so opening the files would only
+ // hold handles for no benefit. (On Windows those idle handles would
+ // additionally block compaction's rename-over-path rotation — rename
+ // over an open destination is EPERM there.)
+ if (db.valueMode === 'disk') {
+ db.valueReader = new ValueReader(db.dir);
+ db.valueReader.open();
+ }
db.seedAccessFromStore();
await db.loadIndexDefinitions();
@@ -305,7 +364,9 @@ export class MiniDb {
await db.loadTextIndexDefinitions();
db.rebuildAllIndexes();
- if (db.autoCompact && shouldCompact(db)) await compact(db);
+ // A read-only instance never compacts: rotation would rename the live
+ // writer's snapshot/WAL out from under it and lose its acknowledged data.
+ if (!db.readOnly && db.autoCompact && shouldCompact(db)) await compact(db);
} catch (err) {
// Release every resource acquired so far: an open that fails after the
// WAL/store are set up must not leak a file handle or keep the everysec /
@@ -342,6 +403,23 @@ export class MiniDb {
const rebuildable = err instanceof SyntaxError || (err as { name?: string }).name === 'CorruptFrameError';
if (!rebuildable) throw err;
if (hooks.onRebuild) hooks.onRebuild(err);
+ if (err instanceof SyntaxError) {
+ // A corrupted index-definition sidecar holds only derived metadata and
+ // must not cost the whole database: drop the sidecars (indexes can be
+ // recreated by the caller) and retry once before falling back to a
+ // full rebuild. If the SyntaxError came from the data files themselves
+ // (e.g. a corrupt frame meta), the retry fails the same way and the
+ // full rebuild below runs anyway.
+ try {
+ for (const f of ['db.indexes.json', 'db.textindexes.json', 'db.compound-indexes.json']) {
+ await fs.rm(path.join(opts.dir, f), { force: true });
+ await fs.rm(path.join(opts.dir, `${f}.tmp`), { force: true });
+ }
+ return await MiniDb.open(opts);
+ } catch {
+ /* fall through to a full rebuild */
+ }
+ }
await fs.rm(opts.dir, { recursive: true, force: true });
return MiniDb.open(opts);
}
@@ -409,7 +487,7 @@ export class MiniDb {
}
}
private async persistIndexDefinitions(): Promise {
- await fs.writeFile(this.indexPath, JSON.stringify(this.indexes.list()), 'utf8');
+ await writeFileAtomic(this.indexPath, JSON.stringify(this.indexes.list()));
}
private async loadTextIndexDefinitions(): Promise {
try {
@@ -431,7 +509,7 @@ export class MiniDb {
}
}
private async persistTextIndexDefinitions(): Promise {
- await fs.writeFile(this.textIndexPath, JSON.stringify(this.textDefs), 'utf8');
+ await writeFileAtomic(this.textIndexPath, JSON.stringify(this.textDefs));
}
private async loadCompoundIndexDefinitions(): Promise {
try {
@@ -444,7 +522,7 @@ export class MiniDb {
}
}
private async persistCompoundIndexDefinitions(): Promise {
- await fs.writeFile(this.compoundIndexPath, JSON.stringify(this.compound.list()), 'utf8');
+ await writeFileAtomic(this.compoundIndexPath, JSON.stringify(this.compound.list()));
}
/** Drop every derived index entry for a key that just expired in the Store. */
@@ -479,13 +557,41 @@ export class MiniDb {
}
}
+ /**
+ * Run a write-op commit body, transparently retrying once when the commit
+ * raced a compaction rotation: an op that passed the _rotateLock gate check
+ * just before it was set can hit the freshly-sealed old WAL (code
+ * 'WAL_SEALED') between the gate and its append, or — one step later in the
+ * rotation — the already-closed but not-yet-replaced old WAL (the untyped
+ * 'WAL is closed'; only retried while a rotation is actually in flight, so a
+ * write after db.close() still fails). The op rolls its in-memory side
+ * effects back on a failed append, so re-running the (idempotent) commit
+ * body against the post-rotation WAL is safe.
+ */
+ private async retryOnWalSeal(commit: () => Promise): Promise {
+ try {
+ await commit();
+ } catch (e) {
+ const sealed = (e as { code?: string }).code === 'WAL_SEALED';
+ const closedMidRotation =
+ this._rotateLock !== null && e instanceof Error && e.message === 'WAL is closed';
+ if (!sealed && !closedMidRotation) throw e;
+ if (this._rotateLock) await this._rotateLock;
+ await commit();
+ }
+ }
+
private touchAccess(pk: string): void {
- this.access.set(pk, ++this.accessSeq);
+ // Re-insert so the iteration order of `access` is LRU..MRU: delete()+add()
+ // moves the key to the most-recently-used end (a plain set() on an existing
+ // key would keep its old position, which forced O(N) victim scans).
+ this.access.delete(pk);
+ this.access.add(pk);
}
private seedAccessFromStore(): void {
this.access.clear();
- for (const [k] of this.store.map) this.touchAccess(k);
+ for (const [k] of this.store.map) this.access.add(k);
}
private projectedBytesForOps(ops: readonly PreparedOp[]): number {
@@ -504,17 +610,13 @@ export class MiniDb {
return projected;
}
+ /** O(1) LRU victim: `access` is insertion-ordered by last touch, so the
+ * first entry that is a live, non-skipped key is the least-recently-used one. */
private pickEvictionVictim(skip: Set): string | undefined {
- let best: string | undefined;
- let bestSeq = Infinity;
- for (const [k, seq] of this.access) {
+ for (const k of this.access) {
if (skip.has(k) || !this.store.map.has(k)) continue;
- if (seq < bestSeq) {
- best = k;
- bestSeq = seq;
- }
+ return k;
}
- if (best) return best;
for (const [k] of this.store.map) if (!skip.has(k)) return k;
return undefined;
}
@@ -523,15 +625,25 @@ export class MiniDb {
const bytes = this.store.recordBytes(pk);
if (!bytes) return;
const op = this.prepareDel(Buffer.from(pk, 'binary'));
- const appended = this.wal.append(encodeFrame({ type: TYPE_DEL, key: op.key }));
- const prev = this.applyOp(op);
- try {
- await appended;
- this.stats.evictions++;
- } catch (e) {
- this.restoreKey(op.pk, prev);
- throw e;
- }
+ // Committed through retryOnWalSeal like any other write: an evict that
+ // passed the writer gate just before a compaction rotation can land its
+ // DEL on the freshly-sealed (or just-closed, soon-to-be-replaced) old WAL,
+ // and the user write that triggered the eviction must never see that race.
+ // A failed attempt restores the victim via restoreKey, so re-running the
+ // idempotent DEL body against the post-rotation WAL is safe.
+ const commit = async (): Promise => {
+ const appended = this.wal.append(encodeFrame({ type: TYPE_DEL, key: op.key }));
+ const prev = this.applyOp(op);
+ const seq = this.store.map.get(op.pk)?.seq;
+ try {
+ await appended;
+ this.stats.evictions++;
+ } catch (e) {
+ this.restoreKey(op.pk, prev, seq);
+ throw e;
+ }
+ };
+ await this.retryOnWalSeal(commit);
}
private async ensureMemoryFor(ops: readonly PreparedOp[]): Promise {
@@ -634,7 +746,7 @@ export class MiniDb {
try {
await appended.done;
} catch (e) {
- this.restoreKey(op.pk, prev);
+ this.restoreKey(op.pk, prev, seq);
throw e;
}
if (this.valueMode === 'disk') {
@@ -650,27 +762,31 @@ export class MiniDb {
this.maybeAutoCompact();
};
- if (this.hasUniqueIndexes()) await this.withUniqueWriteLock(commit);
- else await commit();
+ if (this.hasUniqueIndexes()) await this.withUniqueWriteLock(() => this.retryOnWalSeal(commit));
+ else await this.retryOnWalSeal(commit);
}
async del(key: string | Buffer): Promise {
this.ensureOpen();
this.ensureWritable();
if (this._rotateLock) await this._rotateLock;
- const existed = this.store.get(toKStr(key)) !== undefined;
+ const existed = this.store.has(toKStr(key));
if (!existed) return false;
const op = this.prepareDel(key);
await this.ensureMemoryFor([op]);
- const appended = this.wal.append(encodeFrame({ type: TYPE_DEL, key: op.key }));
- const prev = this.applyOp(op);
- try {
- await appended;
- } catch (e) {
- this.restoreKey(op.pk, prev);
- throw e;
- }
- this.maybeAutoCompact();
+ const commit = async (): Promise => {
+ const appended = this.wal.append(encodeFrame({ type: TYPE_DEL, key: op.key }));
+ const prev = this.applyOp(op);
+ const seq = this.store.map.get(op.pk)?.seq;
+ try {
+ await appended;
+ } catch (e) {
+ this.restoreKey(op.pk, prev, seq);
+ throw e;
+ }
+ this.maybeAutoCompact();
+ };
+ await this.retryOnWalSeal(commit);
return true;
}
@@ -706,6 +822,11 @@ export class MiniDb {
const prev = this.applyOp(op);
if (!prevs.has(op.pk)) prevs.set(op.pk, prev);
}
+ // Seq identity of each record as this batch left it (undefined where the
+ // batch's last op deleted the key): guards both the rollback and the WAL
+ // pointer publish against interleaved same-key commits.
+ const seqs = new Map();
+ for (const pk of prevs.keys()) seqs.set(pk, this.store.map.get(pk)?.seq);
// In valueMode 'disk' the applied records hold in-memory refs for now
// (see set()); their WAL pointers are published after `done` resolves.
// Only the LAST set per key may publish — an earlier op's frame range
@@ -718,15 +839,14 @@ export class MiniDb {
const op = prepared[i]!;
const ref = opRefs[i];
if (op.type === TYPE_SET && ref) {
- lastSet.set(op.pk, { op, loc: { file: 'wal', off: bodyOff + ref.valueOff, len: ref.valLen }, seq: undefined });
+ lastSet.set(op.pk, { op, loc: { file: 'wal', off: bodyOff + ref.valueOff, len: ref.valLen }, seq: seqs.get(op.pk) });
}
}
- for (const [pk, e] of lastSet) e.seq = this.store.map.get(pk)?.seq;
}
try {
await appended.done;
} catch (e) {
- for (const [pk, prev] of prevs) this.restoreKey(pk, prev);
+ for (const [pk, prev] of prevs) this.restoreKey(pk, prev, seqs.get(pk));
throw e;
}
for (const [pk, { op, loc, seq }] of lastSet) {
@@ -735,8 +855,8 @@ export class MiniDb {
this.maybeAutoCompact();
};
- if (this.hasUniqueIndexes()) await this.withUniqueWriteLock(commit);
- else await commit();
+ if (this.hasUniqueIndexes()) await this.withUniqueWriteLock(() => this.retryOnWalSeal(commit));
+ else await this.retryOnWalSeal(commit);
}
private prepareOp(o: BatchInputOp): PreparedOp {
@@ -802,8 +922,16 @@ export class MiniDb {
}
/** Roll a key back to its pre-op record across the store and every derived
- * index. Used when a WAL write fails after applyOp already mutated state. */
- private restoreKey(pk: string, prev: StoreRecord | undefined): void {
+ * index. Used when a WAL write fails after applyOp already mutated state.
+ * `appliedSeq` is the store record's seq captured right after THIS attempt's
+ * own apply (undefined when the op left the key absent, i.e. a DEL). The
+ * restore is skipped when the key's current state no longer matches it —
+ * the same seq-identity guard publishWalRef uses — because a later same-key
+ * op committed (or an expiry reaped the key) meanwhile, and rolling back
+ * over it would wipe state that is already durable. */
+ private restoreKey(pk: string, prev: StoreRecord | undefined, appliedSeq: number | undefined): void {
+ const cur = this.store.map.get(pk);
+ if (appliedSeq === undefined ? cur !== undefined : cur?.seq !== appliedSeq) return;
if (this.indexes.indexes.size) this.indexes.remove(pk, undefined);
for (const ti of this.text.values()) ti.remove(pk);
this.dt.del(pk);
@@ -824,6 +952,56 @@ export class MiniDb {
}
}
+ /** Apply one recovered WAL frame during catchUpFromWal: the same ops
+ * open-time recovery derives from it (frameToOps), plus the incremental
+ * derived-index maintenance applyOp performs on the write path — minus
+ * unique checks: the writer already validated, and intermediate frame
+ * states must apply literally (LWW). */
+ private applyRecoveredFrame(f: FrameRef, fd: number): void {
+ for (const op of frameToOps(f, 'wal', fd, this.valueMode)) this.applyRecoveredOp(op);
+ }
+
+ private applyRecoveredOp(op: RecoveredOp): void {
+ const pk = this.pk(op.key);
+ // Old doc for derived-index removal; decoded before the overwrite, like
+ // applyOp. This get also lazy-reaps an expired old record, whose onExpire
+ // hook then removes its derived entries for us.
+ const oldDoc = this.indexes.indexes.size ? this.decode(this.store.get(pk)) : undefined;
+ if (op.type === TYPE_DEL) {
+ if (!this.store.del(pk)) return;
+ this.access.delete(pk);
+ this.dt.del(pk);
+ this.compound.remove(pk);
+ if (this.indexes.indexes.size && this.indexable(oldDoc)) this.indexes.remove(pk, oldDoc);
+ for (const ti of this.text.values()) ti.remove(pk);
+ return;
+ }
+ this.store.setRef(op.key, op.ref!, op.expireAt, op.dt);
+ // Re-read through the store: a TTL too short to survive the few
+ // microseconds since the replay-time expiry check was already reaped
+ // here, with onExpire dropping derived state — exactly what a fresh
+ // reopen leaves for the key. Otherwise dt/compound/secondary/text indexes
+ // would be resurrected for a key the store no longer holds.
+ const buf = this.store.get(pk);
+ if (buf === undefined) return;
+ this.dt.set(pk, op.dt);
+ // Values are only decoded when a value-derived index exists (all of them
+ // require the json codec): with none, recovery never copies them either.
+ if (this.indexes.indexes.size || this.text.size || this.compound.list().length) {
+ const doc = this.decode(buf)!;
+ this.compound.add(pk, doc, op.dt);
+ if (this.indexes.indexes.size) {
+ if (this.indexable(oldDoc)) this.indexes.remove(pk, oldDoc);
+ if (this.indexable(doc)) this.indexes.add(pk, doc);
+ }
+ for (const ti of this.text.values()) {
+ if (this.indexable(doc)) ti.add(pk, doc);
+ else ti.remove(pk);
+ }
+ }
+ this.touchAccess(pk);
+ }
+
has(key: string | Buffer): boolean {
this.ensureOpen();
return this.store.has(toKStr(key));
@@ -855,31 +1033,35 @@ export class MiniDb {
const meta = cur.dt ? Buffer.from(JSON.stringify({ dt: cur.dt })) : null;
const keyBuf = toBuf(key);
const frame = encodeFrame({ type: TYPE_SET, key: keyBuf, value: curValue, meta, expireAt });
- const wal = this.wal;
- const appended = wal.appendLoc(frame);
- // In-memory ref first (see set()); the disk pointer is published once the
- // frame's bytes are durably in db.wal.
- this.store.set(k, curValue, expireAt, cur.dt);
- const seq = this.store.map.get(k)?.seq;
- try {
- await appended.done;
- } catch (e) {
- // Value/dt are unchanged (only the TTL moved), so restoring the store
- // record is enough; derived indexes were never touched.
- this.store.setRef(k, cur.ref, cur.expireAt, cur.dt);
- throw e;
- }
- if (this.valueMode === 'disk') {
- this.publishWalRef(
- k,
- wal,
- seq,
- { file: 'wal', off: appended.offset + HEADER_SIZE + keyBuf.length, len: curValue.length },
- expireAt,
- cur.dt,
- );
- }
- this.maybeAutoCompact();
+ const commit = async (): Promise => {
+ const wal = this.wal;
+ const appended = wal.appendLoc(frame);
+ // In-memory ref first (see set()); the disk pointer is published once the
+ // frame's bytes are durably in db.wal. prev/seq are captured per attempt
+ // (as in set()): a rotation retry can find a different record in place,
+ // and restoreKey's seq guard then leaves that newer durable state alone.
+ const prev = this.store.map.get(k);
+ this.store.set(k, curValue, expireAt, cur.dt);
+ const seq = this.store.map.get(k)?.seq;
+ try {
+ await appended.done;
+ } catch (e) {
+ this.restoreKey(k, prev, seq);
+ throw e;
+ }
+ if (this.valueMode === 'disk') {
+ this.publishWalRef(
+ k,
+ wal,
+ seq,
+ { file: 'wal', off: appended.offset + HEADER_SIZE + keyBuf.length, len: curValue.length },
+ expireAt,
+ cur.dt,
+ );
+ }
+ this.maybeAutoCompact();
+ };
+ await this.retryOnWalSeal(commit);
return true;
}
@@ -1026,11 +1208,25 @@ export class MiniDb {
if (this.codecName !== 'json') throw new Error('text indexes require valueCodec: "json"');
if (this.text.has(name)) throw new Error(`text index "${name}" already exists`);
const ti = new TextIndex({ fields, postingsPath: this.textPostingsPath(name) });
- this.text.set(name, ti);
const def = { name, fields: fields ?? null };
- this.textDefs.push(def);
+ // Build BEFORE registering: a failed build must leave no phantom index
+ // behind — a registered-but-unbuilt index would both poison every write
+ // path that walks this.text and make a retry fail with "already exists".
ti.build(this.textRecords());
- await this.persistTextIndexDefinitions();
+ this.text.set(name, ti);
+ this.textDefs.push(def);
+ try {
+ await this.persistTextIndexDefinitions();
+ } catch (e) {
+ // Unwind so the in-memory state and the definition sidecar (which does
+ // not name this index) do not diverge; drop the derived postings file
+ // with it, exactly like dropTextIndex would.
+ this.text.delete(name);
+ this.textDefs = this.textDefs.filter((d) => d.name !== name);
+ ti.close();
+ await fs.rm(this.textPostingsPath(name), { force: true }).catch(() => {});
+ throw e;
+ }
}
async dropTextIndex(name: string): Promise {
this.ensureOpen();
@@ -1168,6 +1364,10 @@ export class MiniDb {
if (dtCols.length !== 1) return null;
const col = dtCols[0]!;
const cond = q.dt[col]!;
+ // A dt condition carrying its own offset/count has slice semantics this
+ // fast path cannot reproduce exactly (it honors range bounds only) — the
+ // general path handles it.
+ if (cond.offset !== undefined || cond.count !== undefined) return null;
// Result order must be the dt column's order.
let reverse = false;
@@ -1181,8 +1381,6 @@ export class MiniDb {
const limit = q.limit;
const skip = q.skip ?? 0;
- // Only the range bounds are honored here; ignore any stray count/offset a
- // caller put on the dt cond so they cannot truncate the walk prematurely.
const iterOpts: RangeOptions = { reverse };
if (cond.gte !== undefined) iterOpts.gte = cond.gte;
if (cond.gt !== undefined) iterOpts.gt = cond.gt;
@@ -1231,23 +1429,30 @@ export class MiniDb {
const fast = this.tryDtOrderedLimit(q);
if (fast !== null) return fast;
- let keys: string[] | null = null;
+ // Candidate collection never decodes values and stays lazy (a one-shot
+ // iterable) wherever possible: key scans walk the ordered index directly,
+ // and intersections filter as they go. A bounded query below then decodes
+ // only the rows it returns instead of materializing the whole candidate
+ // set first.
+ let keys: Iterable | null = null;
if (typeof q.key === 'string') {
keys = [toKStr(q.key)];
} else if (q.key && typeof q.key === 'object') {
- if ((q.key as { prefix?: string }).prefix) keys = this.prefix((q.key as { prefix: string }).prefix).map((r) => toKStr(r.key));
- else {
+ if ((q.key as { prefix?: string }).prefix) {
+ const p = toKStr((q.key as { prefix: string }).prefix);
+ keys = this.store.rawKeys({ gte: p, lt: p + '\uffff' });
+ } else {
const opts: RangeOptions = {};
for (const b of ['gte', 'gt', 'lte', 'lt'] as const)
if ((q.key as Record)[b] !== undefined) opts[b] = (q.key as Record)[b] as string;
- keys = this.scan(opts).map((r) => toKStr(r.key));
+ keys = this.store.rawKeys(canonRange(opts));
}
}
if (q.dt) {
for (const [col, cond] of Object.entries(q.dt)) {
const set = new Set(this.dt.range(col, cond).map((r) => r.key));
- keys = keys === null ? [...set] : keys.filter((k) => set.has(k));
+ keys = keys === null ? set : filterKeys(keys, (k) => set.has(k));
}
}
@@ -1258,25 +1463,40 @@ export class MiniDb {
const hits = ti.search(q.text.q, { op: q.text.op, limit: q.text.limit ?? 1_000_000 });
textOrder = hits;
const set = new Set(hits.map((h) => h.key));
- keys = keys === null ? hits.map((h) => h.key) : keys.filter((k) => set.has(k));
+ keys = keys === null ? hits.map((h) => h.key) : filterKeys(keys, (k) => set.has(k));
}
const indexed = this.indexedCandidateKeys(q.filter);
if (indexed) {
const set = new Set(indexed);
- keys = keys === null ? indexed : keys.filter((k) => set.has(k));
+ keys = keys === null ? indexed : filterKeys(keys, (k) => set.has(k));
}
- if (keys === null) keys = this.scan().map((r) => toKStr(r.key));
+ if (keys === null) keys = this.store.rawKeys({});
+ const skip = q.skip ?? 0;
+ const limit = q.limit === undefined ? Infinity : q.limit;
+ // Without an explicit sort or text ranking, result order is the candidate
+ // iteration order, so skip/limit can be applied while iterating: a bounded
+ // query decodes only the rows it returns instead of the whole candidate
+ // set (an indexed equality query with limit previously decoded every
+ // candidate and sliced at the end).
+ const early = !q.sort && !textOrder;
const docs: ScanEntry[] = [];
+ let seen = 0;
for (const k of keys) {
const buf = this.store.get(k);
if (buf === undefined) continue;
const r = this.store.map.get(k);
const value = this.decode(buf)!;
if (q.filter && !match(value, q.filter)) continue;
- docs.push({ key: k, value, dt: r?.dt ?? undefined });
+ if (early) {
+ if (seen++ < skip) continue;
+ docs.push({ key: k, value, dt: r?.dt ?? undefined });
+ if (docs.length >= limit) break;
+ } else {
+ docs.push({ key: k, value, dt: r?.dt ?? undefined });
+ }
}
if (textOrder && !q.sort) {
@@ -1297,9 +1517,7 @@ export class MiniDb {
});
}
- const skip = q.skip ?? 0;
- const limit = q.limit === undefined ? Infinity : q.limit;
- const sliced = skip || limit !== Infinity ? docs.slice(skip, skip + limit) : docs;
+ const sliced = early ? docs : skip || limit !== Infinity ? docs.slice(skip, skip + limit) : docs;
if (q.project) {
return sliced.map((d) => ({ key: fromKStr(d.key), value: project(d.value, q.project) as V, dt: d.dt }));
@@ -1387,6 +1605,40 @@ export class MiniDb {
// ---- maintenance --------------------------------------------------------
+ /** Refresh the write lock's timestamp (see {@link LockFile.renew}). No-op
+ * for a read-only instance. Exposed for lease-style holders such as the
+ * cluster shard pool, which renew on a timer to prove liveness. */
+ async renewLock(): Promise {
+ await this.lock?.renew();
+ }
+
+ /** Advanced/internal (read-replica owners such as the cluster shard pool):
+ * incrementally apply WAL frames appended to db.wal after `offset` — the
+ * same frames open-time recovery would replay, interpreted identically
+ * (frameToOps: valueMode memory/disk refs, expired-SET drop with LWW,
+ * TYPE_BATCH unrolling, dt meta) — plus incremental maintenance of every
+ * derived index (dt, compound, secondary, text). Unique constraints are
+ * NOT checked: the writer already validated, and intermediate frame states
+ * must apply literally, last-writer-wins.
+ *
+ * The instance tracks its own continuation: the first call must pass
+ * recoveryInfo.walScanEnd, every later call the previous call's returned
+ * offset, and the fs identity of the WAL opened for reading must match the
+ * inode recovery (or the last catch-up) scanned — an offset too old/new, a
+ * rotated file and a shrunken one all return null, meaning: reopen from
+ * scratch. A partial/torn tail left by a writer mid-writev is NOT an
+ * error: the scan stops at the last fully-valid frame; call again later
+ * and its CRC validates once the writev landed. */
+ async catchUpFromWal(offset: number): Promise<{ offset: number; appliedFrames: number } | null> {
+ this.ensureOpen();
+ const ri = this.recoveryInfo;
+ const anchor = this.walTail ?? (ri && ri.walIno ? { dev: ri.walDev, ino: ri.walIno, size: ri.walScanEnd } : null);
+ if (!anchor || offset !== anchor.size) return null;
+ const res = catchUpWal(this.walPath, offset, anchor, (f, fd) => this.applyRecoveredFrame(f, fd));
+ if (res) this.walTail = { dev: anchor.dev, ino: anchor.ino, size: res.offset };
+ return res;
+ }
+
async compact(): Promise {
this.ensureOpen();
this.ensureWritable();
diff --git a/packages/minidb/src/lockfile.ts b/packages/minidb/src/lockfile.ts
index 9fd624e47d..43727f1a41 100644
--- a/packages/minidb/src/lockfile.ts
+++ b/packages/minidb/src/lockfile.ts
@@ -6,7 +6,9 @@
// recorded owner PID is no longer alive — never merely because it is old.
import fs from 'node:fs/promises';
-import { unlinkSync } from 'node:fs';
+import fsSync from 'node:fs';
+import path from 'node:path';
+import { renameReplace } from './rename-replace.js';
export class LockError extends Error {
readonly code = 'ELOCKED';
@@ -28,7 +30,27 @@ function pidAlive(pid: unknown): boolean {
// Track held locks so we can release them on process exit as a safety net.
const HELD = new Set();
+// Distinct sidecar names per acquire attempt: two lock users in the same
+// process (e.g. independent shard pools) must never share a tmp/bid/watch
+// path, or one user's cleanup would delete the other's in-flight file.
+let sidecarSeq = 0;
+const nextSidecarSeq = (): number => ++sidecarSeq;
let exitHooked = false;
+// Co-bidders all replace the stale corpse within the same wave (they woke on
+// the same event); the settle pause before the winner claims the lock must
+// outlast that wave so the last bidder to land is unambiguous. A fixed value
+// (even a generous one) loses on shared CI runners that deschedule a bidder
+// for hundreds of milliseconds inside its own atomic-op sequence, so the
+// settle is ADAPTIVE: it scales with how long our own takeover attempt took
+// (4x the wall clock of inspect+writeBid+rename — a stalled machine stalls
+// every bidder), floored at 60ms and capped at 2s so a healthy takeover stays
+// fast. Residual (bounded-delay, inherent to file-based takeover): a bidder
+// whose writeBid is delayed past the winner's final verify can still
+// double-win — the bid-file sweep at verification shrinks this window to
+// "competitor had not even started writing their bid yet", which requires a
+// full-attempt+settle-sized skew and is effectively a process-level pause.
+const TAKEOVER_SETTLE_BASE_MS = 60;
+const TAKEOVER_SETTLE_MAX_MS = 2_000;
function hookExit(): void {
if (exitHooked) return;
exitHooked = true;
@@ -45,32 +67,197 @@ export class LockFile {
this.path = path;
}
- /** Try to acquire the lock. Returns true if acquired, false if held by a live process. */
+ /** Try to acquire the lock exactly once. Returns true when this call created
+ * the lock file, either directly or by winning a stale-lock takeover. Returns
+ * false whenever the lock was already held at attempt time — by a live owner
+ * or by a competing takeover. After observing a held lock this call never
+ * re-races: callers that want to wait retry acquire() at a higher level
+ * (see the cluster lock pool). */
async acquire(): Promise {
+ // Register a "watch" BEFORE touching the lock: every contender is visible
+ // to every other for its whole attempt, regardless of where the scheduler
+ // stalls it. (Settle-window heuristics alone could not survive a bidder
+ // descheduled before its bid write on a shard-parallel CI runner — see the
+ // takeover loop below; a stalled contender is only in the way, not
+ // invisible.)
+ const watch = `${this.path}.watch-${process.pid}-${nextSidecarSeq()}`;
+ await fs.writeFile(watch, JSON.stringify({ pid: process.pid, ts: Date.now() }));
try {
- const fh = await fs.open(this.path, 'wx'); // O_CREAT | O_EXCL | O_WRONLY
- await fh.writeFile(JSON.stringify({ pid: process.pid, ts: Date.now() }));
- await fh.close();
+ await this.reapDeadWatches();
+
+ if (await this.tryCreate()) return true;
+
+ // The lock exists. Only a DEAD owner's lock may be taken over; everything
+ // else (a live owner, or a takeover bid made by another racer in the
+ // meantime) is respected.
+ const seen = await this.inspect();
+ if (seen === null || seen.alive) return false;
+
+ // Takeover via atomic bid-replace, NOT unlink-then-create. Unlinking a
+ // stale lock and then racing to re-create it left a window in which a
+ // loser could delete the winner's just-linked file, after which several
+ // processes all believed they held the lock. Rename atomically replaces
+ // the corpse with our bid.
+ //
+ // Windows cannot rename over a destination while ANY process holds it
+ // open (co-racers reading/stat'ing the corpse make the rename EPERM), so
+ // the rename is retried with jitter. Crucially, each retry re-inspects
+ // the corpse first: a blind retry loop could land our bid seconds late,
+ // OVERWRITING an already-verified winner's lock line and double-holding
+ // (exactly the failure this loop is careful not to reintroduce).
+ const bid = `${this.path}.bid-${process.pid}-${nextSidecarSeq()}`;
+ const attemptStart = Date.now();
+ try {
+ await fs.writeFile(bid, JSON.stringify({ pid: process.pid, ts: Date.now() }));
+ for (let attempt = 0; ; attempt++) {
+ // The corpse must still be there and dead. A competitor who landed
+ // wins by being alive in the file now — back off instead of
+ // overwriting their lock. (Unconditional, not just win32: the same
+ // overwrite hazard exists on POSIX when a co-bidder is descheduled
+ // between its first inspect and its rename.)
+ const gate = await this.inspect();
+ if (gate === null || gate.alive || gate.mine) {
+ await fs.unlink(bid).catch(() => {});
+ return false;
+ }
+ try {
+ await fs.rename(bid, this.path);
+ break;
+ } catch (e) {
+ const code = (e as NodeJS.ErrnoException).code;
+ const epermRetryable =
+ code === 'EPERM' && process.platform === 'win32' && attempt < 50;
+ if (!epermRetryable) {
+ await fs.unlink(bid).catch(() => {});
+ // EEXIST races another creator; a persistent EPERM (Windows
+ // retries exhausted) means some holder kept the path pinned —
+ // either way the corpse could not be displaced this round, so
+ // decline like a live lock and let callers retry higher up.
+ if (code === 'EEXIST' || code === 'EPERM') return false;
+ throw e;
+ }
+ await new Promise((r) => setTimeout(r, 20 + Math.floor(Math.random() * 30)));
+ }
+ }
+ } catch (e) {
+ await fs.unlink(bid).catch(() => {});
+ throw e;
+ }
+
+ // Adaptive settle: scale with how long our own attempt took (a stalled
+ // machine stalls every bidder), floored and capped (see the constants).
+ const elapsedMs = Date.now() - attemptStart;
+ let settleMs = Math.min(TAKEOVER_SETTLE_MAX_MS, Math.max(TAKEOVER_SETTLE_BASE_MS, elapsedMs * 4));
+ for (;;) {
+ await new Promise((resolve) => setTimeout(resolve, settleMs));
+ const cur = await this.inspect();
+ if (cur === null || !cur.mine) return false;
+ // Any live foreign watch means a contender is still in flight (its
+ // registration precedes its whole attempt): wait for its loop to
+ // finish instead of claiming on stale evidence. This is the check
+ // that makes exactly-one a construction, not a timing bet.
+ if (!(await this.hasLiveForeignWatch())) break;
+ settleMs = Math.min(TAKEOVER_SETTLE_MAX_MS, settleMs * 2);
+ }
+ this.markHeld();
+ return true;
+ } finally {
+ await fs.unlink(watch).catch(() => {});
+ }
+ }
+
+ /** Delete watch registrations whose owner pid is no longer alive. */
+ private async reapDeadWatches(): Promise {
+ const dir = path.dirname(this.path);
+ const prefix = `${path.basename(this.path)}.watch-`;
+ for (const f of await fs.readdir(dir).catch(() => [] as string[])) {
+ if (!f.startsWith(prefix)) continue;
+ const pid = Number(f.slice(prefix.length).split('-')[0]);
+ if (Number.isInteger(pid) && pid !== process.pid && !pidAlive(pid)) {
+ await fs.unlink(path.join(dir, f)).catch(() => {});
+ }
+ }
+ }
+
+ /** True when any OTHER process's liveness watch exists (reaping dead ones on sight). */
+ private async hasLiveForeignWatch(): Promise {
+ const dir = path.dirname(this.path);
+ const prefix = `${path.basename(this.path)}.watch-`;
+ for (const f of await fs.readdir(dir).catch(() => [] as string[])) {
+ if (!f.startsWith(prefix)) continue;
+ const pid = Number(f.slice(prefix.length).split('-')[0]);
+ if (!Number.isInteger(pid) || pid === process.pid) continue;
+ if (pidAlive(pid)) return true;
+ await fs.unlink(path.join(dir, f)).catch(() => {});
+ }
+ return false;
+ }
+
+ /** Atomic create-if-absent publish: tmp write + hard link (EEXIST-safe). */
+ private async tryCreate(): Promise {
+ const tmp = `${this.path}.tmp-${process.pid}-${nextSidecarSeq()}`;
+ try {
+ await fs.writeFile(tmp, JSON.stringify({ pid: process.pid, ts: Date.now() }));
+ await fs.link(tmp, this.path);
this.markHeld();
return true;
} catch (e) {
if ((e as NodeJS.ErrnoException).code !== 'EEXIST') throw e;
+ return false;
+ } finally {
+ await fs.unlink(tmp).catch(() => {});
}
- if (await this.isStale()) {
- await fs.unlink(this.path).catch(() => {});
- return this.acquire();
+ }
+
+ /** Read the lock file and decide its state. null = the file vanished. */
+ private async inspect(): Promise<{ ino: number | bigint; alive: boolean; mine: boolean } | null> {
+ let raw: string;
+ let st: { ino: number | bigint };
+ try {
+ [raw, st] = await Promise.all([fs.readFile(this.path, 'utf8'), fs.stat(this.path)]);
+ } catch (e) {
+ if ((e as NodeJS.ErrnoException).code === 'ENOENT') return null;
+ throw e;
}
- return false;
+ let pid: number | undefined;
+ try {
+ pid = (JSON.parse(raw) as { pid?: number }).pid;
+ } catch {
+ pid = undefined; // unparsable content looks abandoned, same as a dead PID
+ }
+ return { ino: st.ino, alive: pidAlive(pid), mine: pid === process.pid };
}
- private async isStale(): Promise {
+ private inspectSync(): { ino: number | bigint; alive: boolean; mine: boolean } | null {
+ let raw: string;
+ let st: { ino: number | bigint };
+ try {
+ raw = fsSync.readFileSync(this.path, 'utf8');
+ st = fsSync.statSync(this.path);
+ } catch (e) {
+ if ((e as NodeJS.ErrnoException).code === 'ENOENT') return null;
+ throw e;
+ }
+ let pid: number | undefined;
try {
- const raw = await fs.readFile(this.path, 'utf8');
- const { pid } = JSON.parse(raw) as { pid?: number };
- return !pidAlive(pid);
+ pid = (JSON.parse(raw) as { pid?: number }).pid;
} catch {
- return true;
+ pid = undefined;
}
+ return { ino: st.ino, alive: pidAlive(pid), mine: pid === process.pid };
+ }
+
+ /** Refresh the lock timestamp (proves liveness to processes inspecting the
+ * lock file). No-op when the lock is not held. Uses write-tmp-then-rename
+ * so a crash mid-renew cannot leave a truncated, "stale-looking" lock file
+ * behind for a lock that is actually still owned. */
+ async renew(): Promise {
+ if (!this.held) return;
+ const tmp = `${this.path}.tmp-${process.pid}-${nextSidecarSeq()}`;
+ await fs.writeFile(tmp, JSON.stringify({ pid: process.pid, ts: Date.now() }));
+ // Windows: replacing our own lock can still clash with a co-process's
+ // readFile/stat of it (EPERM) — the helper rides out such transients.
+ await renameReplace(tmp, this.path, { retries: 20 });
}
private markHeld(): void {
@@ -81,7 +268,12 @@ export class LockFile {
async release(): Promise {
if (!this.held) return;
- await fs.unlink(this.path).catch(() => {});
+ // Unlink ONLY the file this instance actually owns. The content at this
+ // path may have been replaced since we acquired it (a supervisor re-plant a
+ // dead-man's marker, a concurrent takeover…), and deleting such a file
+ // would drop a lock that no longer belongs to us.
+ const cur = await this.inspect();
+ if (cur?.mine) await fs.unlink(this.path).catch(() => {});
this.held = false;
HELD.delete(this);
}
@@ -90,7 +282,8 @@ export class LockFile {
releaseSync(): void {
if (!this.held) return;
try {
- unlinkSync(this.path);
+ const cur = this.inspectSync();
+ if (cur?.mine) fsSync.unlinkSync(this.path);
} catch {
/* ignore */
}
diff --git a/packages/minidb/src/recovery.ts b/packages/minidb/src/recovery.ts
index c25064ed8c..b44309f558 100644
--- a/packages/minidb/src/recovery.ts
+++ b/packages/minidb/src/recovery.ts
@@ -3,11 +3,15 @@
// Startup recovery: load the latest snapshot (if any) then replay the WAL on
// top, last-writer-wins. In valueMode:'disk' recovery scans frames without
// copying values and stores { file, off, len } pointers instead.
+//
+// The per-frame interpretation (expiry drop, batch unrolling, value refs, dt
+// meta) lives in frameToOps so that open-time recovery and read-replica WAL
+// catch-up (catchUpWal) can never drift apart.
import fs from 'node:fs/promises';
import fsSync from 'node:fs';
import path from 'node:path';
-import { scanFrameRefsFd, scanBatchOpRefs, TYPE_SET, TYPE_DEL, TYPE_BATCH } from './codec.js';
+import { scanFrameRefsFd, scanBatchOpRefs, TYPE_SET, TYPE_DEL, TYPE_BATCH, MAGIC } from './codec.js';
import type { FrameRef } from './codec.js';
import type { Store, ValueLoc, ValueRef } from './store.js';
@@ -21,6 +25,13 @@ export interface RecoveryInfo {
corruptRanges: [number, number][];
snapshotCorruptRanges: [number, number][];
lostBytes: number;
+ /** Byte offset in db.wal up to which recovery replayed frames (the scan
+ * endpoint; a torn/corrupt tail beyond it was NOT applied). 0 without WAL.
+ * Anchored by walDev/walIno to the inode that was scanned. */
+ walScanEnd: number;
+ /** dev/ino of the WAL inode recovery scanned (both 0 when there was none). */
+ walDev: number;
+ walIno: number;
}
function readAtSync(fd: number, off: number, len: number): Buffer {
@@ -41,13 +52,22 @@ function parseMeta(meta: Buffer | null): Record | null {
return parsed.dt ?? null;
}
-function applySetRef(
+/** A recovered frame unrolled into one primitive store op; the shared
+ * interpretation layer between open-time recovery and replica catch-up. */
+export interface RecoveredOp {
+ type: number; // TYPE_SET | TYPE_DEL
+ key: Buffer;
+ ref: ValueRef | null;
+ expireAt: number;
+ dt: Record | null;
+}
+
+function* setRefToOps(
f: { key: Buffer; valueOff: number; valLen: number; meta: Buffer | null; expireAt: number },
file: ValueLoc['file'],
fd: number,
- store: Store,
valueMode: ValueMode,
-): void {
+): Generator {
// A record whose TTL already elapsed while the db was closed must not be
// replayed as a live key: that would make `size` count a key scan/get hide,
// and would rebuild indexes without it (inconsistency).
@@ -60,45 +80,50 @@ function applySetRef(
// semantics: a later op (another SET, or a DEL) will re-establish the key if
// needed; otherwise the key stays gone, as its expired TTL dictates.
if (f.expireAt && f.expireAt <= Date.now()) {
- store.del(f.key);
+ yield { type: TYPE_DEL, key: f.key, ref: null, expireAt: 0, dt: null };
return;
}
const dt = parseMeta(f.meta);
- if (valueMode === 'disk') {
- const ref: ValueRef = { kind: 'disk', loc: { file, off: f.valueOff, len: f.valLen } };
- store.setRef(f.key, ref, f.expireAt, dt);
- } else {
- store.set(f.key, readAtSync(fd, f.valueOff, f.valLen), f.expireAt, dt);
- }
+ const ref: ValueRef =
+ valueMode === 'disk'
+ ? { kind: 'disk', loc: { file, off: f.valueOff, len: f.valLen } }
+ : { kind: 'memory', value: readAtSync(fd, f.valueOff, f.valLen) };
+ yield { type: TYPE_SET, key: f.key, ref, expireAt: f.expireAt, dt };
}
-function applyBatchRef(
- f: FrameRef,
- file: ValueLoc['file'],
- fd: number,
- store: Store,
- valueMode: ValueMode,
-): void {
- let ops;
- try {
- ops = scanBatchOpRefs(readAtSync(fd, f.valueOff, f.valLen), f.valueOff);
- } catch {
- // A malformed body with a valid outer CRC can only come from an encoder
- // bug. Skip the whole batch rather than half-apply it, preserving the
- // all-or-nothing guarantee.
- return;
- }
- for (const op of ops) {
- if (op.type === TYPE_SET) applySetRef(op, file, fd, store, valueMode);
- else if (op.type === TYPE_DEL) store.del(op.key);
+/** Unroll one recovered frame into primitive ops. SET frames carry their value
+ * ref (inline bytes in memory mode, a {file, off, len} pointer in disk mode);
+ * expired-at-replay SETs become DELs (see setRefToOps). A BATCH frame yields
+ * its sub-ops in order; a malformed body with a valid outer CRC skips the
+ * whole batch rather than half-applying it. Unknown frame types yield nothing. */
+export function* frameToOps(f: FrameRef, file: ValueLoc['file'], fd: number, valueMode: ValueMode): Generator {
+ if (f.type === TYPE_SET) {
+ yield* setRefToOps(f, file, fd, valueMode);
+ } else if (f.type === TYPE_DEL) {
+ yield { type: TYPE_DEL, key: f.key, ref: null, expireAt: 0, dt: null };
+ } else if (f.type === TYPE_BATCH) {
+ let ops;
+ try {
+ ops = scanBatchOpRefs(readAtSync(fd, f.valueOff, f.valLen), f.valueOff);
+ } catch {
+ // A malformed body with a valid outer CRC can only come from an encoder
+ // bug. Skip the whole batch rather than half-apply it, preserving the
+ // all-or-nothing guarantee.
+ return;
+ }
+ for (const op of ops) {
+ if (op.type === TYPE_SET) yield* setRefToOps(op, file, fd, valueMode);
+ else if (op.type === TYPE_DEL) yield { type: TYPE_DEL, key: op.key, ref: null, expireAt: 0, dt: null };
+ }
}
}
function applyFrames(frames: FrameRef[], file: ValueLoc['file'], fd: number, store: Store, valueMode: ValueMode): void {
for (const f of frames) {
- if (f.type === TYPE_SET) applySetRef(f, file, fd, store, valueMode);
- else if (f.type === TYPE_DEL) store.del(f.key);
- else if (f.type === TYPE_BATCH) applyBatchRef(f, file, fd, store, valueMode);
+ for (const op of frameToOps(f, file, fd, valueMode)) {
+ if (op.type === TYPE_SET) store.setRef(op.key, op.ref!, op.expireAt, op.dt);
+ else if (op.type === TYPE_DEL) store.del(op.key);
+ }
}
}
@@ -135,15 +160,22 @@ export async function recover({
let walFrames = 0;
let walCorrupt: [number, number][] = [];
let truncatedWal = false;
+ let walScanEnd = 0;
+ let walDev = 0;
+ let walIno = 0;
if (fsSync.existsSync(walPath)) {
const fd = fsSync.openSync(walPath, 'r');
let walSize = 0;
try {
- walSize = fsSync.fstatSync(fd).size;
+ const st = fsSync.fstatSync(fd);
+ walSize = st.size;
+ walDev = st.dev;
+ walIno = st.ino;
const r = scanFrameRefsFd(fd, { onCorrupt: mode });
applyFrames(r.frames, 'wal', fd, store, valueMode);
walFrames = r.frames.length;
walCorrupt = r.corruptRanges;
+ walScanEnd = r.eofOffset;
const last = r.corruptRanges[r.corruptRanges.length - 1];
if (last && last[1] === walSize) {
// A torn/corrupt tail is normally truncated so the next writer appends
@@ -167,5 +199,56 @@ export async function recover({
corruptRanges: walCorrupt,
snapshotCorruptRanges: snapshotCorrupt,
lostBytes: [...walCorrupt, ...snapshotCorrupt].reduce((a, [s, e]) => a + (e - s), 0),
+ walScanEnd,
+ walDev,
+ walIno,
};
}
+
+/** Continue a replica from a WAL watermark: strictly scan frames in
+ * [offset, EOF) of `walPath` and hand each to `apply(f, fd)` in order.
+ *
+ * Returns the new continuation offset (end of the last fully-valid frame)
+ * and how many frames were applied. An invalid/partial frame anywhere stops
+ * the scan WITHOUT error (a writer mid-writev leaves such a tail; everything
+ * applied before it stands and the next call re-validates from the stopped
+ * offset — its CRC passes once the writev lands). Returns null when `offset`
+ * cannot be a clean frame-boundary continuation (negative, beyond EOF,
+ * pointing at bytes that start no frame, or the file is not the `anchor`
+ * inode — rotation swapped it in the microseconds between the caller's stat
+ * and this open): the caller must fully reopen. */
+export function catchUpWal(
+ walPath: string,
+ offset: number,
+ anchor: { dev: number; ino: number },
+ apply: (f: FrameRef, fd: number) => void,
+): { offset: number; appliedFrames: number } | null {
+ let fd: number;
+ try {
+ fd = fsSync.openSync(walPath, 'r');
+ } catch (e) {
+ if ((e as NodeJS.ErrnoException).code === 'ENOENT') return null;
+ throw e;
+ }
+ try {
+ const st = fsSync.fstatSync(fd);
+ if (st.dev !== anchor.dev || st.ino !== anchor.ino) return null;
+ const size = st.size;
+ if (offset < 0 || offset > size) return null;
+ const r = scanFrameRefsFd(fd, { onCorrupt: 'strict', startOffset: offset });
+ if (r.frames.length === 0 && r.eofOffset < size) {
+ // Bytes at `offset` start no valid frame. An append-only file grows
+ // whole frames sequentially, so a torn tail is always a frame PREFIX
+ // (starts with the magic): retry next time. Anything else means offset
+ // was not a boundary of this file — no valid continuation exists here.
+ const n = Math.min(MAGIC.length, size - offset);
+ const head = readAtSync(fd, offset, n);
+ if (!MAGIC.subarray(0, n).equals(head)) return null;
+ return { offset, appliedFrames: 0 };
+ }
+ for (const f of r.frames) apply(f, fd);
+ return { offset: r.eofOffset, appliedFrames: r.frames.length };
+ } finally {
+ fsSync.closeSync(fd);
+ }
+}
diff --git a/packages/minidb/src/rename-replace.ts b/packages/minidb/src/rename-replace.ts
new file mode 100644
index 0000000000..f3072fd560
--- /dev/null
+++ b/packages/minidb/src/rename-replace.ts
@@ -0,0 +1,36 @@
+// src/rename-replace.ts
+//
+// `fs.rename(src, dst)` with Windows replace-open-destination semantics.
+//
+// Windows, unlike POSIX, refuses to rename over a destination that ANY
+// process still holds open (no delete sharing by default): libuv returns
+// EPERM. Transient openers — co-process readers doing a split-second
+// readFile/stat, antivirus, file indexers — come and go within single-digit
+// milliseconds, so on Windows the caller-side facility retries EPERM with
+// jitter before giving up. POSIX renames over open files directly and never
+// takes the retry path.
+
+import fs from 'node:fs/promises';
+
+const sleep = (ms: number): Promise => new Promise((r) => setTimeout(r, ms));
+
+export interface RenameReplaceOptions {
+ /** Max EPERM retries before the error propagates (Windows only). */
+ retries?: number;
+ /** Base delay between retries in ms (a like-sized random jitter is added). */
+ baseDelayMs?: number;
+}
+
+export async function renameReplace(src: string, dst: string, opts: RenameReplaceOptions = {}): Promise {
+ if (process.platform !== 'win32') return fs.rename(src, dst);
+ const retries = opts.retries ?? 100;
+ const base = opts.baseDelayMs ?? 20;
+ for (let attempt = 0; ; attempt++) {
+ try {
+ return await fs.rename(src, dst);
+ } catch (e) {
+ if ((e as NodeJS.ErrnoException).code !== 'EPERM' || attempt >= retries) throw e;
+ await sleep(base + Math.floor(Math.random() * (base + 10)));
+ }
+ }
+}
diff --git a/packages/minidb/src/server.ts b/packages/minidb/src/server.ts
index 5f00a8ed06..6124f31352 100644
--- a/packages/minidb/src/server.ts
+++ b/packages/minidb/src/server.ts
@@ -42,6 +42,10 @@ class RespParser {
*feed(chunk: Buffer): Generator {
this.buf = this.buf.length ? Buffer.concat([this.buf, chunk]) : chunk;
if (this.buf.length > this.maxBuf) {
+ // Drop the buffered oversized request before reporting: without the
+ // reset every later chunk would fail with the same error and the giant
+ // buffer would be retained for the life of the connection.
+ this.buf = Buffer.alloc(0);
throw new Error(`RESP request too large (>${this.maxBuf} bytes)`);
}
while (this.buf.length) {
@@ -164,21 +168,48 @@ export async function startServer({ dir, port = 6379, host = '127.0.0.1', fsyncP
const db = (await MiniDb.open({ dir, valueCodec: 'string', fsyncPolicy })) as MiniDb;
const server = net.createServer((socket: Socket) => {
const parser = new RespParser();
+ // Serialize per-connection processing: a new chunk's commands are queued
+ // behind the previous chunk's in-flight work, so replies always leave in
+ // request order. Without this, a slow command in one packet (e.g. SET with
+ // fsync 'always') let replies from the next packet overtake it, breaking
+ // pipelined clients.
+ let queue: Promise = Promise.resolve();
+ // A client that resets the connection while a large reply is being written
+ // makes the next write fail with EPIPE/ECONNRESET. Without an 'error'
+ // listener that event becomes an uncaught exception and takes the whole
+ // process down, so swallow it: the connection is dead either way, and the
+ // queued work below skips further writes to it.
+ socket.on('error', () => {});
+ // Never write to a destroyed socket: write-after-destroy would just
+ // surface as another 'error' event on the dead connection.
+ const send = (res: string | Buffer): void => {
+ if (!socket.destroyed) socket.write(res);
+ };
socket.on('data', (chunk: Buffer) => {
- void (async () => {
+ queue = queue.then(async () => {
try {
for (const args of parser.feed(chunk)) {
- const res = await handle(db, args);
+ if (socket.destroyed) return;
+ let res: string | Buffer | null;
+ try {
+ res = await handle(db, args);
+ } catch (e) {
+ // One failing command must not starve the replies of the
+ // commands already parsed from the same chunk.
+ res = reply.err((e as Error).message);
+ }
if (res === null) {
socket.end();
return;
}
- socket.write(res);
+ send(res);
}
} catch (e) {
- socket.write(reply.err((e as Error).message));
+ // Parser-level failure (e.g. oversized request): feed() has already
+ // reset its buffer, so the connection can keep serving new commands.
+ send(reply.err((e as Error).message));
}
- })();
+ });
});
});
diff --git a/packages/minidb/src/store.ts b/packages/minidb/src/store.ts
index ed04eafdaa..1cd8b6682f 100644
--- a/packages/minidb/src/store.ts
+++ b/packages/minidb/src/store.ts
@@ -89,6 +89,12 @@ class MinHeap {
export interface StoreOptions {
activeExpireIntervalMs?: number;
activeExpireMaxPerTick?: number;
+ /** Time budget (ms) per active-expiration tick, used once
+ * `activeExpireMaxPerTick` entries have been reaped: a simultaneously-expired
+ * burst keeps being reaped within this budget, so TTL storms drain far
+ * faster than the fixed per-tick count alone, while the event-loop pause per
+ * tick stays bounded. */
+ activeExpireTimeBudgetMs?: number;
/** Read a value back from disk for disk-backed records. Required whenever a
* StoreRecord may hold a disk ref. */
readValue?: ValueReader;
@@ -106,13 +112,22 @@ export class Store {
/** Approximate bytes held by live + expired-not-yet-reaped records. In
* valueMode:'disk' this counts keys/metadata/refs, not the value bulk. */
bytes = 0;
+ /** Number of records with an expiry set. Enables an O(1) size fast path when
+ * TTL is not in use. */
+ private expiring = 0;
private readonly maxPerTick: number;
+ private readonly expireTimeBudgetMs: number;
+ /** Sticky flag: set when the last tick reaped ≥ maxPerTick (i.e. there is an
+ * expiry backlog), making the next ticks aggressive until the storm drains
+ * — like Redis's aggressive expire cycle. */
+ private expireAggressive = false;
private timer: ReturnType | null = null;
private readonly onExpire?: (key: string, record: StoreRecord) => void;
private readonly readValue?: ValueReader;
constructor(opts: StoreOptions = {}) {
this.maxPerTick = opts.activeExpireMaxPerTick ?? 100;
+ this.expireTimeBudgetMs = opts.activeExpireTimeBudgetMs ?? 2;
this.onExpire = opts.onExpire;
this.readValue = opts.readValue;
const interval = opts.activeExpireIntervalMs ?? 100;
@@ -121,6 +136,8 @@ export class Store {
}
get size(): number {
+ // O(1) fast path: with no TTL ever set, every map entry is live.
+ if (this.expiring === 0) return this.map.size;
// Count only logically-live keys: expired-but-not-yet-reaped entries stay
// in the map until lazy/active expiration removes them, so map.size would
// otherwise over-report.
@@ -172,7 +189,10 @@ export class Store {
const r = this.map.get(k);
const ok = this.map.delete(k);
if (ok) {
- if (r) this.bytes -= Buffer.byteLength(k, 'binary') + this.refBytes(r.ref) + this.metaBytes(r.dt);
+ if (r) {
+ this.bytes -= Buffer.byteLength(k, 'binary') + this.refBytes(r.ref) + this.metaBytes(r.dt);
+ if (r.expireAt) this.expiring--;
+ }
this.order.delete(k, k);
}
return ok;
@@ -190,14 +210,18 @@ export class Store {
setRef(key: string | Buffer, ref: ValueRef, expireAt = 0, dt: Record | null = null): void {
const k = toKStr(key);
- const existed = this.map.has(k);
- if (existed) this.bytes -= this.recordBytes(k);
+ const prev = this.map.get(k);
+ if (prev) {
+ if (prev.expireAt) this.expiring--;
+ this.bytes -= this.recordBytes(k);
+ }
const seq = ++this.seq;
const stored = this.cloneRef(ref);
this.map.set(k, { ref: stored, expireAt: expireAt || 0, seq, dt });
this.bytes += Buffer.byteLength(k, 'binary') + this.refBytes(stored) + this.metaBytes(dt);
- if (!existed) this.order.insert(k, k);
+ if (!prev) this.order.insert(k, k);
if (expireAt) {
+ this.expiring++;
this.heap.push({ t: expireAt, k, seq });
// Overwriting a TTL key leaves a stale heap entry that is only reaped
// when its (possibly far-future) timestamp passes, so the heap can grow
@@ -245,8 +269,18 @@ export class Store {
return this.remove(toKStr(key));
}
+ /** Metadata-only existence check: no value materialization (no buffer copy in
+ * memory mode, no positioned disk read for disk-backed records). Applies the
+ * same lazy-expiration semantics as get(). */
has(key: string | Buffer): boolean {
- return this.get(key) !== undefined;
+ const k = toKStr(key);
+ const r = this.map.get(k);
+ if (!r) return false;
+ if (r.expireAt && r.expireAt <= Date.now()) {
+ this.expireKey(k, r); // lazy expiration, same as get()
+ return false;
+ }
+ return true;
}
*entries(): Generator {
@@ -272,6 +306,16 @@ export class Store {
yield* this.scan({ gte: pk, lt: pk + '\uffff', count: limit });
}
+ /** Ordered scan yielding canonical keys only, without materializing values
+ * (no buffer copies in memory mode, no positioned reads in disk mode).
+ * Expired records are lazily reaped, exactly as in scan(). */
+ *rawKeys(opts: RangeOptions = {}): Generator {
+ for (const n of this.order.range(opts) as Iterable>) {
+ if (!this.getRecord(n.key)) continue;
+ yield n.key;
+ }
+ }
+
/** Rewrite disk-backed value locations after compaction rotates the
* snapshot/WAL files. Memory refs are left untouched. */
remapLocs(remap: (k: string, loc: ValueLoc, rec: StoreRecord) => ValueLoc | undefined): void {
@@ -284,14 +328,27 @@ export class Store {
private activeExpire(): void {
const now = Date.now();
+ // Normal ticks stay within the small budget; a tick that still finds a
+ // full quota of expired keys flips to aggressive mode (larger budget, like
+ // Redis's fast expire cycle) until the storm drains.
+ const deadline = now + (this.expireAggressive ? Math.max(this.expireTimeBudgetMs, 10) : this.expireTimeBudgetMs);
let n = 0;
- while (n++ < this.maxPerTick && this.heap.size && this.heap.peek()!.t <= now) {
+ let reaped = 0;
+ while (this.heap.size && this.heap.peek()!.t <= now) {
+ // Once the guaranteed per-tick quota is reaped, keep draining within the
+ // time budget: a fixed ~1000/s rate let a large simultaneous-expiry storm
+ // (e.g. 100k keys) linger in memory for minutes. The budget bounds the
+ // pause each tick instead of bounding the work.
+ if (n >= this.maxPerTick && (n & 15) === 0 && Date.now() >= deadline) break;
const e = this.heap.pop()!;
const r = this.map.get(e.k);
if (r && r.seq === e.seq && r.expireAt && r.expireAt <= now) {
this.expireKey(e.k, r);
+ reaped++;
}
+ n++;
}
+ this.expireAggressive = reaped >= this.maxPerTick;
}
/** Synchronously reap every expired record. Returns the number removed. */
diff --git a/packages/minidb/src/text-index.ts b/packages/minidb/src/text-index.ts
index d49867f413..bbe59d598f 100644
--- a/packages/minidb/src/text-index.ts
+++ b/packages/minidb/src/text-index.ts
@@ -24,6 +24,12 @@ import type { PostingEntry } from './text-postings.js';
const LATIN = /[a-z0-9]+/g;
const CJK = /[\u3400-\u9fff\u3040-\u30ff\uff00-\uffef]+/g;
+// Postings records store the term length in a uint16. A single document with a
+// longer token previously made every postings rebuild throw after the index had
+// already been cleared, permanently poisoning the index (and compaction). Such
+// tokens can never be real query terms — drop them at tokenization so one
+// pathological document cannot destroy the index.
+const MAX_TERM_CHARS = 0xffff;
/** Tokenize text into terms (lowercased latin words + CJK uni/bigrams). */
export function tokenize(str: unknown): string[] {
@@ -32,7 +38,8 @@ export function tokenize(str: unknown): string[] {
const latin = s.match(LATIN);
// Loop-push instead of `terms.push(...latin)`: spreading a large match array
// (hundreds of thousands of tokens from a big doc) overflows the call stack.
- if (latin) for (const t of latin) terms.push(t);
+ // Latin matches are ASCII, so chars == utf8 bytes for the length guard.
+ if (latin) for (const t of latin) if (t.length <= MAX_TERM_CHARS) terms.push(t);
const runs = s.match(CJK) ?? [];
for (const r of runs) {
for (let i = 0; i < r.length; i++) {
@@ -143,23 +150,24 @@ export class TextIndex {
* Assigns fresh dense docIDs, writes a new postings file (disk mode) or
* replaces the in-memory base (memory mode), and clears the delta +
* tombstones. Called on open and on compaction.
+ *
+ * Atomic on failure: everything is staged off to the side first and swapped
+ * in only after the new postings file is durably renamed (disk mode), so a
+ * failed rebuild (e.g. a transient ENOSPC/EMFILE inside rebuildSync) leaves
+ * the PREVIOUS index fully functional instead of silently emptying it until
+ * the next successful build.
*/
build(entries: Iterable<{ key: string; value: unknown }>): void {
- this.postings.clear();
- this.docLen.clear();
- this.keys.length = 0;
- this.keyToId.clear();
- this.delta.clear();
- this.deltaCount = 0;
- this.removed.clear();
- this.cache.clear();
- this.N = 0;
-
+ // Staged state.
const agg = new Map>(); // term -> (docID -> freq)
+ const newKeys: (string | undefined)[] = []; // docID -> key
+ const newKeyToId = new Map(); // key -> docID
+ const newDocLen = new Map