From fa941b25a6ccf5502905c037c129109b6f4cd29e Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Fri, 31 Jul 2026 15:43:46 +0800 Subject: [PATCH 01/15] perf(minidb): skip idle everysec fsyncs and add lifecycle stats - everysec WAL now fsyncs on the timer only while dirty (tracked by a write/sync generation watermark); close() keeps its unconditional final sync, and background sync failures surface via walFsyncErrors plus a sticky lastWalFsyncError instead of being silently swallowed - add WAL queue/group-commit counters (walQueuedBytes, walMaxQueuedBytes, walGroupCommits, walGroupCommitFrames) and lifecycle phase stats: recovery bytes/frames/duration, index/text rebuild durations, compaction total/snapshot/rotation/postings durations, rotation pause, and query candidates/decoded/sorted rows; add a syncIntervalMs open option threaded through compaction WAL rotation - rewrite the bench on fixed-seed synthetic data with a stable machine-readable JSON report (cold open 10k/50k/100k, word/ngram search, idle-fsync acceptance, 100k compaction, event-loop delay, peak heap/RSS per scenario) and pin the schema in test/bench-json.test.ts; add app-side baselines with loose complexity budgets in sessionIndex and searchService tests - fix ClusterDb lock-pool closeAll() leaking in-flight shard opens and drain the query store's async close on server shutdown, eliminating the ENOTEMPTY directory-teardown race --- .../backends/minidb/miniDbQueryStore.ts | 20 +- .../app/sessionIndex/sessionIndex.test.ts | 64 ++- packages/kap-server/src/start.ts | 9 +- .../test/search/searchService.test.ts | 93 ++++ packages/minidb/bench/bench.ts | 465 +++++++++++++++--- packages/minidb/src/cluster/lock-pool.ts | 9 + packages/minidb/src/compaction.ts | 28 +- packages/minidb/src/index.ts | 80 ++- packages/minidb/src/recovery.ts | 9 + packages/minidb/src/wal.ts | 89 +++- packages/minidb/test/bench-json.test.ts | 99 ++++ packages/minidb/test/cluster/lock.test.ts | 35 ++ packages/minidb/test/stats.test.ts | 130 +++++ packages/minidb/test/wal.test.ts | 128 +++++ 14 files changed, 1175 insertions(+), 83 deletions(-) create mode 100644 packages/minidb/test/bench-json.test.ts diff --git a/packages/agent-core-v2/src/persistence/backends/minidb/miniDbQueryStore.ts b/packages/agent-core-v2/src/persistence/backends/minidb/miniDbQueryStore.ts index 25de5727884..7d3ddf91e89 100644 --- a/packages/agent-core-v2/src/persistence/backends/minidb/miniDbQueryStore.ts +++ b/packages/agent-core-v2/src/persistence/backends/minidb/miniDbQueryStore.ts @@ -81,6 +81,19 @@ function isRebuildable(error: unknown): boolean { return error instanceof SyntaxError || (error as { name?: string }).name === 'CorruptFrameError'; } +/** + * Fire-and-forget close promises produced by DI disposal (which is + * synchronous). The server shutdown path awaits these via + * `drainQueryStoreDisposals()` before the homeDir is released, so a teardown + * `rm()` never races an in-flight ClusterDb open/close (a late shard open + * would recreate db.wal and fail the rm with ENOTEMPTY). + */ +const pendingDisposals = new Set>(); + +export async function drainQueryStoreDisposals(): Promise { + await Promise.all(pendingDisposals); +} + export class MiniDbQueryStore extends Disposable implements IQueryStore { declare readonly _serviceBrand: undefined; @@ -96,7 +109,12 @@ export class MiniDbQueryStore extends Disposable implements IQueryStore { super(); this.dir = join(this.bootstrap.cacheDir, STORE_SUBDIR); this._register(toDisposable(() => { - void this.close(); + // DI disposal is synchronous, but closing a ClusterDb is not: track the + // close module-level so the shutdown path (`drainQueryStoreDisposals`) + // can await it before the homeDir is torn down. + const pending = this.close().catch(() => {}); + pendingDisposals.add(pending); + void pending.finally(() => pendingDisposals.delete(pending)); })); } diff --git a/packages/agent-core-v2/test/app/sessionIndex/sessionIndex.test.ts b/packages/agent-core-v2/test/app/sessionIndex/sessionIndex.test.ts index a0b35cceefd..366c166db06 100644 --- a/packages/agent-core-v2/test/app/sessionIndex/sessionIndex.test.ts +++ b/packages/agent-core-v2/test/app/sessionIndex/sessionIndex.test.ts @@ -17,7 +17,7 @@ import { IBootstrapService } from '#/app/bootstrap/bootstrap'; import { IFlagService } from '#/app/flag/flag'; import { ISessionIndex, type SessionSummary } from '#/app/sessionIndex/sessionIndex'; import { FileSessionIndex } from '#/app/sessionIndex/sessionIndexService'; -import { MiniDbQueryStore } from '#/persistence/backends/minidb/miniDbQueryStore'; +import { drainQueryStoreDisposals, MiniDbQueryStore } from '#/persistence/backends/minidb/miniDbQueryStore'; import { JsonAtomicDocumentStore } from '#/persistence/backends/node-fs/atomicDocumentStore'; import { FileStorageService } from '#/persistence/backends/node-fs/fileStorageService'; import { IAtomicDocumentStore } from '#/persistence/interface/atomicDocumentStore'; @@ -266,6 +266,9 @@ describe('FileSessionIndex (read model)', () => { afterEach(async () => { disposeHost?.(); disposeHost = undefined; + // The host's synchronous dispose() fires the query store's async close; + // await it so the rm below never races an in-flight ClusterDb close. + await drainQueryStoreDisposals(); await fsp.rm(homeDir, { recursive: true, force: true }); }); @@ -461,4 +464,63 @@ describe('FileSessionIndex (read model)', () => { // The lock is warned about once, then the read model stays disabled. expect(warnings).toEqual(['query-store locked by another process; disabling read model']); }); + + // -- stage-1 performance baselines ------------------------------------------ + // Not tight CI thresholds: numbers are logged as JSON for phase-to-phase + // comparison, and only a loose complexity budget is asserted so an + // accidental quadratic regression trips the test anywhere. + + it('baseline: warm list() at 300 vs 1200 sessions stays within a linear budget', async () => { + const store = build(); + // Session ids are enumerated from disk; summaries come from the read + // model. Seed empty session dirs + batch-put the summaries so the list + // path serves fully warm reads. + const seed = async (from: number, to: number): Promise => { + const ops = []; + for (let i = from; i < to; i++) { + await fsp.mkdir(join(sessionsDir, workspaceId, `s${i}`), { recursive: true }); + ops.push({ + kind: 'put' as const, + collection: SESSION_COLLECTION, + key: `s${i}`, + value: summary(`s${i}`, { title: `session ${i}`, createdAt: i, updatedAt: i }), + }); + } + await queryStore.batch(ops); + }; + const medianListMs = async (): Promise => { + const runs: number[] = []; + for (let r = 0; r < 5; r++) { + const t0 = performance.now(); + const page = await store.list({ workspaceIds: [workspaceId], limit: 50 }); + expect(page.items.length).toBe(50); + runs.push(performance.now() - t0); + } + runs.sort((a, b) => a - b); + return runs[(runs.length / 2) | 0]!; + }; + + await seed(0, 300); + const small = await medianListMs(); + await seed(300, 1200); + const large = await medianListMs(); + console.log( + `[baseline] sessionIndex warm list ${JSON.stringify({ sessions: [300, 1200], medianMs: [small, large] })}`, + ); + // 4x the data must cost well under 10x the time (a linear top page is ~4x). + expect(large).toBeLessThan(small * 10 + 100); + }, 60_000); + + it('baseline: cold backfill over 200 session dirs', async () => { + for (let i = 0; i < 200; i++) { + await seedSession(`s${i}`, { title: `session ${i}`, createdAt: i, updatedAt: i }); + } + const store = build(); + const t0 = performance.now(); + const page = await store.list({ workspaceIds: [workspaceId], limit: 50 }); + const ms = performance.now() - t0; + expect(page.items.length).toBe(50); + expect(page.items[0]?.id).toBe('s199'); + console.log(`[baseline] sessionIndex cold backfill ${JSON.stringify({ sessions: 200, ms })}`); + }); }); diff --git a/packages/kap-server/src/start.ts b/packages/kap-server/src/start.ts index 43da3353538..4e42774f299 100644 --- a/packages/kap-server/src/start.ts +++ b/packages/kap-server/src/start.ts @@ -9,6 +9,7 @@ import { bootstrap, + drainQueryStoreDisposals, IConfigService, IEventService, IProviderDiscoveryService, @@ -367,10 +368,12 @@ export async function startServer(opts: ServerStartOptions): Promise { }); }); }); + +// --------------------------------------------------------------------------- +// stage-1 performance baseline over a synthetic corpus +// --------------------------------------------------------------------------- +// Numbers are logged as JSON for phase-to-phase comparison; only a loose +// complexity budget is asserted (no tight absolute millisecond thresholds in +// shared CI), so an accidental quadratic regression trips the test anywhere. + +describe('baseline: synthetic corpus', () => { + let home: string | undefined; + const services: GlobalSearchService[] = []; + + beforeEach(async () => { + home = await mkdtemp(join(tmpdir(), 'kimi-kap-search-baseline-')); + }); + + afterEach(async () => { + for (const service of services.splice(0)) service.dispose(); + await drainGlobalSearchDisposals(); + if (home !== undefined) { + await rm(home, { recursive: true, force: true }); + home = undefined; + } + }); + + const TOPICS = ['compaction', 'walrus', 'snapshot', 'recovery', '索引', '持久化']; + + async function writeCorpus(from: number, to: number): Promise { + const summaries: SessionSummary[] = []; + for (let i = from; i < to; i++) { + const id = `s${i}`; + summaries.push(summary(id, `session ${i} 索引讨论`, T1 + i)); + const lines: string[] = []; + for (let j = 0; j < 8; j++) { + lines.push(userLine(`session ${i} message ${j} about ${TOPICS[(i + j) % TOPICS.length]!}`, T1 + i * 100 + j)); + lines.push(assistantLine(`reply ${j} covering ${TOPICS[(i + 2 * j) % TOPICS.length]!}`, T1 + i * 100 + j + 1)); + } + await writeWire(home!, id, 'main', lines); + } + return summaries; + } + + async function medianMs(fn: () => Promise, runs = 5): Promise { + const times: number[] = []; + for (let r = 0; r < runs; r++) { + const t0 = performance.now(); + await fn(); + times.push(performance.now() - t0); + } + times.sort((a, b) => a - b); + return times[(times.length / 2) | 0]!; + } + + it('indexing and search latency scale within a linear budget from 100 to 400 sessions', async () => { + // The stub holds the array by reference, so the second reindex sees the + // sessions appended after the first measurement. + const all: SessionSummary[] = []; + const service = makeService(home!, staticIndex(all)); + services.push(service); + + all.push(...(await writeCorpus(0, 100))); + const t0 = performance.now(); + await service.reindex(); + const index100 = performance.now() - t0; + const terms100 = await medianMs(() => service.search({ query: 'compaction' })); + const literal100 = await medianMs(() => service.search({ query: 'message 3 about', mode: 'literal' })); + + all.push(...(await writeCorpus(100, 400))); + const t1 = performance.now(); + await service.reindex(); + const index400 = performance.now() - t1; + const terms400 = await medianMs(() => service.search({ query: 'compaction' })); + const literal400 = await medianMs(() => service.search({ query: 'message 3 about', mode: 'literal' })); + + // Sanity: the corpus really grew and both modes still hit. + const hits = await service.search({ query: 'compaction' }); + expect(hits.items.length).toBeGreaterThan(0); + expect((await service.search({ query: 'message 3 about', mode: 'literal' })).items.length).toBeGreaterThan(0); + + console.log( + `[baseline] searchService ${JSON.stringify({ + sessions: [100, 400], + reindexMs: [index100, index400], + termsMedianMs: [terms100, terms400], + literalMedianMs: [literal100, literal400], + })}`, + ); + // 4x the data must cost well under 10x the time at each step. + expect(index400).toBeLessThan(index100 * 10 + 2000); + expect(terms400).toBeLessThan(terms100 * 10 + 100); + expect(literal400).toBeLessThan(literal100 * 10 + 100); + }, 120_000); +}); diff --git a/packages/minidb/bench/bench.ts b/packages/minidb/bench/bench.ts index 918b05db956..ac048ff4bf0 100644 --- a/packages/minidb/bench/bench.ts +++ b/packages/minidb/bench/bench.ts @@ -1,126 +1,469 @@ // bench/bench.js // -// Throughput / latency micro-benchmarks for MiniDb. +// Throughput / latency / lifecycle benchmarks for MiniDb, on fixed-seed +// synthetic data. Every scenario reports wall time, event-loop-delay and peak +// heap/RSS, and the whole run is emitted as machine-readable JSON (stable +// field names, `schemaVersion: 1`) so later phases can diff before/after. // -// Run: npm run bench (or: node bench/bench.js) +// Run: npm run bench (human output + JSON on stdout) +// node --import tsx bench/bench.ts --json .tmp/bench.json +// node --import tsx bench/bench.ts --quick (small sizes, for tests) +// +// Knobs (env): N, NSMALL (throughput sizes), BENCH_SEED, BENCH_IDLE_MS. import fs from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; +import { monitorEventLoopDelay } from 'node:perf_hooks'; import { MiniDb } from '../src/index.js'; const fmt = (n) => n.toLocaleString('en-US', { maximumFractionDigits: 0 }); const ops = (n, ms) => `${fmt((n / ms) * 1000)} ops/s`; +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); async function tmpDir() { return fs.mkdtemp(path.join(os.tmpdir(), 'minidb-bench-')); } -async function bench(label, fn) { - // warm-up + a couple of GCs if available - if (global.gc) { - global.gc(); +// ---- fixed-seed synthetic data ---------------------------------------------- + +/** mulberry32: tiny deterministic PRNG so every bench run sees the same data. */ +function mulberry32(seed) { + let a = seed >>> 0; + return () => { + a = (a + 0x6d2b79f5) | 0; + let t = Math.imul(a ^ (a >>> 15), 1 | a); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +const LATIN_VOCAB = + 'wal sync snapshot compaction recovery index query cache buffer frame codec store delta merge rotate flush token parse schema server client socket thread worker queue stream ledger journal cursor segment batch commit'.split( + ' ', + ); +const CJK_VOCAB = ['持久化', '快照', '索引', '恢复', '压缩', '查询', '缓存', '日志', '事务', '复制']; +// Needles planted at deterministic intervals so query hit counts are stable. +const NEEDLES = [ + { term: 'walrus', every: 97 }, + { term: '持久化', every: 131 }, + { term: 'checkpoint', every: 257 }, +]; + +/** Deterministic pseudo-message corpus: `count` docs of ~25 words each. */ +function makeMessages(count, seed) { + const rng = mulberry32(seed); + const pick = (arr) => arr[(rng() * arr.length) | 0]; + const docs = []; + for (let i = 0; i < count; i++) { + const words = []; + const n = 20 + ((rng() * 15) | 0); + for (let w = 0; w < n; w++) words.push(rng() < 0.15 ? pick(CJK_VOCAB) : pick(LATIN_VOCAB)); + for (const { term, every } of NEEDLES) if (i % every === 0) words.push(term); + docs.push({ key: `m${i}`, body: words.join(' '), ts: 1_700_000_000_000 + i * 1000 }); } - const t0 = performance.now(); - await fn(); - const ms = performance.now() - t0; - console.log(` ${label.padEnd(46)} ${ms.toFixed(1).padStart(8)} ms`); - return ms; + return docs; } -async function main() { - const VALUE = 'x'.repeat(100); // 100-byte values - const N = Number(process.env.N || 200_000); - const NSMALL = Number(process.env.NSMALL || 3_000); +// ---- measurement machinery ---------------------------------------------------- + +const MIB = 1024 * 1024; + +function percentileOf(sorted, p) { + if (sorted.length === 0) return 0; + const idx = Math.min(sorted.length - 1, Math.ceil((p / 100) * sorted.length) - 1); + return sorted[Math.max(0, idx)]; +} + +function latencySummary(samples) { + if (!samples || samples.length === 0) return undefined; + const sorted = [...samples].sort((a, b) => a - b); + return { + p50: percentileOf(sorted, 50), + p95: percentileOf(sorted, 95), + p99: percentileOf(sorted, 99), + max: sorted[sorted.length - 1], + }; +} + +/** Sample process.memoryUsage() on a timer and keep the peaks. */ +class MemSampler { + constructor() { + this.peakRssBytes = 0; + this.peakHeapUsedBytes = 0; + this.timer = null; + } + start() { + const sample = () => { + const mu = process.memoryUsage(); + if (mu.rss > this.peakRssBytes) this.peakRssBytes = mu.rss; + if (mu.heapUsed > this.peakHeapUsedBytes) this.peakHeapUsedBytes = mu.heapUsed; + }; + sample(); + this.timer = setInterval(sample, 25); + this.timer.unref?.(); + } + stop() { + if (this.timer) clearInterval(this.timer); + this.timer = null; + return { peakRssBytes: this.peakRssBytes, peakHeapUsedBytes: this.peakHeapUsedBytes }; + } +} + +const results = []; + +/** Histogram means are NaN when no sample was recorded (a scenario that never + * yielded the event loop); normalize so the JSON stays numeric. */ +const finite = (n) => (Number.isFinite(n) ? Math.round(n * 1000) / 1000 : 0); - console.log(`\nminidb benchmark (N=${fmt(N)}, value=${VALUE.length}B, node ${process.version})\n`); +/** Run one scenario: time it, capture event-loop delay + memory peaks, record + * a stable-shaped JSON row, and print the human line. */ +async function scenario(name, fn, { ops: opCount, extra } = {}) { + if (global.gc) global.gc(); + const eld = monitorEventLoopDelay(); + const mem = new MemSampler(); + const latencies = []; + eld.enable(); + mem.start(); + const t0 = performance.now(); + const out = (await fn({ lat: (ms) => latencies.push(ms) })) || {}; + const durationMs = performance.now() - t0; + const memPeaks = mem.stop(); + eld.disable(); + const row = { + name, + durationMs: Math.round(durationMs * 1000) / 1000, + ops: opCount, + opsPerSec: opCount ? Math.round((opCount / durationMs) * 1000) : undefined, + latencyMs: latencySummary(latencies), + eventLoopDelayMs: { + mean: finite(eld.mean / 1e6), + p50: finite(eld.percentile(50) / 1e6), + p95: finite(eld.percentile(95) / 1e6), + p99: finite(eld.percentile(99) / 1e6), + max: finite(eld.max / 1e6), + }, + peakRssBytes: memPeaks.peakRssBytes, + peakHeapUsedBytes: memPeaks.peakHeapUsedBytes, + extra: { ...extra, ...out.extra }, + }; + results.push(row); + const tail = opCount ? ` -> ${ops(opCount, durationMs)}` : ''; + console.log( + ` ${name.padEnd(46)} ${durationMs.toFixed(1).padStart(9)} ms${tail}` + + ` [eld p95 ${row.eventLoopDelayMs.p95.toFixed(1)} ms, peak rss ${(row.peakRssBytes / MIB).toFixed(0)} MiB]`, + ); + return row; +} - // --- baseline: raw JS Map ---------------------------------------------- +// ---- scenarios ------------------------------------------------------------------ + +async function throughputScenarios({ N, NSMALL, VALUE }) { + // --- baseline: raw JS Map --- { const m = new Map(); - const ms = await bench('baseline: raw Map set (in-memory)', () => { - for (let i = 0; i < N; i++) m.set(`k${i}`, VALUE); - }); - console.log(` -> ${ops(N, ms)}`); - const ms2 = await bench('baseline: raw Map get (in-memory)', () => { - let s = 0; - for (let i = 0; i < N; i++) if (m.get(`k${i}`)) s++; - return s; - }); - console.log(` -> ${ops(N, ms2)}`); + await scenario( + 'baseline: raw Map set (in-memory)', + () => { + for (let i = 0; i < N; i++) m.set(`k${i}`, VALUE); + }, + { ops: N }, + ); + await scenario( + 'baseline: raw Map get (in-memory)', + () => { + let s = 0; + for (let i = 0; i < N; i++) if (m.get(`k${i}`)) s++; + return s; + }, + { ops: N }, + ); } - // --- DB writes, fsyncPolicy = no (fastest on-disk path) ----------------- + // --- DB writes, fsyncPolicy = no --- { const dir = await tmpDir(); const db = await MiniDb.open({ dir, valueCodec: 'string', fsyncPolicy: 'no', autoCompact: false }); - const ms = await bench('DB set concurrent, fsync=no (group commit)', async () => { - const p = []; - for (let i = 0; i < N; i++) p.push(db.set(`k${i}`, VALUE)); - await Promise.all(p); - }); - console.log(` -> ${ops(N, ms)}`); + await scenario( + 'DB set concurrent, fsync=no (group commit)', + async () => { + const p = []; + for (let i = 0; i < N; i++) p.push(db.set(`k${i}`, VALUE)); + await Promise.all(p); + return { + extra: { + walGroupCommits: db.stats.walGroupCommits, + walGroupCommitFrames: db.stats.walGroupCommitFrames, + walMaxQueuedBytes: db.stats.walMaxQueuedBytes, + }, + }; + }, + { ops: N }, + ); await db.close(); await fs.rm(dir, { recursive: true, force: true }); } - // --- DB writes, fsyncPolicy = everysec --------------------------------- + // --- DB writes, fsyncPolicy = everysec --- { const dir = await tmpDir(); const db = await MiniDb.open({ dir, valueCodec: 'string', fsyncPolicy: 'everysec', autoCompact: false }); - const ms = await bench('DB set concurrent, fsync=everysec', async () => { - const p = []; - for (let i = 0; i < N; i++) p.push(db.set(`k${i}`, VALUE)); - await Promise.all(p); - }); - console.log(` -> ${ops(N, ms)}`); + await scenario( + 'DB set concurrent, fsync=everysec', + async () => { + const p = []; + for (let i = 0; i < N; i++) p.push(db.set(`k${i}`, VALUE)); + await Promise.all(p); + }, + { ops: N }, + ); await db.close(); await fs.rm(dir, { recursive: true, force: true }); } - // --- DB writes, sequential await-each, fsync=always (worst case) ------- + // --- DB writes, sequential await-each, fsync=always (worst case) --- { const dir = await tmpDir(); const db = await MiniDb.open({ dir, valueCodec: 'string', fsyncPolicy: 'always', autoCompact: false }); - const ms = await bench(`DB set sequential, fsync=always (N=${fmt(NSMALL)})`, async () => { - for (let i = 0; i < NSMALL; i++) await db.set(`k${i}`, VALUE); - }); - console.log(` -> ${ops(NSMALL, ms)}`); + await scenario( + `DB set sequential, fsync=always (N=${fmt(NSMALL)})`, + async ({ lat }) => { + for (let i = 0; i < NSMALL; i++) { + const t = performance.now(); + await db.set(`k${i}`, VALUE); + lat(performance.now() - t); + } + }, + { ops: NSMALL }, + ); await db.close(); await fs.rm(dir, { recursive: true, force: true }); } - // --- DB reads (in-memory) ---------------------------------------------- + // --- DB reads (in-memory) --- { const dir = await tmpDir(); const db = await MiniDb.open({ dir, valueCodec: 'string', fsyncPolicy: 'no', autoCompact: false }); const p = []; for (let i = 0; i < N; i++) p.push(db.set(`k${i}`, VALUE)); await Promise.all(p); - const ms = await bench('DB get (in-memory, after load)', () => { - let s = 0; - for (let i = 0; i < N; i++) if (db.get(`k${i}`)) s++; - return s; - }); - console.log(` -> ${ops(N, ms)}`); + await scenario( + 'DB get (in-memory, after load)', + ({ lat }) => { + let s = 0; + for (let i = 0; i < N; i++) { + const t = performance.now(); + if (db.get(`k${i}`)) s++; + lat(performance.now() - t); + } + return s; + }, + { ops: N }, + ); await db.close(); await fs.rm(dir, { recursive: true, force: true }); } +} + +/** Populate a db with `count` string keys via large batch frames (fast prep), + * close it, then measure a cold open and (for the largest size) a compaction. */ +async function coldOpenScenarios({ sizes, seed, VALUE }) { + for (const count of sizes) { + const dir = await tmpDir(); + await scenario( + `populate ${fmt(count)} keys (batch frames)`, + async () => { + const db = await MiniDb.open({ dir, valueCodec: 'string', fsyncPolicy: 'no', autoCompact: false }); + const CHUNK = 2000; + for (let base = 0; base < count; base += CHUNK) { + const ops = []; + for (let i = base; i < Math.min(base + CHUNK, count); i++) ops.push({ op: 'set', key: `k${i}`, value: VALUE }); + await db.batch(ops); + } + await db.close(); + }, + { ops: count }, + ); - // --- compaction -------------------------------------------------------- + await scenario( + `cold open ${fmt(count)} keys`, + async () => { + const db = await MiniDb.open({ dir, valueCodec: 'string', fsyncPolicy: 'everysec', autoCompact: false }); + const s = db.stats; + await db.close(); + return { + extra: { + keys: count, + recoveryBytes: s.recoveryBytes, + recoveryFrames: s.recoveryFrames, + recoveryDurationMs: s.recoveryDurationMs, + indexRebuildDurationMs: s.indexRebuildDurationMs, + textRebuildDurationMs: s.textRebuildDurationMs, + walFsyncs: s.walFsyncs, + }, + }; + }, + { ops: count }, + ); + + // Compaction of the largest populated set doubles as the plan's + // "100k records compaction" scenario. + if (count === Math.max(...sizes)) { + await scenario( + `compact ${fmt(count)} records`, + async () => { + const db = await MiniDb.open({ dir, valueCodec: 'string', fsyncPolicy: 'no', autoCompact: false }); + await db.compact(); + const s = db.stats; + await db.close(); + return { + extra: { + keys: count, + compactionDurationMs: s.compactionDurationMs, + compactionSnapshotDurationMs: s.compactionSnapshotDurationMs, + compactionRotationDurationMs: s.compactionRotationDurationMs, + compactionPostingsDurationMs: s.compactionPostingsDurationMs, + snapshotBytesWritten: s.snapshotBytesWritten, + }, + }; + }, + { ops: count }, + ); + } + await fs.rm(dir, { recursive: true, force: true }); + } +} + +/** Word (default tokenizer) and n-gram searches over the seeded message corpus. */ +async function searchScenarios({ sizes, seed }) { + const WORD_QUERIES = ['walrus', '持久化', 'wal snapshot', 'nonexistentxyz123']; + const NGRAM_QUERIES = ['walru', '持久', 'heckpo']; + const RUNS = 7; + for (const count of sizes) { + const dir = await tmpDir(); + const docs = makeMessages(count, seed); + const db = await MiniDb.open({ dir, valueCodec: 'json', fsyncPolicy: 'no', autoCompact: false }); + await scenario( + `build ${fmt(count)} messages + word/ngram indexes`, + async () => { + const CHUNK = 1000; + for (let base = 0; base < docs.length; base += CHUNK) { + await db.batch(docs.slice(base, base + CHUNK).map((d) => ({ op: 'set', key: d.key, value: d }))); + } + await db.createTextIndex('word', { fields: ['body'] }); + await db.createTextIndex('ngram', { fields: ['body'], tokenizer: 'ngram' }); + }, + { ops: count, extra: { docs: count } }, + ); + + for (const [index, queries] of [ + ['word', WORD_QUERIES], + ['ngram', NGRAM_QUERIES], + ]) { + await scenario( + `search ${index} over ${fmt(count)} messages`, + ({ lat }) => { + const perQuery = []; + for (const q of queries) { + let hits = 0; + const times = []; + for (let r = 0; r < RUNS; r++) { + const t = performance.now(); + hits = db.search(index, q, { limit: 10 }).length; + const ms = performance.now() - t; + lat(ms); + times.push(ms); + } + times.sort((a, b) => a - b); + perQuery.push({ q, hits, medianMs: Math.round(times[(times.length / 2) | 0] * 1000) / 1000 }); + } + return { extra: { docs: count, runs: RUNS, queries: perQuery } }; + }, + { ops: queries.length * RUNS }, + ); + } + await db.close(); + await fs.rm(dir, { recursive: true, force: true }); + } +} + +/** The phase-1 acceptance scenario: an everysec db idles with ZERO background + * fsyncs, and a single write re-arms exactly one sync before it goes quiet. */ +async function walIdleScenarios({ idleMs }) { { const dir = await tmpDir(); - const db = await MiniDb.open({ dir, valueCodec: 'string', fsyncPolicy: 'no', autoCompact: false }); - const p = []; - for (let i = 0; i < N; i++) p.push(db.set(`k${i}`, VALUE)); - await Promise.all(p); - const ms = await bench(`compact snapshot of ${fmt(N)} keys`, () => db.compact()); - const snap = await fs.stat(path.join(dir, 'db.snapshot')); - console.log(` -> ${(snap.size / 1024 / 1024).toFixed(2)} MiB snapshot in ${ms.toFixed(0)} ms`); + const db = await MiniDb.open({ dir, valueCodec: 'string', fsyncPolicy: 'everysec', autoCompact: false }); + const before = db.stats.walFsyncs; + await scenario( + `idle everysec ${idleMs / 1000}s: background fsyncs`, + async () => { + await sleep(idleMs); + return { extra: { idleMs, walFsyncs: db.stats.walFsyncs - before, walFsyncErrors: db.stats.walFsyncErrors } }; + }, + ); + await db.close(); + await fs.rm(dir, { recursive: true, force: true }); + } + { + const dir = await tmpDir(); + // A short interval keeps the re-arm behavior visible in any mode: the + // write dirties the WAL, the next tick syncs once, then it goes quiet. + const db = await MiniDb.open({ dir, valueCodec: 'string', fsyncPolicy: 'everysec', syncIntervalMs: 100, autoCompact: false }); + await db.set('k', 'v'); + const before = db.stats.walFsyncs; + const windowMs = 500; + await scenario('write then idle: fsyncs in the dirty window', async () => { + await sleep(windowMs); + return { extra: { syncIntervalMs: 100, idleMs: windowMs, walFsyncs: db.stats.walFsyncs - before } }; + }); await db.close(); await fs.rm(dir, { recursive: true, force: true }); } +} +async function main() { + const argv = process.argv.slice(2); + const jsonIdx = argv.indexOf('--json'); + const jsonPath = jsonIdx !== -1 ? argv[jsonIdx + 1] : process.env.BENCH_JSON; + const quick = argv.includes('--quick') || process.env.BENCH_QUICK === '1'; + + const SEED = Number(process.env.BENCH_SEED || 42); + const VALUE = 'x'.repeat(100); // 100-byte values + const N = quick ? 5_000 : Number(process.env.N || 200_000); + const NSMALL = quick ? 300 : Number(process.env.NSMALL || 3_000); + const COLD_OPEN_SIZES = quick ? [1_000, 2_000] : [10_000, 50_000, 100_000]; + const SEARCH_SIZES = quick ? [1_000, 2_000] : [10_000, 100_000]; + const IDLE_MS = quick ? 400 : Number(process.env.BENCH_IDLE_MS || 10_000); + + console.log( + `\nminidb benchmark (N=${fmt(N)}, value=${VALUE.length}B, seed=${SEED}${quick ? ', QUICK' : ''}, node ${process.version})\n`, + ); + + await throughputScenarios({ N, NSMALL, VALUE }); + await coldOpenScenarios({ sizes: COLD_OPEN_SIZES, seed: SEED, VALUE }); + await searchScenarios({ sizes: SEARCH_SIZES, seed: SEED }); + await walIdleScenarios({ idleMs: IDLE_MS }); + + const report = { + schemaVersion: 1, + tool: 'minidb/bench', + quick, + startedAt: new Date().toISOString(), + node: process.version, + platform: process.platform, + arch: process.arch, + seed: SEED, + scenarios: results, + }; + const json = JSON.stringify(report, null, 2); + if (jsonPath) { + await fs.mkdir(path.dirname(path.resolve(jsonPath)), { recursive: true }); + await fs.writeFile(jsonPath, json + '\n', 'utf8'); + console.log(`\nJSON report written to ${jsonPath}`); + } else { + console.log('\n--- bench JSON ---'); + console.log(json); + } console.log('\ndone.\n'); } diff --git a/packages/minidb/src/cluster/lock-pool.ts b/packages/minidb/src/cluster/lock-pool.ts index 236046362e2..2a6aa3ccce5 100644 --- a/packages/minidb/src/cluster/lock-pool.ts +++ b/packages/minidb/src/cluster/lock-pool.ts @@ -173,6 +173,15 @@ export class ShardLockPool { async closeAll(): Promise { if (this.closed) return; this.closed = true; + // Opens already in flight are not in writers/readers yet: wait for every + // one of them to settle FIRST, so their entries land in the maps below and + // their handles get closed too. Without this a late open would outlive + // closeAll — holding its lock and recreating db.wal/lock files after the + // owner had already started tearing the directory down. New opens cannot + // start meanwhile: withWriter/withReader throw on this.closed. + for (const opening of [...this.openingWriters.values(), ...this.openingReaders.values()]) { + await opening.catch(() => {}); + } const writers = [...this.writers.values()]; const readers = [...this.readers.values()]; this.writers.clear(); diff --git a/packages/minidb/src/compaction.ts b/packages/minidb/src/compaction.ts index 9b09ef14843..73ff64ead61 100644 --- a/packages/minidb/src/compaction.ts +++ b/packages/minidb/src/compaction.ts @@ -51,13 +51,15 @@ 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'; +import type { FsyncPolicy, WalStats } from './wal.js'; /** Structural interface of the bits compaction needs from a MiniDb. */ export interface CompactionTarget { dir: string; walPath: string; fsyncPolicy: FsyncPolicy; + /** Background-sync interval the replacement WALs inherit (see WALOptions). */ + syncIntervalMs?: number; store: Store; wal: WAL; compactThresholdBytes: number; @@ -67,7 +69,16 @@ 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; compactErrors?: number }; + stats: WalStats & { + compactions: number; + snapshotBytesWritten: number; + compactErrors?: number; + /** Cumulative phase timings (wall-clock ms). Optional so structural test + * doubles need not carry them; MiniDb always provides them. */ + compactionDurationMs?: number; + compactionSnapshotDurationMs?: number; + compactionRotationDurationMs?: number; + }; /** Reader for disk-backed values; reopened after snapshot/WAL rotation so * remapped value pointers read from the new files. On Windows it is also * closed before the rotation renames (see rotateReplace). */ @@ -158,12 +169,14 @@ export async function compact(db: CompactionTarget): Promise { db.compacting = true; db._compactDone = (async () => { + const t0 = performance.now(); 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. await db.onCompacted?.(); db.stats.compactions++; + db.stats.compactionDurationMs = (db.stats.compactionDurationMs ?? 0) + (performance.now() - t0); db.lastCompactError = null; } catch (err) { db.stats.compactErrors = (db.stats.compactErrors ?? 0) + 1; @@ -191,8 +204,10 @@ async function runCompaction(db: CompactionTarget): Promise { // Phase 2: snapshot. NON-BLOCKING — writers keep appending to the WAL and // mutating the store while we iterate. Fuzziness is repaired by the tail. + const snapT0 = performance.now(); const snapRes = await writeSnapshot(db.store, tmp); db.stats.snapshotBytesWritten += snapRes.bytes; + db.stats.compactionSnapshotDurationMs = (db.stats.compactionSnapshotDurationMs ?? 0) + (performance.now() - snapT0); // Phase 2.5: pre-copy the post-fence WAL tail into db.wal.tmp. NON-BLOCKING. // Each pass flushes to get a stable `head`, then copies the bytes that landed @@ -246,6 +261,7 @@ async function runCompaction(db: CompactionTarget): Promise { db._rotateLock = new Promise((resolve) => { releaseRotation = resolve; }); + const rotateT0 = performance.now(); let rotated = false; let remapped = false; // Remap disk-backed value pointers to the new snapshot/WAL files. Guarded @@ -290,7 +306,7 @@ async function runCompaction(db: CompactionTarget): Promise { rotated = true; await fsyncDir(db.dir); - const fresh = new WAL(db.walPath, { fsyncPolicy: db.fsyncPolicy, stats: db.stats }); + const fresh = new WAL(db.walPath, { fsyncPolicy: db.fsyncPolicy, syncIntervalMs: db.syncIntervalMs, stats: db.stats }); db.wal = fresh; await fresh.open(); @@ -305,7 +321,7 @@ async function runCompaction(db: CompactionTarget): Promise { // 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 }); + const fresh = new WAL(db.walPath, { fsyncPolicy: db.fsyncPolicy, syncIntervalMs: db.syncIntervalMs, stats: db.stats }); await fresh.open(); db.wal = fresh; if (rotated) { @@ -319,5 +335,9 @@ async function runCompaction(db: CompactionTarget): Promise { } finally { releaseRotation(); db._rotateLock = null; + // Wall time of the rotation critical section — the window writers were + // parked (their per-op waits accumulate separately in MiniDb's + // compactionRotationPauseMs). + db.stats.compactionRotationDurationMs = (db.stats.compactionRotationDurationMs ?? 0) + (performance.now() - rotateT0); } } diff --git a/packages/minidb/src/index.ts b/packages/minidb/src/index.ts index 53f91e69ae5..ac77d865d13 100644 --- a/packages/minidb/src/index.ts +++ b/packages/minidb/src/index.ts @@ -145,6 +145,8 @@ export interface OpenOptions { dir: string; valueCodec?: ValueCodecName; fsyncPolicy?: FsyncPolicy; + /** Background-sync interval for fsyncPolicy 'everysec' (default 1000 ms). */ + syncIntervalMs?: number; compactThresholdBytes?: number; autoCompact?: boolean; activeExpireIntervalMs?: number; @@ -251,6 +253,7 @@ export class MiniDb { private codec!: ValueCodec; private codecName: ValueCodecName = 'buffer'; fsyncPolicy: FsyncPolicy = 'everysec'; + syncIntervalMs = 1000; private closed = false; recoveryInfo: RecoveryInfo | null = null; /** Continuation watermark for catchUpFromWal: the WAL inode + applied @@ -278,10 +281,45 @@ export class MiniDb { compactErrors: 0, walBytesWritten: 0, walFsyncs: 0, + /** Failed fsync attempts; a background everysec failure never rejects a + * write — it surfaces only here and in lastWalFsyncError. */ + walFsyncErrors: 0, + /** Sticky copy of the most recent fsync failure (never cleared). */ + lastWalFsyncError: null as unknown, + /** Bytes currently queued in the live WAL's in-memory append buffer. */ + walQueuedBytes: 0, + /** High-water mark of walQueuedBytes. */ + walMaxQueuedBytes: 0, + /** WAL group commits (one per flushed batch) and the frames they carried. */ + walGroupCommits: 0, + walGroupCommitFrames: 0, snapshotBytesWritten: 0, evictions: 0, maxMemoryRejections: 0, queryIndexHits: 0, + // ---- lifecycle phase metrics (cumulative wall-clock ms / counts) ---- + /** Bytes and frames recovery scanned at open (snapshot + WAL). */ + recoveryBytes: 0, + recoveryFrames: 0, + recoveryDurationMs: 0, + /** Open-time derived-index rebuilds (secondary + dt + compound). */ + indexRebuildDurationMs: 0, + /** Text-index (re)builds: at open and after each compaction. */ + textRebuildDurationMs: 0, + /** Whole successful compactions, hook included. */ + compactionDurationMs: 0, + /** The non-blocking snapshot phase of compaction. */ + compactionSnapshotDurationMs: 0, + /** The rotation critical section of compaction (writes park meanwhile). */ + compactionRotationDurationMs: 0, + /** Text-postings rebuild after a compaction rotation. */ + compactionPostingsDurationMs: 0, + /** Cumulative time write ops spent parked on a compaction rotation. */ + compactionRotationPauseMs: 0, + /** Candidate keys iterated / values decoded / rows fed to a sort in query(). */ + queryCandidates: 0, + queryDecoded: 0, + querySortedRows: 0, }; /** Hook called by compaction after the store snapshot + WAL are rotated, so @@ -289,7 +327,11 @@ export class MiniDb { * live set. Structural part of the CompactionTarget interface; the * compaction awaits it, so it may be sync or async. */ onCompacted: () => void | Promise = async (): Promise => { + const t0 = performance.now(); await this.rebuildTextPostings(); + const ms = performance.now() - t0; + this.stats.compactionPostingsDurationMs += ms; + this.stats.textRebuildDurationMs += ms; }; static async open(opts: OpenOptions): Promise> { @@ -301,6 +343,7 @@ export class MiniDb { db.textIndexPath = path.join(db.dir, 'db.textindexes.json'); db.compoundIndexPath = path.join(db.dir, 'db.compound-indexes.json'); db.fsyncPolicy = opts.fsyncPolicy ?? 'everysec'; + db.syncIntervalMs = opts.syncIntervalMs ?? 1000; db.codecName = opts.valueCodec ?? 'buffer'; db.codec = CODECS[db.codecName] as ValueCodec; const valueMode: ValueModeSetting = opts.valueMode ?? 'memory'; @@ -364,13 +407,14 @@ export class MiniDb { }, }); try { - db.wal = new WAL(db.walPath, { fsyncPolicy: db.fsyncPolicy, stats: db.stats }); + db.wal = new WAL(db.walPath, { fsyncPolicy: db.fsyncPolicy, syncIntervalMs: db.syncIntervalMs, stats: db.stats }); // 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(); + const recT0 = performance.now(); db.recoveryInfo = await recover({ dir: db.dir, store: db.store, @@ -378,6 +422,9 @@ export class MiniDb { truncate: !db.readOnly, valueMode: db.valueMode, }); + db.stats.recoveryDurationMs += performance.now() - recT0; + db.stats.recoveryBytes += db.recoveryInfo.snapshotBytes + db.recoveryInfo.walBytes; + db.stats.recoveryFrames += db.recoveryInfo.snapshotFrames + db.recoveryInfo.walFrames; // 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. @@ -513,10 +560,14 @@ export class MiniDb { } private async rebuildAllIndexes(): Promise { + const t0 = performance.now(); this.indexes.rebuild(this._liveRecordsRaw()); this.dt.rebuild([...this.liveRecords()].map(({ key, dt }) => ({ key: this.pk(key), dt }))); this.compound.rebuild(this.liveRecords()); + this.stats.indexRebuildDurationMs += performance.now() - t0; + const t1 = performance.now(); for (const [, ti] of this.text) await ti.build(this.textRecords()); + this.stats.textRebuildDurationMs += performance.now() - t1; } private *_liveRecordsRaw(): Generator<{ key: Buffer; value: unknown }> { @@ -586,6 +637,17 @@ export class MiniDb { if (this.autoCompact && !this.compacting && shouldCompact(this)) compact(this).catch(() => {}); } + /** Park a write op while a compaction rotation is in flight, accounting the + * wait so compactionRotationPauseMs reflects the writer-visible pause + * (as opposed to compactionRotationDurationMs, the rotation's wall time). */ + private async awaitRotation(): Promise { + const rl = this._rotateLock; + if (!rl) return; + const t0 = performance.now(); + await rl; + this.stats.compactionRotationPauseMs += performance.now() - t0; + } + private hasUniqueIndexes(): boolean { for (const idx of this.indexes.indexes.values()) if (idx.unique) return true; return false; @@ -624,7 +686,7 @@ export class MiniDb { 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 this.awaitRotation(); await commit(); } } @@ -771,7 +833,7 @@ export class MiniDb { this.ensureOpen(); this.ensureWritable(); this.checkKey(key); - if (this._rotateLock) await this._rotateLock; + await this.awaitRotation(); const op = this.prepareSet(key, value, { ttl, dt }); await this.ensureMemoryFor([op]); @@ -817,7 +879,7 @@ export class MiniDb { async del(key: string | Buffer): Promise { this.ensureOpen(); this.ensureWritable(); - if (this._rotateLock) await this._rotateLock; + await this.awaitRotation(); const existed = this.store.has(toKStr(key)); if (!existed) return false; const op = this.prepareDel(key); @@ -842,7 +904,7 @@ export class MiniDb { async batch(ops: readonly BatchInputOp[]): Promise { this.ensureOpen(); this.ensureWritable(); - if (this._rotateLock) await this._rotateLock; + await this.awaitRotation(); if (!ops || ops.length === 0) return; const prepared = ops.map((o) => this.prepareOp(o)); await this.ensureMemoryFor(prepared); @@ -1068,7 +1130,7 @@ export class MiniDb { async expire(key: string | Buffer, ttlMs: number): Promise { this.ensureOpen(); this.ensureWritable(); - if (this._rotateLock) await this._rotateLock; + await this.awaitRotation(); const k = toKStr(key); const cur = this.store.getRecord(k); if (cur === undefined) return false; @@ -1457,6 +1519,7 @@ export class MiniDb { const out: { key: string; value: V; dt: Record | undefined }[] = []; let skipped = 0; for (const { key: kstr } of this.dt.iterate(col, iterOpts)) { + this.stats.queryCandidates++; let rejected = false; for (const c of eqChecks) { if (!this.indexes.hasEq(c.name, c.value, kstr)) { @@ -1468,6 +1531,7 @@ export class MiniDb { const buf = this.store.get(kstr); if (buf === undefined) continue; const r = this.store.map.get(kstr); + this.stats.queryDecoded++; const value = this.decode(buf)!; if (q.filter && !match(value, q.filter)) continue; if (skipped < skip) { @@ -1548,9 +1612,11 @@ export class MiniDb { const docs: ScanEntry[] = []; let seen = 0; for (const k of keys) { + this.stats.queryCandidates++; const buf = this.store.get(k); if (buf === undefined) continue; const r = this.store.map.get(k); + this.stats.queryDecoded++; const value = this.decode(buf)!; if (q.filter && !match(value, q.filter)) continue; if (early) { @@ -1563,11 +1629,13 @@ export class MiniDb { } if (textOrder && !q.sort) { + this.stats.querySortedRows += docs.length; const rank = new Map(textOrder.map((h, i) => [h.key, i])); docs.sort((a, b) => (rank.get(a.key) ?? 1e9) - (rank.get(b.key) ?? 1e9)); } if (q.sort) { + this.stats.querySortedRows += docs.length; const entries = Object.entries(q.sort); docs.sort((a, b) => { for (const [p, dir] of entries) { diff --git a/packages/minidb/src/recovery.ts b/packages/minidb/src/recovery.ts index b44309f5583..90df57d158f 100644 --- a/packages/minidb/src/recovery.ts +++ b/packages/minidb/src/recovery.ts @@ -21,6 +21,9 @@ export type ValueMode = 'memory' | 'disk'; export interface RecoveryInfo { snapshotFrames: number; walFrames: number; + /** On-disk sizes of the files recovery scanned (0 when absent). */ + snapshotBytes: number; + walBytes: number; truncatedWal: boolean; corruptRanges: [number, number][]; snapshotCorruptRanges: [number, number][]; @@ -144,10 +147,12 @@ export async function recover({ const walPath = path.join(dir, 'db.wal'); let snapshotFrames = 0; + let snapshotBytes = 0; let snapshotCorrupt: [number, number][] = []; if (fsSync.existsSync(snapPath)) { const fd = fsSync.openSync(snapPath, 'r'); try { + snapshotBytes = fsSync.fstatSync(fd).size; const r = scanFrameRefsFd(fd, { onCorrupt: mode }); applyFrames(r.frames, 'snapshot', fd, store, valueMode); snapshotFrames = r.frames.length; @@ -158,6 +163,7 @@ export async function recover({ } let walFrames = 0; + let walBytes = 0; let walCorrupt: [number, number][] = []; let truncatedWal = false; let walScanEnd = 0; @@ -169,6 +175,7 @@ export async function recover({ try { const st = fsSync.fstatSync(fd); walSize = st.size; + walBytes = st.size; walDev = st.dev; walIno = st.ino; const r = scanFrameRefsFd(fd, { onCorrupt: mode }); @@ -195,6 +202,8 @@ export async function recover({ return { snapshotFrames, walFrames, + snapshotBytes, + walBytes, truncatedWal, corruptRanges: walCorrupt, snapshotCorruptRanges: snapshotCorrupt, diff --git a/packages/minidb/src/wal.ts b/packages/minidb/src/wal.ts index 9ad4a04d871..2d5aae08e7d 100644 --- a/packages/minidb/src/wal.ts +++ b/packages/minidb/src/wal.ts @@ -4,7 +4,9 @@ // policies matching Redis AOF. // // 'always' — write + fsync for every flush (safest, slowest) -// 'everysec' — write every flush; fsync on a 1s timer (default; ≤1s loss window) +// 'everysec' — write every flush; fsync on a 1s timer, but only while there +// are writes not yet covered by a successful fsync (default; +// ≤1s loss window; an idle WAL never fsyncs) // 'no' — write only; let the OS flush (fastest, may lose seconds) // // Group commit: all append() calls within a tick are coalesced into a single @@ -24,12 +26,33 @@ interface PendingWrite { reject: (err: unknown) => void; } +/** Cumulative WAL counters, owned by MiniDb so they survive WAL rotation + * during compaction (which replaces the WAL). */ +export interface WalStats { + walBytesWritten: number; + /** Successful fsyncs (write-path 'always', background 'everysec', close). */ + walFsyncs: number; + /** Failed fsync attempts. A background everysec failure does not reject any + * write — it is observable only here and via lastWalFsyncError. */ + walFsyncErrors: number; + /** Sticky copy of the most recent fsync failure (never cleared on success). */ + lastWalFsyncError: unknown; + /** Bytes currently sitting in the in-memory append queue. */ + walQueuedBytes: number; + /** High-water mark of walQueuedBytes. */ + walMaxQueuedBytes: number; + /** Number of group commits (one per flushed batch). */ + walGroupCommits: number; + /** Total frames carried by those group commits. */ + walGroupCommitFrames: number; +} + export interface WALOptions { fsyncPolicy?: FsyncPolicy; syncIntervalMs?: number; /** Optional sink for cumulative write/fsync counters. Owned by MiniDb so the * counts survive WAL rotation during compaction (which replaces the WAL). */ - stats?: { walBytesWritten: number; walFsyncs: number }; + stats?: WalStats; } export class WAL { @@ -52,7 +75,17 @@ export class WAL { private sealed = false; private timer: ReturnType | null = null; private closed = false; - private readonly stats: { walBytesWritten: number; walFsyncs: number } | null; + private readonly stats: WalStats | null; + /** Durability watermark, Redis-AOF style: writeGen counts the writev batches + * that landed in the OS page cache, syncedGen the watermark the last + * successful fsync is known to cover. The WAL is dirty while they differ. + * A generation (not a boolean) so a successful fsync never clears writes a + * concurrent flush landed while the fsync was in flight. */ + private writeGen = 0; + private syncedGen = 0; + /** Set while a background (everysec) sync is in flight, so a slow fsync + * never stacks a second background fsync on top of itself. */ + private bgSyncing = false; constructor(path: string, opts: WALOptions = {}) { const policy = opts.fsyncPolicy ?? 'everysec'; @@ -71,7 +104,19 @@ export class WAL { this.nextOffset = st.size; if (this.policy === 'everysec') { this.timer = setInterval(() => { - this.sync().catch(() => {}); + // Skip idle ticks entirely: an everysec WAL with no unsynced writes + // must not fsync (the previous unconditional fsync cost one syscall + + // disk wake-up per second for the database's whole lifetime). + // Sync failures do not reject any write (the page-cache copy is the + // acknowledged one); they are recorded in stats.walFsyncErrors / + // lastWalFsyncError instead of being silently swallowed. + if (this.writeGen === this.syncedGen || this.bgSyncing) return; + this.bgSyncing = true; + this.sync() + .catch(() => {}) + .finally(() => { + this.bgSyncing = false; + }); }, this.syncIntervalMs); this.timer.unref?.(); } @@ -102,6 +147,12 @@ export class WAL { const done = new Promise((resolve, reject) => { this.queue.push({ buf: frame, resolve, reject }); this.queuedBytes += frame.length; + if (this.stats) { + this.stats.walQueuedBytes += frame.length; + if (this.stats.walQueuedBytes > this.stats.walMaxQueuedBytes) { + this.stats.walMaxQueuedBytes = this.stats.walQueuedBytes; + } + } if (!this.flushing && !this.scheduled) { this.scheduled = true; setImmediate(() => { void this.flushBatch(); }); @@ -125,7 +176,13 @@ export class WAL { const run = async () => { const batch = this.queue; this.queue = []; + const batchBytes = this.queuedBytes; this.queuedBytes = 0; + if (this.stats) { + this.stats.walQueuedBytes -= batchBytes; + this.stats.walGroupCommits++; + this.stats.walGroupCommitFrames += batch.length; + } // writev(2) may short-write (signal interruption, RLIMIT_FSIZE, …). Retry // until the whole batch lands so a partial write never rejects frames // whose in-memory side effects were already applied. Only a real I/O @@ -139,6 +196,9 @@ export class WAL { if (bytesWritten === 0) throw new Error('WAL writev made no progress (short write)'); this.size += bytesWritten; if (this.stats) this.stats.walBytesWritten += bytesWritten; + // The bytes are in the OS page cache but not necessarily on disk: + // the WAL is dirty until a successful fsync covers this generation. + this.writeGen++; let rem = bytesWritten; while (rem > 0 && bufs.length > 0) { const left = bufs[0]!.length - off; @@ -181,12 +241,27 @@ export class WAL { this.nextOffset = st.size; } - /** Force an fsync of the underlying file. */ + /** Force an fsync of the underlying file. On success the durability + * watermark advances to the write generation sampled when the fsync was + * issued; a failure is recorded (walFsyncErrors + sticky lastWalFsyncError) + * and rethrown, and the WAL stays dirty. */ async sync(): Promise { - if (this.fh) { + if (!this.fh) return; + const gen = this.writeGen; + try { await this.fh.sync(); - if (this.stats) this.stats.walFsyncs++; + } catch (err) { + if (this.stats) { + this.stats.walFsyncErrors++; + this.stats.lastWalFsyncError = err; + } + throw err; } + if (this.stats) this.stats.walFsyncs++; + // Only generations issued BEFORE this fsync may be marked synced: a flush + // that landed while the fsync was in flight is not covered by it, so the + // WAL stays dirty and the next tick syncs again. + if (this.syncedGen < gen) this.syncedGen = gen; } /** Flush buffered frames to the OS (without necessarily fsync'ing). diff --git a/packages/minidb/test/bench-json.test.ts b/packages/minidb/test/bench-json.test.ts new file mode 100644 index 00000000000..45042213722 --- /dev/null +++ b/packages/minidb/test/bench-json.test.ts @@ -0,0 +1,99 @@ +// Runs the real bench script in --quick mode and pins the machine-readable +// JSON report's shape: later phases diff before/after numbers, so the field +// names below are a stable contract. +import { test } from 'vitest'; +import assert from 'node:assert/strict'; +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const pkgDir = fileURLToPath(new URL('..', import.meta.url)); + +test('bench --quick emits a JSON report with a stable schema', async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'minidb-bench-json-')); + try { + const reportPath = path.join(dir, 'report.json'); + await promisify(execFile)( + process.execPath, + ['--import', 'tsx', path.join(pkgDir, 'bench', 'bench.ts'), '--quick', '--json', reportPath], + { cwd: pkgDir, timeout: 240_000, maxBuffer: 16 * 1024 * 1024 }, + ); + const report = JSON.parse(await fs.readFile(reportPath, 'utf8')); + + // Top-level envelope. + assert.equal(report.schemaVersion, 1); + assert.equal(report.tool, 'minidb/bench'); + assert.equal(typeof report.startedAt, 'string'); + assert.ok(!Number.isNaN(Date.parse(report.startedAt)), 'startedAt is an ISO timestamp'); + assert.equal(typeof report.node, 'string'); + assert.equal(typeof report.platform, 'string'); + assert.equal(typeof report.arch, 'string'); + assert.equal(typeof report.seed, 'number'); + assert.ok(Array.isArray(report.scenarios)); + assert.ok(report.scenarios.length >= 10, 'all scenario families ran'); + + // Every scenario row carries the shared measurement fields. + for (const s of report.scenarios) { + assert.equal(typeof s.name, 'string'); + assert.ok(s.name.length > 0); + assert.equal(typeof s.durationMs, 'number', `${s.name}.durationMs`); + assert.ok(s.durationMs >= 0); + if (s.ops !== undefined) assert.equal(typeof s.ops, 'number', `${s.name}.ops`); + if (s.opsPerSec !== undefined) assert.equal(typeof s.opsPerSec, 'number', `${s.name}.opsPerSec`); + for (const k of ['mean', 'p50', 'p95', 'p99', 'max']) { + assert.equal(typeof s.eventLoopDelayMs[k], 'number', `${s.name}.eventLoopDelayMs.${k}`); + } + assert.ok(s.peakRssBytes > 0, `${s.name}.peakRssBytes`); + assert.ok(s.peakHeapUsedBytes > 0, `${s.name}.peakHeapUsedBytes`); + if (s.latencyMs !== undefined) { + for (const k of ['p50', 'p95', 'p99', 'max']) { + assert.equal(typeof s.latencyMs[k], 'number', `${s.name}.latencyMs.${k}`); + } + } + } + + const byName = (re) => report.scenarios.find((s) => re.test(s.name)); + + // Cold open rows expose the recovery/rebuild breakdown. + const cold = byName(/cold open/); + assert.ok(cold, 'a cold-open scenario exists'); + for (const k of ['keys', 'recoveryBytes', 'recoveryFrames', 'recoveryDurationMs', 'indexRebuildDurationMs', 'textRebuildDurationMs', 'walFsyncs']) { + assert.equal(typeof cold.extra[k], 'number', `cold open extra.${k}`); + } + + // The acceptance scenario: an idle everysec db performs zero fsyncs. + const idle = byName(/idle everysec/); + assert.ok(idle, 'the idle everysec scenario exists'); + assert.equal(idle.extra.walFsyncs, 0, 'idle everysec db: zero background fsyncs'); + assert.equal(idle.extra.walFsyncErrors, 0); + + // A write re-arms the background sync. + const dirty = byName(/write then idle/); + assert.ok(dirty, 'the dirty-window scenario exists'); + assert.ok(dirty.extra.walFsyncs >= 1, 'a write triggers a background fsync'); + + // Search rows report per-query hit counts and medians. + for (const re of [/search word/, /search ngram/]) { + const search = byName(re); + assert.ok(search, `${re} scenario exists`); + assert.ok(Array.isArray(search.extra.queries)); + for (const q of search.extra.queries) { + assert.equal(typeof q.q, 'string'); + assert.equal(typeof q.hits, 'number'); + assert.equal(typeof q.medianMs, 'number'); + } + } + + // The compaction row exposes the phase breakdown. + const compact = byName(/compact \d/); + assert.ok(compact, 'a compaction scenario exists'); + for (const k of ['keys', 'compactionDurationMs', 'compactionSnapshotDurationMs', 'compactionRotationDurationMs', 'compactionPostingsDurationMs', 'snapshotBytesWritten']) { + assert.equal(typeof compact.extra[k], 'number', `compact extra.${k}`); + } + } finally { + await fs.rm(dir, { recursive: true, force: true }); + } +}, 300_000); diff --git a/packages/minidb/test/cluster/lock.test.ts b/packages/minidb/test/cluster/lock.test.ts index 9f24a797ecb..56cb0235b20 100644 --- a/packages/minidb/test/cluster/lock.test.ts +++ b/packages/minidb/test/cluster/lock.test.ts @@ -138,3 +138,38 @@ test('close releases every shard lock it holds', async () => { await rmrf(dir); } }); + +test('close() waits for an in-flight shard open and releases its lock', async () => { + const dir = await tmpDir('minidb-cluster-'); + try { + const key = keyOnShard('inflight', 0, 4); + const db1 = await ClusterDb.open({ dir, shardCount: 4, valueCodec: 'json' }); + await db1.set(key, { holder: 1 }); // db1 holds shard 0 + + // lockHoldMs: 0 — an acquired writer never auto-yields, so a leaked handle + // would hold the shard lock indefinitely. + const db2 = await ClusterDb.open({ dir, shardCount: 4, valueCodec: 'json', lockAcquireTimeoutMs: 5_000, lockHoldMs: 0 }); + // db2's shard-0 open is now stuck in the lock-retry loop behind db1. + const pendingSet = db2.set(key, { holder: 2 }); + await sleep(50); + const t0 = performance.now(); + const closing = db2.close(); + // Release the holder mid-close: db2's in-flight open can now succeed — + // close() must still wait for it and close the freshly acquired handle. + await sleep(100); + await db1.close(); + + await pendingSet.catch(() => {}); // may resolve or reject; either is fine + await closing; + const waited = performance.now() - t0; + assert.ok(waited >= 80, `close() waited for the in-flight open to settle (${Math.round(waited)}ms)`); + + // No leaked handle holds the shard lock: a fresh instance writes at once. + const db3 = await ClusterDb.open({ dir, shardCount: 4, valueCodec: 'json', lockAcquireTimeoutMs: 300, lockHoldMs: 0 }); + await db3.set(key, { holder: 3 }); + assert.deepEqual(await db3.get(key), { holder: 3 }); + await db3.close(); + } finally { + await rmrf(dir); + } +}); diff --git a/packages/minidb/test/stats.test.ts b/packages/minidb/test/stats.test.ts index 6cb3636a087..23f82002941 100644 --- a/packages/minidb/test/stats.test.ts +++ b/packages/minidb/test/stats.test.ts @@ -11,6 +11,8 @@ async function tmpDir() { return fs.mkdtemp(path.join(os.tmpdir(), 'minidb-stats-')); } +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + // Frame overhead is 22 (header) + 4 (crc) = 26 bytes, plus key + value. const FRAME_OVERHEAD = 26; @@ -91,3 +93,131 @@ test('batch writes a single frame (lower write amplification than per-key sets)' await fs.rm(dir, { recursive: true, force: true }); } }); + +test("everysec: idle db performs zero background fsyncs; a dirty window syncs once then goes quiet", async () => { + const dir = await tmpDir(); + const db = await MiniDb.open({ dir, valueCodec: 'string', fsyncPolicy: 'everysec', syncIntervalMs: 25, autoCompact: false }); + try { + await sleep(120); + assert.equal(db.stats.walFsyncs, 0, 'idle everysec db must not fsync in the background'); + + await db.set('k', 'v'); + await sleep(120); + assert.equal(db.stats.walFsyncs, 1, 'the dirty interval fsyncs once'); + + await sleep(120); + assert.equal(db.stats.walFsyncs, 1, 'no fsync growth after the writes stopped'); + + await db.set('k2', 'v2'); + await sleep(120); + assert.equal(db.stats.walFsyncs, 2, 'the next dirty window fsyncs once more'); + } finally { + await db.close(); // final close sync runs after our assertions + await fs.rm(dir, { recursive: true, force: true }); + } +}); + +test('everysec background sync failure is observable in stats but does not change write semantics', async () => { + const dir = await tmpDir(); + const db = await MiniDb.open({ dir, valueCodec: 'string', fsyncPolicy: 'everysec', syncIntervalMs: 25, autoCompact: false }); + try { + const fh = (db as unknown as { wal: { fh: { sync: () => Promise } } }).wal.fh; + const orig = fh.sync.bind(fh); + const boom = new Error('injected fsync failure'); + fh.sync = () => Promise.reject(boom); + + // The cache-rebuildable write contract is unchanged: sets resolve from the + // page cache even while every background fsync fails. + await db.set('k', 'v'); + await sleep(150); + assert.ok(db.stats.walFsyncErrors >= 1, `expected walFsyncErrors >= 1, got ${db.stats.walFsyncErrors}`); + assert.equal(db.stats.lastWalFsyncError, boom, 'the failure is observable via stats'); + assert.equal(db.stats.walFsyncs, 0, 'no successful fsync yet'); + + fh.sync = orig; + await sleep(150); + assert.ok(db.stats.walFsyncs >= 1, 'background sync recovers once the failure clears'); + assert.equal(db.stats.lastWalFsyncError, boom, 'sticky error survives later successes'); + } finally { + await db.close(); + await fs.rm(dir, { recursive: true, force: true }); + } +}); + +test('recovery stats capture scanned bytes, frames and duration at open', async () => { + const dir = await tmpDir(); + const db = await MiniDb.open({ dir, valueCodec: 'string', fsyncPolicy: 'no', autoCompact: false }); + const N = 50; + for (let i = 0; i < N; i++) await db.set(`k${i}`, `v${i}`); + await db.close(); + + const walBytes = (await fs.stat(path.join(dir, 'db.wal'))).size; + const reopened = await MiniDb.open({ dir, valueCodec: 'string', fsyncPolicy: 'no', autoCompact: false }); + try { + assert.equal(reopened.stats.recoveryFrames, N, 'one frame per set replayed'); + assert.equal(reopened.stats.recoveryBytes, walBytes, 'WAL bytes accounted (no snapshot yet)'); + assert.ok(reopened.stats.recoveryDurationMs >= 0, 'duration recorded'); + assert.equal(reopened.recoveryInfo.walBytes, walBytes); + assert.equal(reopened.recoveryInfo.snapshotBytes, 0); + } finally { + await reopened.close(); + await fs.rm(dir, { recursive: true, force: true }); + } +}); + +test('compaction phase stats break down into snapshot/rotation/postings/total', async () => { + const dir = await tmpDir(); + const db = await MiniDb.open({ dir, valueCodec: 'json', fsyncPolicy: 'no', autoCompact: false }); + try { + await db.createTextIndex('body', { fields: ['body'] }); + for (let i = 0; i < 100; i++) await db.set(`k${i}`, { body: `message number ${i} hello world` }); + await db.compact(); + + assert.equal(db.stats.compactions, 1); + assert.ok(db.stats.compactionDurationMs > 0, 'total recorded'); + assert.ok(db.stats.compactionSnapshotDurationMs > 0, 'snapshot phase recorded'); + assert.ok(db.stats.compactionRotationDurationMs > 0, 'rotation phase recorded'); + assert.ok(db.stats.compactionPostingsDurationMs > 0, 'postings rebuild recorded'); + assert.ok(db.stats.textRebuildDurationMs > 0, 'postings rebuild also counts as a text rebuild'); + assert.ok( + db.stats.compactionSnapshotDurationMs + db.stats.compactionRotationDurationMs <= db.stats.compactionDurationMs, + 'phases are bounded by the total', + ); + } finally { + await db.close(); + await fs.rm(dir, { recursive: true, force: true }); + } +}); + +test('query stats count candidates, decodes and sorted rows', async () => { + const dir = await tmpDir(); + const db = await MiniDb.open({ dir, valueCodec: 'json', fsyncPolicy: 'no', autoCompact: false }); + try { + for (let i = 0; i < 100; i++) await db.set(`k${i}`, { n: i, grp: i % 10 === 0 ? 'x' : 'y' }); + + const c0 = db.stats.queryCandidates; + const d0 = db.stats.queryDecoded; + const s0 = db.stats.querySortedRows; + + // Full-scan filter without sort: every candidate is decoded, nothing sorted. + const filtered = db.query({ filter: { grp: 'x' } }); + assert.equal(filtered.length, 10); + assert.equal(db.stats.queryCandidates - c0, 100, 'every record was a candidate'); + assert.equal(db.stats.queryDecoded - d0, 100, 'every candidate was decoded to match the filter'); + assert.equal(db.stats.querySortedRows - s0, 0, 'no sort without sort/text'); + + // Same query with a sort: only the matched rows reach the sort. + const sorted = db.query({ filter: { grp: 'x' }, sort: { n: -1 } }); + assert.equal(sorted.length, 10); + assert.equal(db.stats.querySortedRows - s0, 10, 'only matched rows are sorted'); + + // A bounded query decodes only until the limit is filled. + const c1 = db.stats.queryCandidates; + const page = db.query({ filter: { grp: 'x' }, limit: 3 }); + assert.equal(page.length, 3); + assert.ok(db.stats.queryCandidates - c1 < 100, 'early exit stops before the full scan'); + } finally { + await db.close(); + await fs.rm(dir, { recursive: true, force: true }); + } +}); diff --git a/packages/minidb/test/wal.test.ts b/packages/minidb/test/wal.test.ts index 11d031d6574..4c59de15f17 100644 --- a/packages/minidb/test/wal.test.ts +++ b/packages/minidb/test/wal.test.ts @@ -8,6 +8,20 @@ import { WAL } from '../src/wal.js'; import { encodeFrame, FrameParser, CorruptFrameError, TYPE_SET, TYPE_DEL } from '../src/codec.js'; const B = (s) => Buffer.from(s); +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +function freshStats() { + return { + walBytesWritten: 0, + walFsyncs: 0, + walFsyncErrors: 0, + lastWalFsyncError: null, + walQueuedBytes: 0, + walMaxQueuedBytes: 0, + walGroupCommits: 0, + walGroupCommitFrames: 0, + }; +} async function tmpWalPath() { const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'minidb-wal-')); @@ -136,3 +150,117 @@ test('seal(): rejects new appends with WAL_SEALED, queued frames stay flushable' await fs.rm(dir, { recursive: true, force: true }); } }); + +test("everysec: idle WAL performs zero background fsyncs; only dirty intervals sync", async () => { + const { dir, file } = await tmpWalPath(); + try { + const stats = freshStats(); + const wal = new WAL(file, { fsyncPolicy: 'everysec', syncIntervalMs: 25, stats }); + await wal.open(); + + // ~5 intervals with no writes: not a single fsync. + await sleep(120); + assert.equal(stats.walFsyncs, 0, 'idle everysec WAL must not fsync'); + + // A write dirties the WAL: exactly one background fsync, then quiet again. + await wal.append(encodeFrame({ type: TYPE_SET, key: B('k'), value: B('v') })); + await sleep(120); + assert.equal(stats.walFsyncs, 1, 'one background fsync per dirty interval'); + await sleep(120); + assert.equal(stats.walFsyncs, 1, 'fsync count does not grow once synced'); + + // Another write: one more fsync, no burst. + await wal.append(encodeFrame({ type: TYPE_SET, key: B('k2'), value: B('v2') })); + await sleep(120); + assert.equal(stats.walFsyncs, 2); + + // close() keeps its unconditional final sync even though the WAL is clean. + await wal.close(); + assert.equal(stats.walFsyncs, 3, 'close() always performs the final sync'); + assert.equal(stats.walFsyncErrors, 0); + } finally { + await fs.rm(dir, { recursive: true, force: true }); + } +}); + +test('close() performs a final fsync even when there were no writes at all', async () => { + const { dir, file } = await tmpWalPath(); + try { + const stats = freshStats(); + const wal = new WAL(file, { fsyncPolicy: 'everysec', syncIntervalMs: 25, stats }); + await wal.open(); + await wal.close(); + assert.equal(stats.walFsyncs, 1); + } finally { + await fs.rm(dir, { recursive: true, force: true }); + } +}); + +test('background sync failure is recorded but neither rejects writes nor clears dirty', async () => { + const { dir, file } = await tmpWalPath(); + try { + const stats = freshStats(); + const wal = new WAL(file, { fsyncPolicy: 'everysec', syncIntervalMs: 25, stats }); + await wal.open(); + + const fh = (wal as unknown as { fh: { sync: () => Promise } }).fh; + const orig = fh.sync.bind(fh); + const boom = new Error('injected fsync failure'); + fh.sync = () => Promise.reject(boom); + + // Writes are acknowledged from the page cache: the failing background + // fsync never rejects them. + await wal.append(encodeFrame({ type: TYPE_SET, key: B('k'), value: B('v') })); + await sleep(120); + assert.ok(stats.walFsyncErrors >= 1, `expected fsync errors, got ${stats.walFsyncErrors}`); + assert.equal(stats.lastWalFsyncError, boom, 'sticky error is observable'); + assert.equal(stats.walFsyncs, 0, 'no successful fsync meanwhile'); + + // A failed sync must not clear the dirty mark: once the failure goes away + // the next tick retries and the WAL converges to synced. + fh.sync = orig; + await sleep(120); + assert.ok(stats.walFsyncs >= 1, 'sync retried after the failure'); + assert.equal(stats.lastWalFsyncError, boom, 'sticky error is not cleared by a later success'); + + // Clean again: no more background fsyncs. + const n = stats.walFsyncs; + await sleep(120); + assert.equal(stats.walFsyncs, n); + await wal.close(); + } finally { + await fs.rm(dir, { recursive: true, force: true }); + } +}); + +test('queue depth and group-commit counters track the append buffer', async () => { + const { dir, file } = await tmpWalPath(); + try { + const stats = freshStats(); + const wal = new WAL(file, { fsyncPolicy: 'no', stats }); + await wal.open(); + + // Sequential appends: each lands in its own group commit. + for (let i = 0; i < 5; i++) { + await wal.append(encodeFrame({ type: TYPE_SET, key: B(`s${i}`), value: B('v') })); + } + assert.equal(stats.walGroupCommits, 5); + assert.equal(stats.walGroupCommitFrames, 5); + assert.equal(stats.walQueuedBytes, 0, 'queue drains after each flush'); + + // Concurrent appends coalesce: fewer commits than frames. + const N = 200; + const ops = []; + for (let i = 0; i < N; i++) { + ops.push(wal.append(encodeFrame({ type: TYPE_SET, key: B(`c${i}`), value: B('v') }))); + } + await Promise.all(ops); + assert.equal(stats.walGroupCommitFrames, 5 + N); + assert.ok(stats.walGroupCommits < 5 + N, `expected coalescing, got ${stats.walGroupCommits} commits`); + assert.ok(stats.walMaxQueuedBytes > 0, 'high-water mark recorded the burst'); + assert.equal(stats.walQueuedBytes, 0); + await wal.close(); + } finally { + await fs.rm(dir, { recursive: true, force: true }); + } +}); From 298f8a3bf22fb90c42130c2826dd931a91d604e7 Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Fri, 31 Jul 2026 16:15:28 +0800 Subject: [PATCH 02/15] perf(minidb): bound startup rebuild and steady-state hot paths - rebuild all derived indexes in one shared store walk: a single decode per record fans out to staged builders, dt rebuild reads record metadata only, and index-less opens no longer decode at all - rank full-text results with a bounded min-heap plus a stable key tie-break instead of sorting every candidate - remove/overwrite text docs via a docID -> delta-terms reverse map instead of scanning the whole delta vocabulary - validate unique batches incrementally against touched postings instead of copying the full per-index owner map - reap due TTL entries from the expiry heap on the write path instead of a full-store sweep per write --- packages/minidb/bench/query.ts | 222 ++++++++++++ packages/minidb/bench/reader-catchup.ts | 23 +- packages/minidb/src/compound-index.ts | 79 +++-- packages/minidb/src/dt-index.ts | 43 ++- packages/minidb/src/index-manager.ts | 174 ++++++---- packages/minidb/src/index.ts | 65 +++- packages/minidb/src/store.ts | 44 +++ packages/minidb/src/text-index.ts | 322 ++++++++++++------ .../minidb/test/e2e/index-consistency.test.ts | 31 ++ packages/minidb/test/indexes-extra.test.ts | 44 +++ packages/minidb/test/stats.test.ts | 39 +++ packages/minidb/test/store.test.ts | 37 ++ packages/minidb/test/text-index.test.ts | 80 +++++ 13 files changed, 997 insertions(+), 206 deletions(-) diff --git a/packages/minidb/bench/query.ts b/packages/minidb/bench/query.ts index 3cbf98ce8b1..65aabf65704 100644 --- a/packages/minidb/bench/query.ts +++ b/packages/minidb/bench/query.ts @@ -23,6 +23,219 @@ async function bench(label, fn, iters = 1) { return { ms, r }; } +// --------------------------------------------------------------------------- +// Phase-2 hot-path scenarios (plan/02): bounded startup rebuild, full-text +// top-K, text delta overwrite, unique batch validation, TTL write pauses. +// Sizes default to the plan's acceptance scenarios; shrink via env for a +// quick smoke run (OPEN_N / TOPK_N / DELTA_TERMS / UNIQUE_OWNERS / TTL_N). +// --------------------------------------------------------------------------- + +function percentile(sorted, p) { + 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)]; +} + +/** Cold open of the same 100k-doc database with 0/1/2 text indexes defined. + * Built once with two text indexes; the definitions sidecar is trimmed + * between runs to emulate fewer defined indexes. */ +async function coldOpenScenario() { + const OPEN_N = Number(process.env.OPEN_N || 100_000); + console.log(`\n -- cold open, ${fmt(OPEN_N)} docs, 0/1/2 text indexes --`); + const dir = await tmpDir(); + try { + { + const db = await MiniDb.open({ dir, valueCodec: 'json', fsyncPolicy: 'no', autoCompact: false }); + const bulk = []; + for (let i = 0; i < OPEN_N; i++) { + bulk.push(db.set(`doc:${String(i).padStart(7, '0')}`, { title: `title ${i}`, body: `hello world doc ${i}` }, { dt: { created: i } })); + if (bulk.length >= 10_000) { + await Promise.all(bulk); + bulk.length = 0; + } + } + await Promise.all(bulk); + await db.createTextIndex('body', { fields: ['body'] }); + await db.createTextIndex('title', { fields: ['title'] }); + await db.close(); + } + const defsPath = path.join(dir, 'db.textindexes.json'); + const defs = JSON.parse(await fs.readFile(defsPath, 'utf8')); + for (const k of [0, 1, 2]) { + await fs.writeFile(defsPath, JSON.stringify(defs.slice(0, k)), 'utf8'); + const opens = []; + let decoded; + for (let r = 0; r < 3; r++) { + const t0 = performance.now(); + const db = await MiniDb.open({ dir, valueCodec: 'json', fsyncPolicy: 'no', autoCompact: false }); + opens.push(performance.now() - t0); + decoded = db.stats.indexRebuildDecoded ?? 'n/a'; + await db.close(); + } + opens.sort((a, b) => a - b); + console.log( + ` textIndexes=${k}`.padEnd(24), + `median ${opens[1].toFixed(1).padStart(8)} ms (min ${opens[0].toFixed(1)}, rebuild-decoded=${decoded})`, + ); + } + } finally { + await fs.rm(dir, { recursive: true, force: true }); + } +} + +/** Full-text top-K over ~1M candidates with limit 10/50/1000. Two warm-up + * searches run first (JIT/GC steady state); the reported number is the min + * of five — the ranking phase's cost, not first-run noise. */ +async function topKScenario() { + const TOPK_N = Number(process.env.TOPK_N || 1_000_000); + console.log(`\n -- full-text top-K, ${fmt(TOPK_N)} candidates --`); + const dir = await tmpDir(); + try { + const db = await MiniDb.open({ dir, valueCodec: 'json', fsyncPolicy: 'no', autoCompact: false }); + await db.createTextIndex('body', { fields: ['body'] }); + for (let base = 0; base < TOPK_N; base += 10_000) { + const bulk = []; + for (let i = base; i < Math.min(base + 10_000, TOPK_N); i++) bulk.push(db.set(`doc:${i}`, { body: `common word ${i}` })); + await Promise.all(bulk); + } + db.search('body', 'common', { limit: 10 }); + db.search('body', 'common', { limit: 10 }); + for (const limit of [10, 50, 1000]) { + let hits = 0; + let best = Infinity; + const rss0 = process.memoryUsage().rss; + for (let i = 0; i < 5; i++) { + const t0 = performance.now(); + hits = db.search('body', 'common', { limit }).length; + best = Math.min(best, performance.now() - t0); + } + const rssDelta = Math.max(0, process.memoryUsage().rss - rss0) / 1024 / 1024; + console.log(` limit=${String(limit).padEnd(6)}`.padEnd(24), `${best.toFixed(1).padStart(8)} ms/search (min-of-5, hits=${hits}, rssΔ~${rssDelta.toFixed(0)} MiB)`); + } + await db.close(); + } finally { + await fs.rm(dir, { recursive: true, force: true }); + } +} + +/** Overwrite one document while the text delta holds many distinct terms. */ +async function deltaOverwriteScenario() { + const DELTA_TERMS = Number(process.env.DELTA_TERMS || 1_000_000); + const DOCS = 1000; + const perDoc = Math.ceil(DELTA_TERMS / DOCS); + console.log(`\n -- text delta overwrite, ~${fmt(DELTA_TERMS)} distinct delta terms (${DOCS} docs x ${fmt(perDoc)} words) --`); + const dir = await tmpDir(); + try { + const db = await MiniDb.open({ dir, valueCodec: 'json', fsyncPolicy: 'no', autoCompact: false }); + await db.createTextIndex('body', { fields: ['body'] }); + const bulk = []; + let w = 0; + for (let d = 0; d < DOCS; d++) { + const words = []; + for (let i = 0; i < perDoc; i++) words.push(`w${w++}`); + bulk.push(db.set(`doc:${d}`, { body: words.join(' ') })); + if (bulk.length >= 100) { + await Promise.all(bulk); + bulk.length = 0; + } + } + await Promise.all(bulk); + // Overwrite a single doc repeatedly; each overwrite must visit only the + // doc's own terms, not the whole delta vocabulary. + const lat = []; + for (let i = 0; i < 100; i++) { + const t0 = performance.now(); + await db.set('doc:0', { body: `w0 w1 w2 extra${i}` }); + lat.push(performance.now() - t0); + } + lat.sort((a, b) => a - b); + console.log( + ` overwrite 1 doc x100`.padEnd(24), + `p50 ${percentile(lat, 50).toFixed(2)} ms p95 ${percentile(lat, 95).toFixed(2)} ms max ${lat[lat.length - 1].toFixed(2)} ms`, + ); + await db.close(); + } finally { + await fs.rm(dir, { recursive: true, force: true }); + } +} + +/** Unique batch validation cost vs unique-index owner count. */ +async function uniqueBatchScenario() { + const OWNERS = Number(process.env.UNIQUE_OWNERS || 100_000); + console.log(`\n -- unique batch validation, ${fmt(OWNERS)} owners --`); + const dir = await tmpDir(); + try { + const db = await MiniDb.open({ dir, valueCodec: 'json', fsyncPolicy: 'no', autoCompact: false }); + await db.createIndex('byN', { field: 'n', unique: true }); + for (let base = 0; base < OWNERS; base += 10_000) { + const bulk = []; + for (let i = base; i < Math.min(base + 10_000, OWNERS); i++) bulk.push(db.set(`u:${i}`, { n: i })); + await Promise.all(bulk); + } + let next = OWNERS; + for (const size of [1, 10, 100]) { + const lat = []; + for (let r = 0; r < 30; r++) { + // Update existing keys with fresh unique values (no conflicts), so the + // check must logically vacate the touched keys' old owners. + const ops = []; + for (let i = 0; i < size; i++) ops.push({ op: 'set', key: `u:${r % OWNERS}`, value: { n: next++ } }); + const t0 = performance.now(); + await db.batch(ops); + lat.push(performance.now() - t0); + } + lat.sort((a, b) => a - b); + console.log( + ` batch size=${String(size).padEnd(5)}`.padEnd(24), + `p50 ${percentile(lat, 50).toFixed(2)} ms p95 ${percentile(lat, 95).toFixed(2)} ms max ${lat[lat.length - 1].toFixed(2)} ms`, + ); + } + await db.close(); + } finally { + await fs.rm(dir, { recursive: true, force: true }); + } +} + +/** Write pauses with a large live TTL set and maxMemoryBytes accounting on. */ +async function ttlWritePauseScenario() { + const TTL_N = Number(process.env.TTL_N || 100_000); + const WRITES = Number(process.env.TTL_WRITES || 2_000); + console.log(`\n -- TTL write pause, ${fmt(TTL_N)} live TTL records, ${fmt(WRITES)} writes --`); + const dir = await tmpDir(); + try { + // Budget comfortably fits everything: the rejection path is not what is + // being measured — the per-write expiry sweep is. + const db = await MiniDb.open({ + dir, + valueCodec: 'json', + fsyncPolicy: 'no', + autoCompact: false, + maxMemoryBytes: 512 * 1024 * 1024, + }); + for (let base = 0; base < TTL_N; base += 10_000) { + const bulk = []; + for (let i = base; i < Math.min(base + 10_000, TTL_N); i++) { + bulk.push(db.set(`ttl:${i}`, { v: i }, { ttl: 3_600_000 })); + } + await Promise.all(bulk); + } + const lat = []; + for (let i = 0; i < WRITES; i++) { + const t0 = performance.now(); + await db.set(`w:${i}`, { v: i }); + lat.push(performance.now() - t0); + } + lat.sort((a, b) => a - b); + console.log( + ` plain writes`.padEnd(24), + `p50 ${percentile(lat, 50).toFixed(2)} ms p95 ${percentile(lat, 95).toFixed(2)} ms max ${lat[lat.length - 1].toFixed(2)} ms`, + ); + await db.close(); + } finally { + await fs.rm(dir, { recursive: true, force: true }); + } +} + async function main() { const N = Number(process.env.N || 50_000); const ITERS = Number(process.env.ITERS || 200); @@ -88,6 +301,15 @@ async function main() { await db.close(); await fs.rm(dir, { recursive: true, force: true }); + + // ONLY= selects a subset of the phase-2 scenarios (e.g. ONLY=topk). + const only = (process.env.ONLY ?? '').toLowerCase(); + const pick = (name: string) => only === '' || name.includes(only); + if (pick('open')) await coldOpenScenario(); + if (pick('topk')) await topKScenario(); + if (pick('delta')) await deltaOverwriteScenario(); + if (pick('unique')) await uniqueBatchScenario(); + if (pick('ttl')) await ttlWritePauseScenario(); console.log('\ndone.\n'); } diff --git a/packages/minidb/bench/reader-catchup.ts b/packages/minidb/bench/reader-catchup.ts index e6aa6c37389..00bc03cfbc1 100644 --- a/packages/minidb/bench/reader-catchup.ts +++ b/packages/minidb/bench/reader-catchup.ts @@ -44,6 +44,7 @@ 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'; import { ClusterDb, shardDirName } from '../src/cluster/index.js'; import { shardFor } from '../src/cluster/utils.js'; @@ -119,6 +120,7 @@ function preloadKeys(n: number): string[] { interface Row { keys: number; buildMs: number; + fullReopenMs: number; reads: number; qps: number; p50: number; @@ -140,6 +142,20 @@ async function benchSize(n: number): Promise { await runWorker(['preload', dir, String(SHARDS), String(HOT_SHARD), String(n), String(VALUE_BYTES)]); const buildMs = performance.now() - t0; + // Full-reopen cost of the built shard: what a reader pays when incremental + // catch-up cannot serve (first attach, fingerprint mismatch, forced + // resync). Median of three cold read-only opens of the hot shard. + const shardDir = path.join(dir, shardDirName(HOT_SHARD, SHARDS)); + const reopens: number[] = []; + for (let r = 0; r < 3; r++) { + const t = performance.now(); + const shard = await MiniDb.open({ dir: shardDir, valueCodec: 'json', readOnly: true }); + reopens.push(performance.now() - t); + await shard.close(); + } + reopens.sort((a, b) => a - b); + const fullReopenMs = reopens[1]!; + 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]!); @@ -189,6 +205,7 @@ async function benchSize(n: number): Promise { return { keys: n, buildMs, + fullReopenMs, reads: lat.length, qps: (lat.length / windowMs) * 1000, p50: percentile(lat, 50), @@ -215,17 +232,17 @@ async function main(): Promise { const row = await benchSize(n); rows.push(row); console.log( - `built in ${fmt(row.buildMs)}ms; reads=${fmt(row.reads)} (${fmt(row.qps)}/s), ` + + `built in ${fmt(row.buildMs)}ms; fullReopen=${row.fullReopenMs.toFixed(1)}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)}`); + console.log(`\n ${'keys'.padStart(8)} | ${'build'.padStart(8)} | ${'reopen'.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)}`, + ` ${fmt(r.keys).padStart(8)} | ${fmt(r.buildMs).padStart(8)} | ${r.fullReopenMs.toFixed(1).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(''); diff --git a/packages/minidb/src/compound-index.ts b/packages/minidb/src/compound-index.ts index d6c9303ac5c..bb54a879928 100644 --- a/packages/minidb/src/compound-index.ts +++ b/packages/minidb/src/compound-index.ts @@ -84,35 +84,38 @@ export class CompoundIndexManager { return typeof order === 'string'; } - /** Add/update a document across all compound indexes. */ - add(pk: string, doc: unknown, dt: Record | null): void { - for (const entry of this.indexes.values()) { - const { group, order } = this.extract(entry, doc, dt); - const prev = entry.byPk.get(pk); - const valid = group !== undefined && group !== null && this.validOrder(entry, order); - - // No-op when placement is unchanged. Without this guard, re-setting a key - // with the same group+order inserted a duplicate skiplist node (and a - // later delete left a phantom entry behind). - if (prev && valid && prev.group === group && prev.order === order) continue; - - if (prev) { - const oldList = entry.groups.get(prev.group); - if (oldList) { - oldList.delete(prev.order, pk); - if (oldList.length === 0) entry.groups.delete(prev.group); - } + /** Add/update a document in one compound index entry. */ + private addToEntry(entry: CompoundEntry, pk: string, doc: unknown, dt: Record | null): void { + const { group, order } = this.extract(entry, doc, dt); + const prev = entry.byPk.get(pk); + const valid = group !== undefined && group !== null && this.validOrder(entry, order); + + // No-op when placement is unchanged. Without this guard, re-setting a key + // with the same group+order inserted a duplicate skiplist node (and a + // later delete left a phantom entry behind). + if (prev && valid && prev.group === group && prev.order === order) return; + + if (prev) { + const oldList = entry.groups.get(prev.group); + if (oldList) { + oldList.delete(prev.order, pk); + if (oldList.length === 0) entry.groups.delete(prev.group); } + } - if (valid) { - this.groupOf(entry, group).insert(order, pk); - entry.byPk.set(pk, { group, order }); - } else { - entry.byPk.delete(pk); - } + if (valid) { + this.groupOf(entry, group).insert(order, pk); + entry.byPk.set(pk, { group, order }); + } else { + entry.byPk.delete(pk); } } + /** Add/update a document across all compound indexes. */ + add(pk: string, doc: unknown, dt: Record | null): void { + for (const entry of this.indexes.values()) this.addToEntry(entry, pk, doc, dt); + } + remove(pk: string, _doc?: unknown, _dt?: Record | null): void { for (const entry of this.indexes.values()) { const prev = entry.byPk.get(pk); @@ -141,12 +144,30 @@ export class CompoundIndexManager { /** Rebuild from entries of { key, value, dt }. */ rebuild(entries: Iterable<{ key: string | Buffer; value: unknown; dt?: Record | null }>): void { - for (const entry of this.indexes.values()) { - entry.groups.clear(); - entry.byPk.clear(); - } + const b = this.beginRebuild(); for (const { key, value, dt } of entries) { - this.add(typeof key === 'string' ? key : Buffer.from(key).toString('binary'), value, dt ?? null); + b.add(typeof key === 'string' ? key : Buffer.from(key).toString('binary'), value, dt ?? null); } + b.commit(); + } + + /** Stage a rebuild in fresh per-index state and swap it in on commit(), so + * a rebuild that fails midway leaves the previous indexes fully intact. */ + beginRebuild(): { add(pk: string, doc: unknown, dt: Record | null): void; commit(): void } { + const staged: { entry: CompoundEntry; next: CompoundEntry }[] = []; + for (const entry of this.indexes.values()) { + staged.push({ entry, next: { ...entry, groups: new Map(), byPk: new Map() } }); + } + return { + add: (pk, doc, dt) => { + for (const { next } of staged) this.addToEntry(next, pk, doc, dt); + }, + commit: () => { + for (const { entry, next } of staged) { + entry.groups = next.groups; + entry.byPk = next.byPk; + } + }, + }; } } diff --git a/packages/minidb/src/dt-index.ts b/packages/minidb/src/dt-index.ts index d01ba7fd8dc..28ce03fd44b 100644 --- a/packages/minidb/src/dt-index.ts +++ b/packages/minidb/src/dt-index.ts @@ -19,8 +19,8 @@ export interface DtRangeEntry { } export class DtIndex { - private readonly cols = new Map(); // col -> column - private readonly byKey = new Map>(); // key -> { col: ms } + private cols = new Map(); // col -> column + private byKey = new Map>(); // key -> { col: ms } private col(name: string): DtColumn { let c = this.cols.get(name); @@ -97,10 +97,39 @@ export class DtIndex { /** Rebuild from an iterator of { key, dt }. */ rebuild(entries: Iterable<{ key: string; dt: Record | null | undefined }>): void { - this.cols.clear(); - this.byKey.clear(); - for (const { key, dt } of entries) { - if (dt) this.set(key, dt); - } + const b = this.beginRebuild(); + for (const { key, dt } of entries) b.add(key, dt); + b.commit(); + } + + /** Stage a rebuild in fresh state and swap it in on commit(), so a rebuild + * that fails midway leaves the previous index fully intact. Rebuild keys + * are unique (one store record each), so add() is a pure insert — the + * diff-based set() logic is not needed here. */ + beginRebuild(): { add(key: string, dt: Record | null | undefined): void; commit(): void } { + const cols = new Map(); + const byKey = new Map>(); + return { + add: (key, dt) => { + if (!dt) return; + const rec: Record = {}; + for (const [name, ms] of Object.entries(dt)) { + if (typeof ms !== 'number' || !Number.isFinite(ms)) continue; + let c = cols.get(name); + if (!c) { + c = { list: new SkipList({ compareKey: cmpNumber, compareVal: cmpString }), byKey: new Map() }; + cols.set(name, c); + } + c.list.insert(ms, key); + c.byKey.set(key, ms); + rec[name] = ms; + } + if (Object.keys(rec).length) byKey.set(key, rec); + }, + commit: () => { + this.cols = cols; + this.byKey = byKey; + }, + }; } } diff --git a/packages/minidb/src/index-manager.ts b/packages/minidb/src/index-manager.ts index d426db68964..6fe49b0213e 100644 --- a/packages/minidb/src/index-manager.ts +++ b/packages/minidb/src/index-manager.ts @@ -76,6 +76,54 @@ function flatten(value: unknown): unknown[] { return Array.isArray(value) ? value : [value]; } +/** A live-index holder of a batch-claimed value must be the claimant itself + * or a key the batch vacates (deletes, or overwrites with a doc that no + * longer carries the value); anything else is a final-state conflict. The + * "touched and still claims it" sub-case never reaches a verdict here: it is + * detected by the batch-local claim map when the holder's own final claims + * are checked, so a 'set' final op is always treated as vacated at this + * point. */ +function assertVacated( + idx: AnyIndex, + holder: string, + claimant: string, + value: unknown, + lastOp: ReadonlyMap, +): void { + if (holder === claimant) return; + const fin = lastOp.get(holder); + if (!fin) throw new UniqueViolationError(idx.name, value); + // fin.op === 'del': vacated. fin.op === 'set': either it still claims the + // value (the batch-local claim map throws on the holder's own entry) or it + // moved away — vacated either way for this claimant. +} + +/** Insert one doc into an index's given state (shared by the incremental + * write path and staged rebuilds). */ +function insertDoc(idx: AnyIndex, pk: string, doc: unknown): void { + const value = getField(doc, idx.field); + if (value === undefined && idx.sparse) return; + if (idx.type === 'range') { + // Index each distinct numeric element once. Without the de-dupe, an + // array like [10, 10, 10] would insert three (10, pk) nodes and the + // same key would be reported three times by findRange. + const vals = [...new Set(flatten(value).filter((v): v is number => typeof v === 'number' && Number.isFinite(v)))]; + if (vals.length === 0) return; + for (const v of vals) idx.list.insert(v, pk); + idx.byPk.set(pk, vals); + } else { + const keys: string[] = []; + for (const v of flatten(value)) { + const sk = scalarKey(v); + let set = idx.map.get(sk); + if (!set) idx.map.set(sk, (set = new Set())); + set.add(pk); + keys.push(sk); + } + idx.byPk.set(pk, keys); + } +} + export class IndexManager { readonly indexes = new Map(); @@ -140,50 +188,51 @@ export class IndexManager { } /** - * Validate unique constraints for a batch of ops by computing the index state - * AFTER the whole batch and checking it for collisions. The check is - * order-independent, so valid transformations like swapping a unique value - * between two keys, or deleting one key and reusing its value in another, - * are accepted (their final state is still unique). + * Validate unique constraints for a batch of ops against the index state + * AFTER the whole batch. The check is order-independent, so valid + * transformations like swapping a unique value between two keys, or deleting + * one key and reusing its value in another, are accepted (their final state + * is still unique). * * `ops` is the full op list (set AND del); the last op per key wins. + * + * Incremental: for every value claimed by the batch it probes only that + * value's current posting (O(1) equality / O(log N) range per value) and a + * batch-local claim map — it never copies the full per-index owner state, + * so a small batch stays cheap on a large index. */ checkUniqueBatch(ops: readonly { pk: string; op: 'set' | 'del'; doc: unknown }[]): void { const lastOp = new Map(); for (const o of ops) lastOp.set(o.pk, o); - const touched = new Set(lastOp.keys()); for (const idx of this.indexes.values()) { if (!idx.unique) continue; - if (idx.type === 'range') { - const owner = new Map(); - for (const [pk, vals] of idx.byPk) { - if (touched.has(pk)) continue; - for (const v of vals) owner.set(v, pk); - } - for (const [pk, o] of lastOp) { - if (o.op === 'del') continue; - for (const v of flatten(getField(o.doc, idx.field))) { + // Batch-local claims: value -> claiming pk. Two different keys finally + // claiming the same value is a conflict regardless of the live index. + // This also covers the "holder is touched and still claims the value" + // case: the holder's own final claims pass through this same map. + const claimed = new Map(); + for (const [pk, o] of lastOp) { + if (o.op === 'del') continue; + const value = getField(o.doc, idx.field); + if (value === undefined && idx.sparse) continue; + for (const v of flatten(value)) { + if (idx.type === 'range') { if (typeof v !== 'number' || !Number.isFinite(v)) continue; - const prev = owner.get(v); + const prev = claimed.get(v); if (prev !== undefined && prev !== pk) throw new UniqueViolationError(idx.name, v); - owner.set(v, pk); - } - } - } else { - const owner = new Map(); - for (const [sk, set] of idx.map) { - for (const pk of set) if (!touched.has(pk)) owner.set(sk, pk); - } - for (const [pk, o] of lastOp) { - if (o.op === 'del') continue; - const value = getField(o.doc, idx.field); - if (value === undefined && idx.sparse) continue; - for (const v of flatten(value)) { + claimed.set(v, pk); + // Current holder in the live index, if any: a conflict unless it + // is the claimant itself or a key the batch vacates. + const hit = idx.list.range({ gte: v, lte: v, count: 1 }); + if (hit.length) assertVacated(idx, hit[0]!.val, pk, v, lastOp); + } else { const sk = scalarKey(v); - const prev = owner.get(sk); + const prev = claimed.get(sk); if (prev !== undefined && prev !== pk) throw new UniqueViolationError(idx.name, v); - owner.set(sk, pk); + claimed.set(sk, pk); + const set = idx.map.get(sk); + if (set) for (const h of set) assertVacated(idx, h, pk, v, lastOp); } } } @@ -218,29 +267,7 @@ export class IndexManager { } add(pk: string, doc: unknown): void { - for (const idx of this.indexes.values()) { - const value = getField(doc, idx.field); - if (value === undefined && idx.sparse) continue; - if (idx.type === 'range') { - // Index each distinct numeric element once. Without the de-dupe, an - // array like [10, 10, 10] would insert three (10, pk) nodes and the - // same key would be reported three times by findRange. - const vals = [...new Set(flatten(value).filter((v): v is number => typeof v === 'number' && Number.isFinite(v)))]; - if (vals.length === 0) continue; - for (const v of vals) idx.list.insert(v, pk); - idx.byPk.set(pk, vals); - } else { - const keys: string[] = []; - for (const v of flatten(value)) { - const sk = scalarKey(v); - let set = idx.map.get(sk); - if (!set) idx.map.set(sk, (set = new Set())); - set.add(pk); - keys.push(sk); - } - idx.byPk.set(pk, keys); - } - } + for (const idx of this.indexes.values()) insertDoc(idx, pk, doc); } remove(pk: string, _doc: unknown): void { @@ -306,18 +333,37 @@ export class IndexManager { /** Rebuild all indexes from an iterator of { key, value } (value = decoded doc). */ rebuild(entries: Iterable<{ key: string | Buffer; value: unknown }>): void { - for (const idx of this.indexes.values()) { - if (idx.type === 'range') { - idx.list = new SkipList({ compareKey: cmpNumber, compareVal: cmpString }); - idx.byPk.clear(); - } else { - idx.map.clear(); - idx.byPk.clear(); - } - } + const b = this.beginRebuild(); for (const { key, value } of entries) { const pk = typeof key === 'string' ? key : Buffer.from(key).toString('binary'); - if (value && typeof value === 'object') this.add(pk, value); + b.add(pk, value); + } + b.commit(); + } + + /** Stage a rebuild in fresh per-index state and swap it in on commit(), so + * a rebuild that fails midway leaves the previous indexes fully intact. */ + beginRebuild(): { add(pk: string, doc: unknown): void; commit(): void } { + const staged: { idx: AnyIndex; next: AnyIndex }[] = []; + for (const idx of this.indexes.values()) { + const next: AnyIndex = + idx.type === 'range' + ? { ...idx, list: new SkipList({ compareKey: cmpNumber, compareVal: cmpString }), byPk: new Map() } + : { ...idx, map: new Map(), byPk: new Map() }; + staged.push({ idx, next }); } + return { + add: (pk, doc) => { + if (!doc || typeof doc !== 'object') return; + for (const { next } of staged) insertDoc(next, pk, doc); + }, + commit: () => { + for (const { idx, next } of staged) { + if (idx.type === 'range' && next.type === 'range') idx.list = next.list; + else if (idx.type === 'equality' && next.type === 'equality') idx.map = next.map; + idx.byPk = next.byPk; + } + }, + }; } } diff --git a/packages/minidb/src/index.ts b/packages/minidb/src/index.ts index ac77d865d13..bf146ccc209 100644 --- a/packages/minidb/src/index.ts +++ b/packages/minidb/src/index.ts @@ -17,7 +17,7 @@ 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'; -import { TextIndex, type TextIndexOptions } from './text-index.js'; +import { TextIndex, type TextIndexOptions, type TextIndexBuild } from './text-index.js'; import { createNgramTokenizer } from './trigram.js'; import { CompoundIndexManager } from './compound-index.js'; import { getPath, match, project } from './query.js'; @@ -45,6 +45,12 @@ export type { TextIndexTokenizerName } from './trigram.js'; export type ValueCodecName = 'buffer' | 'string' | 'json'; +const yieldToLoop = (): Promise => new Promise((r) => setImmediate(r)); +/** The open-time index rebuild yields to the event loop every this many + * records, so a huge Store walk never hard-blocks the host (mirrors the + * BUILD_YIELD_DOCS watermark in text-index.ts). */ +const REBUILD_YIELD_DOCS = 2048; + export interface ValueCodec { encode(v: V): Buffer; decode(b: Buffer): V; @@ -304,6 +310,9 @@ export class MiniDb { recoveryDurationMs: 0, /** Open-time derived-index rebuilds (secondary + dt + compound). */ indexRebuildDurationMs: 0, + /** Values decoded by the open-time shared rebuild walk (0 when no + * value-derived index exists: the walk is metadata-only then). */ + indexRebuildDecoded: 0, /** Text-index (re)builds: at open and after each compaction. */ textRebuildDurationMs: 0, /** Whole successful compactions, hook included. */ @@ -560,14 +569,56 @@ export class MiniDb { } private async rebuildAllIndexes(): Promise { + // One Store walk feeds every staged builder. dt comes from record metadata + // (never decoded); the value is decoded at most once per record and only + // when a value-derived index (secondary / compound / text) actually exists + // — an index-less open performs a metadata-only walk. + const dtB = this.dt.beginRebuild(); + const secB = this.indexes.indexes.size ? this.indexes.beginRebuild() : null; + const cmpB = this.compound.indexes.size ? this.compound.beginRebuild() : null; + const textBs: { b: TextIndexBuild }[] = []; + for (const [, ti] of this.text) textBs.push({ b: ti.beginBuild() }); + const needValues = secB !== null || cmpB !== null || textBs.length > 0; + const t0 = performance.now(); - this.indexes.rebuild(this._liveRecordsRaw()); - this.dt.rebuild([...this.liveRecords()].map(({ key, dt }) => ({ key: this.pk(key), dt }))); - this.compound.rebuild(this.liveRecords()); + let docsSinceYield = 0; + try { + for (const rec of this.store.rawRecords()) { + // Yield periodically so a huge open-time rebuild never hard-blocks the + // event loop; safe because open() awaits this before publishing the + // db or starting the background compaction. + if (++docsSinceYield >= REBUILD_YIELD_DOCS) { + docsSinceYield = 0; + await yieldToLoop(); + } + dtB.add(rec.kstr, rec.dt); + if (!needValues) continue; + const value = this.decode(rec.readValue()); + this.stats.indexRebuildDecoded++; + secB?.add(rec.kstr, value); + cmpB?.add(rec.kstr, value, rec.dt); + if (this.indexable(value)) for (const { b } of textBs) b.add(rec.kstr, value); + } + } catch (e) { + for (const { b } of textBs) b.abort(); + throw e; + } this.stats.indexRebuildDurationMs += performance.now() - t0; + + // Commit the fallible builders first (text postings do file I/O); the + // in-memory swaps cannot fail. A text commit failure leaves every + // not-yet-committed builder on its previous state. const t1 = performance.now(); - for (const [, ti] of this.text) await ti.build(this.textRecords()); + try { + for (const { b } of textBs) await b.commit(); + } catch (e) { + for (const { b } of textBs) b.abort(); + throw e; + } this.stats.textRebuildDurationMs += performance.now() - t1; + secB?.commit(); + cmpB?.commit(); + dtB.commit(); } private *_liveRecordsRaw(): Generator<{ key: Buffer; value: unknown }> { @@ -758,7 +809,9 @@ export class MiniDb { private async ensureMemoryFor(ops: readonly PreparedOp[]): Promise { if (this.maxMemoryBytes === null) return; - this.store.reapExpired(); + // Drain due TTL entries via the store's expiry heap (O(due)) instead of a + // full-store sweep on every write. + this.store.reapExpiredDue(); let projected = this.projectedBytesForOps(ops); if (projected <= this.maxMemoryBytes) return; diff --git a/packages/minidb/src/store.ts b/packages/minidb/src/store.ts index 1cd8b6682fb..745eb5ca4ff 100644 --- a/packages/minidb/src/store.ts +++ b/packages/minidb/src/store.ts @@ -31,6 +31,17 @@ export interface StoreEntry { export type ValueReader = (loc: ValueLoc) => Buffer; +/** Internal raw-record view used by derived-index rebuilds: the canonical + * (byte-string) key, the dt metadata, and a lazy value reader. Nothing is + * materialized unless readValue() is called, so a rebuild that only needs + * metadata never copies a buffer (memory mode) or issues a positioned read + * (disk mode). */ +export interface RawRecord { + kstr: string; + dt: Record | null; + readValue: () => Buffer; +} + const toKStr = (key: string | Buffer): string => typeof key === 'string' ? key : Buffer.from(key).toString('binary'); const fromKStr = (kstr: string): Buffer => Buffer.from(kstr, 'binary'); @@ -291,6 +302,18 @@ export class Store { } } + /** Walk live records without materializing values: yields the canonical key, + * dt metadata, and a lazy value reader. Expired records are skipped (not + * reaped) exactly as in entries(). Internal to the package — derived-index + * rebuilds use it to share a single walk and a single decode per record. */ + *rawRecords(): Generator { + const now = Date.now(); + for (const [k, r] of this.map) { + if (r.expireAt && r.expireAt <= now) continue; + yield { kstr: k, dt: r.dt, readValue: () => this.materialize(r.ref) }; + } + } + /** Ordered scan over keys. */ *scan(opts: RangeOptions = {}): Generator { for (const n of this.order.range(opts) as Iterable>) { @@ -364,6 +387,27 @@ export class Store { return n; } + /** Reap already-expired records via the TTL min-heap: O(due + stale heap + * entries) instead of the O(store) full scan of reapExpired(), so callers + * on the write hot path do not pay a full-store sweep per call. Falls back + * to the full scan only when the heap provably diverged from the map (live + * TTL records remain but the heap is empty — an invariant no code path may + * produce), resyncing instead of leaking expired bytes. */ + reapExpiredDue(): number { + const now = Date.now(); + let n = 0; + while (this.heap.size && this.heap.peek()!.t <= now) { + 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); + n++; + } + } + if (this.expiring > 0 && this.heap.size === 0) return n + this.reapExpired(); + return n; + } + /** Stop the active-expiration timer. */ close(): void { if (this.timer) { diff --git a/packages/minidb/src/text-index.ts b/packages/minidb/src/text-index.ts index 3830b9a0a52..ac2425c17a4 100644 --- a/packages/minidb/src/text-index.ts +++ b/packages/minidb/src/text-index.ts @@ -102,6 +102,15 @@ export interface SearchOptions { limit?: number; } +/** Staged text-index rebuild (see TextIndex.beginBuild): feed docs with + * add(), swap everything in with commit(), or discard with abort(). */ +export interface TextIndexBuild { + /** Stage one document; returns its token count (feeds yield watermarks). */ + add(key: string, value: unknown): number; + commit(): Promise; + abort(): void; +} + const EMPTY_MAP: ReadonlyMap = new Map(); /** One write that landed while a `build()` was in flight (see buildQueue). */ @@ -109,6 +118,55 @@ type BuildOp = | { readonly kind: 'add'; readonly key: string; readonly doc: unknown } | { readonly kind: 'remove'; readonly key: string }; +/** Bounded collector for the K best hits by (score desc, key asc). The heap + * root holds the WORST kept hit, so a new candidate enters only when it beats + * that root — O(log K) per candidate and K kept in memory, instead of an + * O(C log C) full sort over every candidate. The key tie-break keeps the + * order of equal-score hits stable across paginated queries. */ +class TopK { + private readonly a: SearchHit[] = []; + + constructor(private readonly k: number) {} + + /** x ranks strictly after y (smaller score, or equal score with larger key). */ + private static worse(x: SearchHit, y: SearchHit): boolean { + return x.score < y.score || (x.score === y.score && x.key > y.key); + } + + offer(hit: SearchHit): void { + const a = this.a; + if (a.length < this.k) { + a.push(hit); + let i = a.length - 1; + while (i > 0) { + const p = (i - 1) >> 1; + if (!TopK.worse(a[i]!, a[p]!)) break; // parent is worse -> heap holds + [a[p], a[i]] = [a[i]!, a[p]!]; + i = p; + } + return; + } + if (this.k === 0 || !TopK.worse(a[0]!, hit)) return; // must beat the worst kept + a[0] = hit; + let i = 0; + for (;;) { + let w = i; // index of the worst among {i, left, right} + const l = 2 * i + 1; + const r = l + 1; + if (l < a.length && TopK.worse(a[l]!, a[w]!)) w = l; + if (r < a.length && TopK.worse(a[r]!, a[w]!)) w = r; + if (w === i) break; + [a[w], a[i]] = [a[i]!, a[w]!]; + i = w; + } + } + + /** The kept hits in final rank order: score descending, key ascending. */ + sorted(): SearchHit[] { + return this.a.sort((x, y) => y.score - x.score || (x.key < y.key ? -1 : x.key > y.key ? 1 : 0)); + } +} + export class TextIndex { private readonly fields: readonly string[] | null; private readonly tokenizer: (text: string) => string[]; @@ -129,6 +187,10 @@ export class TextIndex { private readonly delta = new Map>(); // term -> (docID -> freq) private deltaCount = 0; private readonly removed = new Set(); // tombstoned docIDs + // Reverse view of the delta: docID -> its distinct delta terms. remove() + // walks only the removed doc's own terms through it, instead of scanning + // every term in the delta vocabulary. + private readonly deltaDocs = new Map>(); /** * Ops that landed while a `build()` was in flight. The ops ALSO apply to @@ -208,35 +270,71 @@ export class TextIndex { * replaces the in-memory base (memory mode), and clears the delta + * tombstones. Called on open and on compaction. * - * Async and event-loop friendly: the tokenization pass yields every + * Async and event-loop friendly: the feeding loop yields every * BUILD_YIELD_DOCS docs / BUILD_YIELD_TOKENS tokens and the postings write * batches its I/O, so a large rebuild never hard-blocks the host process - * for many seconds the way the old fully-synchronous build did. Mutations - * arriving mid-build keep applying to the live view (searches stay correct) - * and are recorded in `buildQueue`; once the new base is swapped in, the - * queue is replayed synchronously, so the result is exactly as if those ops - * had arrived after the rebuild. + * for many seconds the way the old fully-synchronous build did. + */ + async build(entries: Iterable<{ key: string; value: unknown }>): Promise { + const b = this.beginBuild(); + let docsSinceYield = 0; + let tokensSinceYield = 0; + try { + for (const { key, value } of entries) { + tokensSinceYield += b.add(key, value); + docsSinceYield++; + if (docsSinceYield >= BUILD_YIELD_DOCS || tokensSinceYield >= BUILD_YIELD_TOKENS) { + docsSinceYield = 0; + tokensSinceYield = 0; + await yieldToLoop(); + } + } + } catch (e) { + b.abort(); + throw e; + } + await b.commit(); + } + + /** + * Stage a rebuild: accumulate docs incrementally, then swap everything in on + * commit(). Lets the caller feed several indexes from one shared Store walk + * (one decode per record fanned out to every builder). add() is synchronous + * (pure tokenization) — a caller feeding many docs should yield to the + * event loop periodically (see build()); commit() is async (batched + * postings I/O). + * + * Mutations arriving while the build is staged keep applying to the live + * view (searches stay correct) and are recorded in `buildQueue`; once the + * new base is swapped in, commit() replays the queue synchronously, so the + * result is exactly as if those ops had arrived after the rebuild. * * 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 PostingsFile.rebuild) - * leaves the PREVIOUS index fully functional instead of silently emptying it - * until the next successful build. + * failed commit (e.g. a transient ENOSPC/EMFILE inside PostingsFile.rebuild) + * leaves the PREVIOUS index fully functional instead of silently emptying + * it until the next successful build. abort() discards the staged state + * (nothing is written before commit() runs). */ - async build(entries: Iterable<{ key: string; value: unknown }>): Promise { + beginBuild(): TextIndexBuild { if (this.buildQueue !== null) throw new Error('text index build already in progress'); const queue: BuildOp[] = []; this.buildQueue = queue; - try { - // 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(); // docID -> token count - let n = 0; - let docsSinceYield = 0; - let tokensSinceYield = 0; - for (const { key, value } of entries) { + // 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(); // docID -> token count + let n = 0; + let done = false; + // Failure/abort paths only disarm their own queue: the ops in it were + // already applied to the live view, which stays authoritative. + const disarm = (): void => { + if (this.buildQueue === queue) this.buildQueue = null; + }; + return { + add: (key, value): number => { + if (done) throw new Error('text index build already finished'); const docID = newKeys.length; newKeys.push(key); newKeyToId.set(key, docID); @@ -250,83 +348,103 @@ export class TextIndex { } newDocLen.set(docID, tokens.length); n++; - docsSinceYield++; - tokensSinceYield += tokens.length; - if (docsSinceYield >= BUILD_YIELD_DOCS || tokensSinceYield >= BUILD_YIELD_TOKENS) { - docsSinceYield = 0; - tokensSinceYield = 0; - await yieldToLoop(); - } - } - - if (this.path) { - // Disk mode: write the new postings file (tmp + fsync + atomic rename - // in PostingsFile.rebuild). The old read handle is closed only at the - // rename — and only on Windows, where an open fd would block it (POSIX - // keeps the old inode readable through the rename, so searches never - // lose the base). A rebuild that throws before its commit leaves the - // old file/handle untouched; a commit-time failure re-attaches it. - const oldPf = this.pf; - let dict: Map; + return tokens.length; + }, + commit: async () => { + if (done) throw new Error('text index build already finished'); + done = true; try { - dict = await PostingsFile.rebuild(this.path, aggToSorted(agg), { - beforeRename: - process.platform === 'win32' && oldPf !== null - ? () => { - oldPf.close(); - if (this.pf === oldPf) this.pf = null; - } - : undefined, - }); + await this.commitBuild(queue, agg, newKeys, newKeyToId, newDocLen, n); } catch (e) { - if (oldPf !== null && !oldPf.open) { - try { - this.pf = PostingsFile.open(this.path); - } catch { - /* old handle unrecoverable; the next successful build fixes it */ - } - } + // Staging never touched the live view, so the previous index is + // intact; the queued ops were already applied to it — just disarm. + disarm(); throw e; } - // The rename happened — the old postings are replaced on disk, so from - // here the swap commits to the new index. A failed reopen (EMFILE & co.) - // is not special-cased: readBase treats a null pf as an empty base, so - // reads degrade to delta-only until the next build instead of reading - // through a stale dictionary. - const newPf = PostingsFile.open(this.path); - this.postings.clear(); - for (const [t, e] of dict) this.postings.set(t, e); - oldPf?.close(); - this.pf = newPf; - } else { - // Memory mode: pure in-memory staging, no fallible I/O involved. - this.memBase = agg; - } + }, + // Nothing staged on disk yet (the postings write only happens inside + // commit): disarming the queue and dropping the reference is the whole + // abort. + abort: () => { + if (done) return; + done = true; + disarm(); + }, + }; + } - // Swap in the staged per-doc state, drop the write buffer, and replay - // the ops that landed mid-build onto the new base — one synchronous - // segment, so no mutation can interleave mid-swap. - this.docLen.clear(); - for (const [id, len] of newDocLen) this.docLen.set(id, len); - this.keys.length = 0; - for (const k of newKeys) this.keys.push(k); - this.keyToId.clear(); - for (const [k, id] of newKeyToId) this.keyToId.set(k, id); - this.delta.clear(); - this.deltaCount = 0; - this.removed.clear(); - this.cache.clear(); - this.N = n; - this.buildQueue = null; - for (const op of queue) { - if (op.kind === 'add') this.add(op.key, op.doc); - else this.remove(op.key); + /** Swap a fully staged build into the live index (see beginBuild). */ + private async commitBuild( + queue: BuildOp[], + agg: Map>, + newKeys: (string | undefined)[], + newKeyToId: Map, + newDocLen: Map, + n: number, + ): Promise { + if (this.path) { + // Disk mode: write the new postings file (tmp + fsync + atomic rename + // in PostingsFile.rebuild). The old read handle is closed only at the + // rename — and only on Windows, where an open fd would block it (POSIX + // keeps the old inode readable through the rename, so searches never + // lose the base). A rebuild that throws before its commit leaves the + // old file/handle untouched; a commit-time failure re-attaches it. + const oldPf = this.pf; + let dict: Map; + try { + dict = await PostingsFile.rebuild(this.path, aggToSorted(agg), { + beforeRename: + process.platform === 'win32' && oldPf !== null + ? () => { + oldPf.close(); + if (this.pf === oldPf) this.pf = null; + } + : undefined, + }); + } catch (e) { + if (oldPf !== null && !oldPf.open) { + try { + this.pf = PostingsFile.open(this.path); + } catch { + /* old handle unrecoverable; the next successful build fixes it */ + } + } + throw e; } - } catch (e) { - // Staging never touched the live view, so the previous index is intact; - // the queued ops were already applied to it — just disarm the queue. - if (this.buildQueue === queue) this.buildQueue = null; - throw e; + // The rename happened — the old postings are replaced on disk, so from + // here the swap commits to the new index. A failed reopen (EMFILE & co.) + // is not special-cased: readBase treats a null pf as an empty base, so + // reads degrade to delta-only until the next build instead of reading + // through a stale dictionary. + const newPf = PostingsFile.open(this.path); + this.postings.clear(); + for (const [t, e] of dict) this.postings.set(t, e); + oldPf?.close(); + this.pf = newPf; + } else { + // Memory mode: pure in-memory staging, no fallible I/O involved. + this.memBase = agg; + } + + // Swap in the staged per-doc state, drop the write buffer, and replay + // the ops that landed mid-build onto the new base — one synchronous + // segment, so no mutation can interleave mid-swap. + this.docLen.clear(); + for (const [id, len] of newDocLen) this.docLen.set(id, len); + this.keys.length = 0; + for (const k of newKeys) this.keys.push(k); + this.keyToId.clear(); + for (const [k, id] of newKeyToId) this.keyToId.set(k, id); + this.delta.clear(); + this.deltaCount = 0; + this.deltaDocs.clear(); + this.removed.clear(); + this.cache.clear(); + this.N = n; + this.buildQueue = null; + for (const op of queue) { + if (op.kind === 'add') this.add(op.key, op.doc); + else this.remove(op.key); } } @@ -349,11 +467,14 @@ export class TextIndex { m.set(docID, c); this.deltaCount++; } + this.deltaDocs.set(docID, new Set(counts.keys())); this.docLen.set(docID, tokens.length); this.N++; } - /** Remove a document by key (tombstone its docID). */ + /** Remove a document by key (tombstone its docID). Walks only the doc's own + * delta terms via the reverse map — O(terms in document), not O(all delta + * terms). */ remove(key: string): void { this.buildQueue?.push({ kind: 'remove', key }); this.removeInner(key); @@ -366,7 +487,15 @@ export class TextIndex { this.keyToId.delete(key); this.keys[id] = undefined; this.docLen.delete(id); - for (const m of this.delta.values()) if (m.delete(id)) this.deltaCount--; + const terms = this.deltaDocs.get(id); + if (terms) { + for (const t of terms) { + const m = this.delta.get(t); + if (m?.delete(id)) this.deltaCount--; + if (m && m.size === 0) this.delta.delete(t); + } + this.deltaDocs.delete(id); + } this.N--; } @@ -432,7 +561,7 @@ export class TextIndex { } } - const scored: SearchHit[] = []; + const top = new TopK(limit); for (const id of candidates) { const len = this.docLen.get(id) ?? 1; let score = 0; @@ -442,11 +571,10 @@ export class TextIndex { } if (score > 0) { const key = this.keys[id]; - if (key !== undefined) scored.push({ key, score }); + if (key !== undefined) top.offer({ key, score }); } } - scored.sort((a, b) => b.score - a.score); - return scored.slice(0, limit); + return top.sorted(); } /** Close the underlying postings file. */ diff --git a/packages/minidb/test/e2e/index-consistency.test.ts b/packages/minidb/test/e2e/index-consistency.test.ts index 361013ac772..0cfa528d63f 100644 --- a/packages/minidb/test/e2e/index-consistency.test.ts +++ b/packages/minidb/test/e2e/index-consistency.test.ts @@ -31,6 +31,10 @@ test('index-consistency: indexes stay consistent with the store under random ops await db.createIndex('byAge', { field: 'age', type: 'range' }); await db.createIndex('byEmail', { field: 'email', unique: true }); await db.createTextIndex('body', { fields: ['bio'] }); + // A second text index and a compound index exercise the shared rebuild + // walk's fan-out (one decode per record feeding every staged builder). + await db.createTextIndex('cityText', { fields: ['city'] }); + await db.createCompoundIndex('byCityAge', { groupBy: 'city', orderBy: 'age' }); const live = new Map(); // key -> doc (reference of live docs) try { @@ -78,6 +82,19 @@ test('index-consistency: indexes stay consistent with the store under random ops const expectedHits = [...live.entries()].filter(([, d]) => d.bio.includes(term)).map(([k]) => k).sort(); assert.deepEqual(hits, expectedHits, 'text search 北京'); + // 5b) second text index over the city field + const cityHits = db.search('cityText', 'Paris', { limit: 1000 }).map((r) => r.key).sort(); + const expectedCityHits = [...live.entries()].filter(([, d]) => d.city === 'Paris').map(([k]) => k).sort(); + assert.deepEqual(cityHits, expectedCityHits, 'text search city=Paris'); + + // 5c) compound index: group ordered by (age, key) + const parisOrdered = db.compoundRange('byCityAge', 'Paris').map((r) => r.key); + const expectedParis = [...live.entries()] + .filter(([, d]) => d.city === 'Paris') + .sort((a, b) => a[1].age - b[1].age || (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0)) + .map(([k]) => k); + assert.deepEqual(parisOrdered, expectedParis, 'compound Paris ordered by age'); + // 6) after rebuild on reopen, indexes still match await db.close(); db = await MiniDb.open({ dir, valueCodec: 'json', fsyncPolicy: 'no', autoCompact: false }); @@ -85,6 +102,20 @@ test('index-consistency: indexes stay consistent with the store under random ops const expected2 = [...live.entries()].filter(([, d]) => d.city === 'Paris').map(([k]) => k).sort(); assert.deepEqual(fromIdx2, expected2, 'byCity Paris after rebuild'); assert.deepEqual(db.scan().map((r) => r.key), expectedKeys, 'key order after rebuild'); + // The shared rebuild walk fanned out to every staged builder: all derived + // index families match the reference model again after reopen. + assert.deepEqual( + db.search('cityText', 'Paris', { limit: 1000 }).map((r) => r.key).sort(), + expectedCityHits, + 'cityText Paris after rebuild', + ); + assert.deepEqual( + db.search('body', term, { limit: 1000 }).map((r) => r.key).sort(), + expectedHits, + 'text search 北京 after rebuild', + ); + assert.deepEqual(db.compoundRange('byCityAge', 'Paris').map((r) => r.key), expectedParis, 'compound Paris after rebuild'); + assert.deepEqual(db.dtRange('created', { gte: 0 }).map((r) => r.key).sort(), expectedKeys, 'dt after rebuild'); } finally { await db.close().catch(() => {}); await rmrf(dir); diff --git a/packages/minidb/test/indexes-extra.test.ts b/packages/minidb/test/indexes-extra.test.ts index f9c0f9aaee1..e539e6bd6ba 100644 --- a/packages/minidb/test/indexes-extra.test.ts +++ b/packages/minidb/test/indexes-extra.test.ts @@ -118,3 +118,47 @@ test('secondary indexes require the json codec', async () => { await fs.rm(dir, { recursive: true, force: true }); } }); + +test('unique range index: batch swap, del+reuse, and conflict', async () => { + const dir = await tmpDir(); + try { + const db = await MiniDb.open({ dir, valueCodec: 'json' }); + await db.createIndex('byScore', { field: 'score', type: 'range', unique: true }); + await db.set('a', { score: 1 }); + await db.set('b', { score: 2 }); + + // Swapping two values inside one batch is a valid final state. + await db.batch([ + { op: 'set', key: 'a', value: { score: 2 } }, + { op: 'set', key: 'b', value: { score: 1 } }, + ]); + assert.equal(db.get('a')?.score, 2); + assert.equal(db.get('b')?.score, 1); + + // Claiming an untouched owner's value is still rejected, batch-atomically. + await assert.rejects(db.batch([{ op: 'set', key: 'c', value: { score: 2 } }]), UniqueViolationError); + assert.equal(db.get('c'), undefined, 'nothing committed on failure'); + + // Deleting the holder and reusing its value in the same batch is valid. + await db.batch([ + { op: 'del', key: 'a' }, + { op: 'set', key: 'c', value: { score: 2 } }, + ]); + assert.equal(db.get('a'), undefined); + assert.equal(db.get('c')?.score, 2); + + // Two batch keys claiming the same fresh value is an intra-batch conflict. + await assert.rejects( + db.batch([ + { op: 'set', key: 'd', value: { score: 9 } }, + { op: 'set', key: 'e', value: { score: 9 } }, + ]), + UniqueViolationError, + ); + assert.equal(db.get('d'), undefined); + assert.equal(db.get('e'), undefined); + await db.close(); + } finally { + await fs.rm(dir, { recursive: true, force: true }); + } +}); diff --git a/packages/minidb/test/stats.test.ts b/packages/minidb/test/stats.test.ts index 23f82002941..2c586b4669e 100644 --- a/packages/minidb/test/stats.test.ts +++ b/packages/minidb/test/stats.test.ts @@ -221,3 +221,42 @@ test('query stats count candidates, decodes and sorted rows', async () => { await fs.rm(dir, { recursive: true, force: true }); } }); + +test('index rebuild stats: values decoded once per record, 0 without value-derived indexes', async () => { + // No secondary/compound/text index: the open-time rebuild walk must be + // metadata-only (dt comes from record metadata, values are never decoded). + const dir = await tmpDir(); + let db = await MiniDb.open({ dir, valueCodec: 'json', fsyncPolicy: 'no', autoCompact: false }); + for (let i = 0; i < 20; i++) await db.set(`k${i}`, { n: i }, { dt: { created: i } }); + await db.close(); + db = await MiniDb.open({ dir, valueCodec: 'json', fsyncPolicy: 'no', autoCompact: false }); + try { + assert.equal(db.stats.indexRebuildDecoded, 0, 'no decodes without value-derived indexes'); + assert.equal(db.dtRange('created', { gte: 0 }).length, 20, 'dt index rebuilt from metadata alone'); + } finally { + await db.close(); + await fs.rm(dir, { recursive: true, force: true }); + } + + // With several value-derived indexes: exactly one decode per live record, + // fanned out to every staged builder in the shared walk. + const dir2 = await tmpDir(); + db = await MiniDb.open({ dir: dir2, valueCodec: 'json', fsyncPolicy: 'no', autoCompact: false }); + await db.createTextIndex('body', { fields: ['body'] }); + await db.createTextIndex('title', { fields: ['title'] }); + await db.createIndex('byN', { field: 'n' }); + await db.createCompoundIndex('byGrpN', { groupBy: 'grp', orderBy: 'n' }); + for (let i = 0; i < 20; i++) await db.set(`k${i}`, { n: i, grp: 'g', body: `b${i}`, title: `t${i}` }); + await db.close(); + db = await MiniDb.open({ dir: dir2, valueCodec: 'json', fsyncPolicy: 'no', autoCompact: false }); + try { + assert.equal(db.stats.indexRebuildDecoded, 20, 'one decode per record fanned out to all builders'); + assert.equal(db.search('body', 'b1').length, 1); + assert.equal(db.search('title', 't2').length, 1); + assert.equal(db.findEq('byN', 3).length, 1); + assert.equal(db.compoundRange('byGrpN', 'g').length, 20); + } finally { + await db.close(); + await fs.rm(dir2, { recursive: true, force: true }); + } +}); diff --git a/packages/minidb/test/store.test.ts b/packages/minidb/test/store.test.ts index 050706e3996..4c39250403e 100644 --- a/packages/minidb/test/store.test.ts +++ b/packages/minidb/test/store.test.ts @@ -168,3 +168,40 @@ test('active expiration drains a simultaneous-expiry storm within seconds', asyn assert.equal(s.map.size, 0); s.close(); }); + +test('reapExpiredDue drains only due entries via the TTL heap', () => { + const s = new Store({ activeExpireIntervalMs: 0 }); + s.set('expired', B('1'), Date.now() - 1000); + s.set('future', B('2'), Date.now() + 3_600_000); + s.set('plain', B('3')); + assert.equal(s.reapExpiredDue(), 1, 'only the due record is reaped'); + assert.ok(!s.has('expired')); + assert.ok(s.has('future')); + assert.ok(s.has('plain')); + // nothing due -> a no-op (and no full-store sweep needed) + assert.equal(s.reapExpiredDue(), 0); + assert.equal(s.map.size, 2); + s.close(); +}); + +test('reapExpiredDue skips stale heap entries from overwritten TTLs', () => { + const s = new Store({ activeExpireIntervalMs: 0 }); + const t1 = Date.now() - 1000; // already past + s.set('k', B('1'), t1); + s.set('k', B('2'), Date.now() + 3_600_000); // overwrite: the t1 heap entry is now stale + assert.equal(s.reapExpiredDue(), 0, 'the stale entry must not reap the live record'); + assert.equal(s.get('k')?.toString(), '2'); + s.close(); +}); + +test('reapExpiredDue falls back to a full scan when the heap diverged from the map', () => { + const s = new Store({ activeExpireIntervalMs: 0 }); + s.set('a', B('1'), Date.now() - 1000); + s.set('b', B('2'), Date.now() - 1000); + // Force the broken invariant (live TTL records, empty heap) the fallback + // guards against; no code path may produce this. + (s as unknown as { heap: { clear(): void } }).heap.clear(); + assert.equal(s.reapExpiredDue(), 2, 'divergence detected -> full resync reap'); + assert.equal(s.map.size, 0); + s.close(); +}); diff --git a/packages/minidb/test/text-index.test.ts b/packages/minidb/test/text-index.test.ts index 85f73b22f78..d01cdb47a7f 100644 --- a/packages/minidb/test/text-index.test.ts +++ b/packages/minidb/test/text-index.test.ts @@ -590,3 +590,83 @@ test('MiniDb: createTextIndex rejects an unknown tokenizer', async () => { await fs.rm(dir, { recursive: true, force: true }); } }); + +// ---- top-K + delta reverse map (plan/02 bounded hot paths) ---------------- + +test('TextIndex: top-K ranks score desc with a stable key tie-break', async () => { + const dir = await tmpDir(); + try { + const ti = new TextIndex({ postingsPath: path.join(dir, 't.postings') }); + // Every doc has the same term freq and the same doc length, so all scores + // are identical and the order must come from the key tie-break alone. + await ti.build([ + { key: 'k3', value: { bio: 'common pad' } }, + { key: 'k1', value: { bio: 'common pad' } }, + { key: 'k2', value: { bio: 'common pad' } }, + ]); + assert.deepEqual(ti.search('common', { limit: 2 }).map((h) => h.key), ['k1', 'k2'], 'equal scores -> key asc'); + assert.deepEqual(ti.search('common', { limit: 10 }).map((h) => h.key), ['k1', 'k2', 'k3']); + assert.deepEqual(ti.search('common', { limit: 0 }), [], 'limit 0 stays empty'); + ti.close(); + } finally { + await fs.rm(dir, { recursive: true, force: true }); + } +}); + +test('TextIndex: top-K over many candidates matches the full-ranking reference', async () => { + const dir = await tmpDir(); + try { + const ti = new TextIndex({ postingsPath: path.join(dir, 't.postings') }); + const entries: { key: string; value: { bio: string } }[] = []; + for (let i = 0; i < 500; i++) { + // Vary both term frequency (i % 7 + 1) and doc length (i % 11 pads), so + // scores differ across docs; a third of the docs carry no 'x' at all. + const reps = i % 3 === 0 ? 0 : (i % 7) + 1; + const body = `${'x '.repeat(reps)}${Array.from({ length: i % 11 }, (_, j) => `p${j}`).join(' ')}`.trim(); + entries.push({ key: `k${String(i).padStart(4, '0')}`, value: { bio: body } }); + } + await ti.build(entries); + + // The full ranking (limit above the candidate count returns everything). + const all = ti.search('x', { limit: 1_000_000 }); + const matching = entries.filter((e) => e.value.bio.includes('x')).length; + assert.equal(all.length, matching, 'unbounded search returns every scoring doc'); + for (let i = 1; i < all.length; i++) { + const [p, c] = [all[i - 1]!, all[i]!]; + assert.ok(p.score > c.score || (p.score === c.score && p.key < c.key), `rank order at ${i}`); + } + const rank = new Map(all.map((h, i) => [h.key, i])); + for (const limit of [1, 5, 10, 50, 499]) { + const hits = ti.search('x', { limit }); + assert.equal(hits.length, Math.min(limit, all.length)); + // Exactly the first `limit` rows of the full ranking, in the same order. + assert.deepEqual( + hits.map((h) => h.key), + all.slice(0, limit).map((h) => h.key), + `limit=${limit} is the full ranking's prefix`, + ); + for (const h of hits) assert.ok(rank.get(h.key)! < limit); + } + ti.close(); + } finally { + await fs.rm(dir, { recursive: true, force: true }); + } +}); + +test('TextIndex: overwrite/remove prune the delta via the doc reverse map', () => { + const ti = new TextIndex(); // memory base; adds go to the delta + ti.add('a', { bio: 'apple banana' }); + ti.add('b', { bio: 'cherry' }); + assert.equal(ti.termCount(), 3); + + ti.add('a', { bio: 'mango' }); // overwrite: apple/banana leave the delta + assert.deepEqual(ti.search('apple').map((h) => h.key), []); + assert.deepEqual(ti.search('banana').map((h) => h.key), []); + assert.deepEqual(ti.search('mango').map((h) => h.key), ['a']); + assert.equal(ti.termCount(), 2, 'pruned terms leave the vocabulary (cherry, mango)'); + + ti.remove('b'); + assert.deepEqual(ti.search('cherry').map((h) => h.key), []); + assert.equal(ti.termCount(), 1, 'only mango remains'); + ti.close(); +}); From dd0968330eb2b6bc41a326d8a92adfc5f36188dd Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Sun, 2 Aug 2026 20:28:29 +0800 Subject: [PATCH 03/15] feat(session-index): add minidb read model with keyset pagination - add ISessionIndex read-model lifecycle (prepare/status, ready/degraded states) behind the persistence_minidb_readmodel experimental flag - add ISessionIndexMirror write side recording fresh summaries into a bounded, coalescing queue after the authoritative document is durable - replace the offset cursor with before/after keyset pagination; rename list/countActive to listRecent/count - extend IQueryStore with ordered columns and pageByColumn, plus getMany/listKeys/dropCollection - wire the read model through kap-server routes and start, and update the klient sessions contract - index every session for global search instead of the 500 most recent --- .../skills/agent-core-dev/edge-exposure.md | 4 +- apps/kimi-code/src/cli/v2/run-v2-print.ts | 5 +- apps/kimi-code/test/cli/v2-run-print.test.ts | 9 +- apps/kimi-inspect/src/channel/client.ts | 2 +- apps/kimi-inspect/src/components/Sidebar.tsx | 2 +- .../src/app/sessionIndex/sessionIndex.ts | 104 ++- .../sessionIndex/sessionIndexMirrorService.ts | 219 +++++ .../src/app/sessionIndex/sessionIndexModel.ts | 62 ++ .../app/sessionIndex/sessionIndexProjector.ts | 206 +++++ .../app/sessionIndex/sessionIndexService.ts | 800 +++++++++++++----- .../app/sessionIndex/sessionIndexSource.ts | 188 ++++ .../workspaceSessionsService.ts | 8 +- packages/agent-core-v2/src/index.ts | 1 + .../backends/minidb/miniDbQueryStore.ts | 84 +- .../src/persistence/interface/queryStore.ts | 65 +- .../sessionMetadata/sessionMetadataService.ts | 42 +- .../app/sessionExport/sessionExport.test.ts | 6 +- .../app/sessionIndex/sessionIndex.test.ts | 636 ++++++++++---- .../sessionIndex/sessionIndexMirror.test.ts | 216 +++++ .../test/app/sessionIndex/stubs.ts | 26 + .../workspaceLifecycle.test.ts | 6 +- .../workspaceSessionsService.test.ts | 29 +- .../backends/minidb/miniDbQueryStore.test.ts | 144 ++++ .../test/persistence/interface/stubs.ts | 6 + .../sessionMetadata/sessionMetadata.test.ts | 68 +- .../sessionLifecycle/sessionLifecycle.test.ts | 12 +- packages/kap-server/src/routes/sessions.ts | 202 +++-- packages/kap-server/src/routes/tools.ts | 2 +- .../kap-server/src/search/searchService.ts | 2 +- packages/kap-server/src/start.ts | 30 +- packages/kap-server/test/rpc.test.ts | 20 +- .../test/search/searchRoute.test.ts | 6 +- .../test/search/searchService.test.ts | 14 +- packages/kap-server/test/sessions.test.ts | 146 ++++ packages/kap-server/test/setup.ts | 18 + packages/kap-server/vitest.config.ts | 1 + .../klient/src/contract/global/sessions.ts | 12 +- packages/klient/src/core/facade/global.ts | 5 +- 38 files changed, 2814 insertions(+), 594 deletions(-) create mode 100644 packages/agent-core-v2/src/app/sessionIndex/sessionIndexMirrorService.ts create mode 100644 packages/agent-core-v2/src/app/sessionIndex/sessionIndexModel.ts create mode 100644 packages/agent-core-v2/src/app/sessionIndex/sessionIndexProjector.ts create mode 100644 packages/agent-core-v2/src/app/sessionIndex/sessionIndexSource.ts create mode 100644 packages/agent-core-v2/test/app/sessionIndex/sessionIndexMirror.test.ts create mode 100644 packages/agent-core-v2/test/app/sessionIndex/stubs.ts create mode 100644 packages/kap-server/test/setup.ts diff --git a/.agents/skills/agent-core-dev/edge-exposure.md b/.agents/skills/agent-core-dev/edge-exposure.md index 334c1e4546c..5039201ac95 100644 --- a/.agents/skills/agent-core-dev/edge-exposure.md +++ b/.agents/skills/agent-core-dev/edge-exposure.md @@ -55,9 +55,9 @@ Read = `GET`, write = `POST`. `sid` = `session_id`, `aid` = `agent_id`. | resource | action | Service.method | verb | |---|---|---|---| -| `sessions` | `list` | ISessionIndex.list | GET | +| `sessions` | `listRecent` | ISessionIndex.listRecent | GET | | `sessions` | `get` | ISessionIndex.get | GET | -| `sessions` | `countActive` | ISessionIndex.countActive | GET | +| `sessions` | `count` | ISessionIndex.count | GET | | `workspaces` | `list` | IWorkspaceService.list | GET | | `workspaces` | `get` | IWorkspaceService.get | GET | | `workspaces` | `createOrTouch` | IWorkspaceService.createOrTouch | POST | diff --git a/apps/kimi-code/src/cli/v2/run-v2-print.ts b/apps/kimi-code/src/cli/v2/run-v2-print.ts index 891032b4d8c..aac6062fcef 100644 --- a/apps/kimi-code/src/cli/v2/run-v2-print.ts +++ b/apps/kimi-code/src/cli/v2/run-v2-print.ts @@ -330,8 +330,7 @@ async function resolveNativeSession( }; if (opts.session !== undefined) { - const page = await index.list({}); - const target = page.items.find((summary) => summary.id === opts.session); + const target = await index.get(opts.session); if (target === undefined) { throw new Error(`Session "${opts.session}" not found.`); } @@ -358,7 +357,7 @@ async function resolveNativeSession( } if (opts.continue) { - const page = await index.list({}); + const page = await index.listRecent({}); const previous = page.items.find((summary) => summary.cwd === workDir); if (previous !== undefined) { const session = await resumeById(previous.id); diff --git a/apps/kimi-code/test/cli/v2-run-print.test.ts b/apps/kimi-code/test/cli/v2-run-print.test.ts index 44d3390d8c8..c7b76db4238 100644 --- a/apps/kimi-code/test/cli/v2-run-print.test.ts +++ b/apps/kimi-code/test/cli/v2-run-print.test.ts @@ -222,6 +222,7 @@ function makeFakeHarness() { })), }, ], + [ISessionIndex, { get: vi.fn(async () => undefined), listRecent: vi.fn(async () => ({ items: [] })) }], [ IBootstrapService, { @@ -463,8 +464,8 @@ describe('runV2Print', () => { const { app, agent, agentServices, appServices, profileState } = makeFakeHarness(); profileState.profileName = 'reviewer'; - const index = appServices.get(ISessionIndex) as { list: ReturnType }; - index.list.mockResolvedValue({ items: [{ id: 'ses_1', cwd: process.cwd() }] }); + const index = appServices.get(ISessionIndex) as { get: ReturnType }; + index.get.mockResolvedValue({ id: 'ses_1', cwd: process.cwd() }); mocks.bootstrap.mockReturnValue({ app }); mocks.ensureMainAgent.mockResolvedValue(agent); @@ -488,8 +489,8 @@ describe('runV2Print', () => { const { app, agent, agentServices, appServices, profileState } = makeFakeHarness(); profileState.profileName = 'reviewer'; - const index = appServices.get(ISessionIndex) as { list: ReturnType }; - index.list.mockResolvedValue({ items: [{ id: 'ses_1', cwd: process.cwd() }] }); + const index = appServices.get(ISessionIndex) as { get: ReturnType }; + index.get.mockResolvedValue({ id: 'ses_1', cwd: process.cwd() }); mocks.bootstrap.mockReturnValue({ app }); mocks.ensureMainAgent.mockResolvedValue(agent); diff --git a/apps/kimi-inspect/src/channel/client.ts b/apps/kimi-inspect/src/channel/client.ts index 280f3ed829d..f0149efb1a5 100644 --- a/apps/kimi-inspect/src/channel/client.ts +++ b/apps/kimi-inspect/src/channel/client.ts @@ -5,7 +5,7 @@ * `makeProxy`-materialized typed proxy over a service-bound HTTP channel. * * const client = createInspectClient({ url: 'http://127.0.0.1:58627' }); - * await client.core(ISessionIndex).list({}); + * await client.core(ISessionIndex).listRecent({}); * await client.workspace('wd_1').service(ISessionLifecycleService).resume('s1'); * await client.session('s1').service(ISessionMetadata).read(); * await client.session('s1').agent('main').service(IAgentRPCService).cancel({}); diff --git a/apps/kimi-inspect/src/components/Sidebar.tsx b/apps/kimi-inspect/src/components/Sidebar.tsx index dfbadfb2648..a2b51795362 100644 --- a/apps/kimi-inspect/src/components/Sidebar.tsx +++ b/apps/kimi-inspect/src/components/Sidebar.tsx @@ -66,7 +66,7 @@ export function Sidebar({ queryFn: () => klient .core(ISessionIndex) - .list({ + .listRecent({ workspaceIds: workspaceId === null ? undefined : [workspaceId], includeArchived: true, limit: 200, diff --git a/packages/agent-core-v2/src/app/sessionIndex/sessionIndex.ts b/packages/agent-core-v2/src/app/sessionIndex/sessionIndex.ts index 2eb814cd4f4..d24f9fb3649 100644 --- a/packages/agent-core-v2/src/app/sessionIndex/sessionIndex.ts +++ b/packages/agent-core-v2/src/app/sessionIndex/sessionIndex.ts @@ -1,15 +1,34 @@ /** - * `sessionIndex` domain — session index contract. + * `sessionIndex` domain (L2) — session index contract. * * `ISessionIndex` is a domain-specific persistence Store: a backend-neutral - * query facade over the set of persisted sessions (open or closed). It - * enumerates sessions and derives session identity (`workspaceId`), returning - * data (`SessionSummary`) or counts — never filesystem paths or live handles. - * The index is a read model. Backends are deployment-specific (local - * filesystem today; database / query store on a server). `remove` is the one - * write: it evicts a deleted session's derived/cached state so `get` stops - * answering for the id — the authoritative record (the session directory) is - * deleted by the caller (`sessionLifecycle.delete`). + * query facade over the set of persisted sessions (open or closed). It serves + * recency-ordered pages, point lookups, and counts (`SessionSummary` data or + * numbers — never filesystem paths or live handles). Writes (create / + * archive) live in `sessionLifecycle` / `session`; the index is a read model. + * Backends are deployment-specific (local filesystem today; database / query + * store on a server). `remove` is the one write: it evicts a deleted + * session's derived/cached state so `get` stops answering for the id — the + * authoritative record (the session directory) is deleted by the caller + * (`sessionLifecycle.delete`). + * + * Listings follow a canonical order — `updatedAt` descending, `id` + * descending as the tie-break — and page with keyset cursors: `before` / + * `after` take a session id and return the page strictly older / newer than + * it; `Page.nextCursor` carries the id to pass as `before` for the next + * older page. An unknown cursor id yields an empty, terminal page. + * + * Lifecycle (flag `persistence_minidb_readmodel`): the read model has an + * explicit `prepare()` — `uninitialized → preparing → ready`, or `degraded` + * when it must fall back to the authoritative store. `prepare()` is called + * once by the composition root; read paths kick it lazily (single-flight) + * when a host never did. `status()` exposes the state machine, the published + * generation, and the cumulative degraded count. + * + * `ISessionIndexMirror` is the write side of the read model: `SessionMetadata` + * records fresh summaries into a bounded, coalescing queue after the + * authoritative document is durable, so user mutations never wait on the + * derived store. Bound at App scope. */ import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; @@ -34,22 +53,83 @@ export interface SessionSummary { } export interface SessionListQuery { + /** + * Restrict to sessions persisted under any of these workspace ids. A single + * workspace is `[id]`; callers resolving a legacy split bucket (one + * directory, several id spellings — see `IWorkspaceAliases.resolveAliasIds`) + * pass the whole alias set and get one merged listing. Absent lists every + * bucket. + */ readonly workspaceIds?: readonly string[]; readonly sessionId?: string; readonly includeArchived?: boolean; - readonly cursor?: string; readonly limit?: number; readonly childOf?: string; + /** Keyset cursor: the page strictly older than this session id. */ + readonly before?: string; + /** Keyset cursor: the page strictly newer than this session id. */ + readonly after?: string; +} + +export interface SessionCountQuery { + readonly workspaceIds?: readonly string[]; + readonly includeArchived?: boolean; +} + +export type SessionIndexState = 'uninitialized' | 'preparing' | 'ready' | 'degraded'; + +export interface SessionIndexStatus { + readonly state: SessionIndexState; + /** Published read-model generation; absent until the first projection. */ + readonly generation?: number; + /** Why the index last entered `degraded` (authoritative fallback). */ + readonly reason?: string; + /** How many times the index entered `degraded` in this process. */ + readonly degradedCount: number; } export interface ISessionIndex { readonly _serviceBrand: undefined; - list(query: SessionListQuery): Promise>; + /** + * Open the read model and make it servable: open the query store, create + * the schema, restore the published generation (running the initial + * projection when none exists), and start background reconciliation. + * Single-flight; a no-op when the read-model flag is off. + */ + prepare(options?: { deadlineMs?: number }): Promise; + status(): SessionIndexStatus; get(id: string): Promise; - countActive(workspaceIds: readonly string[]): Promise; + /** Recency-ordered keyset page over the persisted session set. */ + listRecent(query: SessionListQuery): Promise>; + /** Materialized count over the given workspace-id set. */ + count(query: SessionCountQuery): Promise; + /** + * The one write: evict a deleted session's derived/cached state so `get` + * stops answering for the id — the authoritative record (the session + * directory) is deleted by the caller (`sessionLifecycle.delete`). + */ remove(id: string): Promise; } export const ISessionIndex: ServiceIdentifier = createDecorator('sessionIndex'); + +export interface ISessionIndexMirror { + readonly _serviceBrand: undefined; + + /** + * Enqueue the latest summary of a session for mirroring into the read + * model. Synchronous, bounded, and coalescing (only the newest summary per + * session is kept); never throws — failures stay dirty and are healed by + * reconciliation. + */ + record(summary: SessionSummary): void; + /** Summaries accepted but not yet flushed (read-your-writes window). */ + pending(): readonly SessionSummary[]; + /** Flush everything currently queued; resolves with the queue empty. */ + drain(): Promise; +} + +export const ISessionIndexMirror: ServiceIdentifier = + createDecorator('sessionIndexMirror'); diff --git a/packages/agent-core-v2/src/app/sessionIndex/sessionIndexMirrorService.ts b/packages/agent-core-v2/src/app/sessionIndex/sessionIndexMirrorService.ts new file mode 100644 index 00000000000..8159e91dd00 --- /dev/null +++ b/packages/agent-core-v2/src/app/sessionIndex/sessionIndexMirrorService.ts @@ -0,0 +1,219 @@ +/** + * `sessionIndex` domain (L2) — `ISessionIndexMirror` implementation. + * + * The write side of the session read model. `SessionMetadata` (Session scope) + * records the freshest `SessionSummary` here once the authoritative + * `state.json` is durable; this App-scoped queue then mirrors it into the + * `IQueryStore` read model *off the user completion path*. Updates coalesce + * per session (only the newest summary is kept) and flush in chunks — on a + * short timer or as soon as a batch fills — writing summaries (with the + * recency column declared) and per-workspace counter deltas into the + * currently published generation. + * + * Everything here is best-effort: a flush failure keeps the entries queued, + * backs off, and after repeated failures gives up until the next `record` — + * the failed entries stay dirty and the domain's reconciliation heals them + * from the authoritative documents. A queue overflow drops incoming summaries + * (logged) rather than growing memory without bound. `drain()` is the + * explicit shutdown path — the composition root awaits it before the query + * store closes; DI disposal additionally fires a best-effort drain into a + * module-level set so hosts without explicit wiring can await it via + * `drainSessionIndexMirror()`. + * + * Bound at App scope. + */ + +import { Disposable, toDisposable } from '#/_base/di/lifecycle'; +import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { ILogService } from '#/_base/log/log'; +import { IntervalTimer } from '#/_base/utils/timer'; +import { IFlagService } from '#/app/flag/flag'; +import { IQueryStore } from '#/persistence/interface/queryStore'; + +import { ISessionIndexMirror, type SessionSummary } from './sessionIndex'; +import { + SESSION_INDEX_MANIFEST, + recencyColumn, + sessionCollection, + sessionCountersCollection, + withRecencyField, + type SessionWorkspaceCounts, +} from './sessionIndexModel'; + +const READ_MODEL_FLAG = 'persistence_minidb_readmodel'; + +const FLUSH_INTERVAL_MS = 100; +const FLUSH_BATCH_SIZE = 500; +const MAX_PENDING = 10_000; +const MAX_CONSECUTIVE_FAILURES = 5; + +/** + * Best-effort drains fired by DI disposal (which is synchronous). The server + * shutdown path awaits the service's own `drain()` explicitly before the + * query store closes; this set is the backstop for hosts that only tear the + * scope down. + */ +const pendingDrains = new Set>(); + +export async function drainSessionIndexMirror(): Promise { + await Promise.all(pendingDrains); +} + +export class SessionIndexMirror extends Disposable implements ISessionIndexMirror { + declare readonly _serviceBrand: undefined; + + private readonly pendingMap = new Map(); + private readonly timer = this._register(new IntervalTimer({ unref: true })); + private flushing: Promise | undefined; + private consecutiveFailures = 0; + private disposed = false; + private overflowLogged = false; + + constructor( + @IQueryStore private readonly queryStore: IQueryStore, + @IFlagService private readonly flags: IFlagService, + @ILogService private readonly log: ILogService, + ) { + super(); + this._register( + toDisposable(() => { + this.disposed = true; + const pending = this.drain().catch(() => {}); + pendingDrains.add(pending); + void pending.finally(() => pendingDrains.delete(pending)); + }), + ); + } + + record(summary: SessionSummary): void { + if (this.disposed || !this.flags.enabled(READ_MODEL_FLAG)) return; + if (this.pendingMap.size >= MAX_PENDING && !this.pendingMap.has(summary.id)) { + if (!this.overflowLogged) { + this.overflowLogged = true; + this.log.warn('session index mirror queue full; dropping summaries until it drains', { + pending: this.pendingMap.size, + }); + } + return; + } + this.overflowLogged = false; + this.pendingMap.set(summary.id, summary); + if (this.pendingMap.size >= FLUSH_BATCH_SIZE) { + void this.flush(); + } else if (!this.timer.isSet()) { + this.timer.cancelAndSet(() => void this.flush(), FLUSH_INTERVAL_MS); + } + } + + pending(): readonly SessionSummary[] { + return [...this.pendingMap.values()]; + } + + async drain(): Promise { + this.timer.cancel(); + while (this.pendingMap.size > 0) { + const before = this.pendingMap.size; + await this.flush(); + if (this.pendingMap.size >= before) { + // No progress — the store is down; the next reconciliation heals. + this.log.warn('session index mirror drain made no progress; leaving the rest dirty', { + pending: this.pendingMap.size, + }); + return; + } + } + } + + private flush(): Promise { + this.flushing ??= this.flushChunk().finally(() => { + this.flushing = undefined; + if (this.pendingMap.size > 0 && this.consecutiveFailures < MAX_CONSECUTIVE_FAILURES) { + this.timer.cancelAndSet(() => void this.flush(), FLUSH_INTERVAL_MS); + } + }); + return this.flushing; + } + + private async flushChunk(): Promise { + const chunk = [...this.pendingMap.entries()].slice(0, FLUSH_BATCH_SIZE); + if (chunk.length === 0) return; + try { + const manifest = await this.queryStore.getCheckpoint(SESSION_INDEX_MANIFEST); + if (manifest === undefined) { + // No published generation yet — the running projection reads the + // authoritative documents and covers these sessions; retry shortly. + this.consecutiveFailures += 1; + return; + } + const collection = sessionCollection(manifest.seq); + const counters = sessionCountersCollection(manifest.seq); + const ids = chunk.map(([id]) => id); + const olds = await this.queryStore.getMany(collection, ids); + + const deltas = new Map(); + const bump = (workspaceId: string, field: 'active' | 'archived', by: number): void => { + const entry = deltas.get(workspaceId) ?? { active: 0, archived: 0 }; + entry[field] += by; + deltas.set(workspaceId, entry); + }; + for (const [id, summary] of chunk) { + const old = olds.get(id); + if (old === undefined) { + bump(summary.workspaceId, summary.archived ? 'archived' : 'active', 1); + } else if (old.workspaceId !== summary.workspaceId) { + bump(old.workspaceId, old.archived ? 'archived' : 'active', -1); + bump(summary.workspaceId, summary.archived ? 'archived' : 'active', 1); + } else if (old.archived !== summary.archived) { + bump(summary.workspaceId, old.archived ? 'archived' : 'active', -1); + bump(summary.workspaceId, summary.archived ? 'archived' : 'active', 1); + } + } + + const current = await this.queryStore.getMany(counters, [ + ...deltas.keys(), + ]); + const ops = [ + ...chunk.map(([id, summary]) => ({ + kind: 'put' as const, + collection, + key: id, + value: withRecencyField(manifest.seq, summary), + columns: { [recencyColumn(manifest.seq)]: summary.updatedAt }, + })), + ...[...deltas.entries()].map(([workspaceId, delta]) => { + const base = current.get(workspaceId) ?? { active: 0, archived: 0 }; + const value: SessionWorkspaceCounts = { + active: Math.max(0, base.active + delta.active), + archived: Math.max(0, base.archived + delta.archived), + }; + return { kind: 'put' as const, collection: counters, key: workspaceId, value }; + }), + ]; + await this.queryStore.batch(ops); + for (const [id, summary] of chunk) { + if (this.pendingMap.get(id) === summary) this.pendingMap.delete(id); + } + this.consecutiveFailures = 0; + } catch (error) { + this.consecutiveFailures += 1; + this.log.warn('failed to flush session index mirror chunk', { + pending: this.pendingMap.size, + failures: this.consecutiveFailures, + error: String(error), + }); + if (this.consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) { + this.log.warn('session index mirror giving up until the next record; reconciliation will heal', { + pending: this.pendingMap.size, + }); + } + } + } +} + +registerScopedService( + LifecycleScope.App, + ISessionIndexMirror, + SessionIndexMirror, + ScopeActivation.OnScopeCreated, + 'sessionIndex', +); diff --git a/packages/agent-core-v2/src/app/sessionIndex/sessionIndexModel.ts b/packages/agent-core-v2/src/app/sessionIndex/sessionIndexModel.ts new file mode 100644 index 00000000000..c3c21a4e17e --- /dev/null +++ b/packages/agent-core-v2/src/app/sessionIndex/sessionIndexModel.ts @@ -0,0 +1,62 @@ +/** + * `sessionIndex` domain (L2) — read-model layout shared by the index, the + * mirror, and the projector. + * + * The derived read model is versioned by *generation*: every projection + * writes a fresh `session:g` collection (summaries, keyed by session id, + * with the generation's recency column declared) plus a + * `sessionCounters:g` collection (per-workspace materialized + * active/archived counts), then publishes `N` with one atomic checkpoint + * write. Readers only ever read the published generation, so a projection + * that dies midway leaves the previous generation fully intact; orphaned + * halves of crashed generations are dropped before reuse and the previous + * generation is dropped after a successful publish. The collections are + * plain `IQueryStore` collections — no backend-specific type escapes into + * the domain. + */ + +import type { SessionSummary } from './sessionIndex'; + +export const SESSION_INDEX_MANIFEST = 'sessionIndex'; + +export const PARENT_INDEX_NAME = 'byParent'; + +export interface SessionWorkspaceCounts { + readonly active: number; + readonly archived: number; +} + +export function sessionCollection(generation: number): string { + return `session:g${generation}`; +} + +export function sessionCountersCollection(generation: number): string { + return `sessionCounters:g${generation}`; +} + +/** + * The ordered recency column for a generation. Column names are store-wide, + * so the column is namespaced per generation: two coexisting generations + * (one published, one being projected) then walk disjoint ordered + * structures and can never interleave into each other's pages. The stored + * record carries the same-named field — the engine orders by the column and + * its cross-shard merge compares by the value field of that name — and the + * index strips it again on every read. + */ +export function recencyColumn(generation: number): string { + return `g${generation}:updatedAt`; +} + +/** Attach the generation's recency field to a summary for storage. */ +export function withRecencyField(generation: number, summary: SessionSummary): SessionSummary { + return { ...summary, [recencyColumn(generation)]: summary.updatedAt }; +} + +/** Remove the generation's recency field from a stored record. */ +export function stripRecencyField(generation: number, record: SessionSummary): SessionSummary { + const key = recencyColumn(generation); + if (!(key in record)) return record; + const rest: Record = { ...record }; + delete rest[key]; + return rest as unknown as SessionSummary; +} diff --git a/packages/agent-core-v2/src/app/sessionIndex/sessionIndexProjector.ts b/packages/agent-core-v2/src/app/sessionIndex/sessionIndexProjector.ts new file mode 100644 index 00000000000..cd313cbde39 --- /dev/null +++ b/packages/agent-core-v2/src/app/sessionIndex/sessionIndexProjector.ts @@ -0,0 +1,206 @@ +/** + * `sessionIndex` domain (L2) — projector and reconciliation for the read + * model. + * + * The projector materializes the authoritative session metadata + * (`state.json` documents) into a fresh read-model generation: a full scan + * with bounded concurrency, chunked `batch` writes (no cross-shard atomicity + * required), per-workspace counters recomputed exactly, and finally one + * atomic checkpoint publish that makes the generation readable. A projector + * that dies midway never publishes, so readers keep serving the previous + * generation; the next run clears its own stragglers before writing. + * Publishing also schedules the previous generation's drop. + * + * Reconciliation runs against the *published* generation: it re-scans the + * authoritative set, upserts summaries that drifted (mirror loss, external + * edits), deletes entries whose document disappeared, and rewrites every + * counter from the authoritative scan — bounding counter drift to one + * reconcile interval. + * + * This is an internal collaborator of `FileSessionIndex`, not a DI service: + * the index drives it single-flight and owns the state machine around it. + */ + +import { ILogService } from '#/_base/log/log'; +import { IAtomicDocumentStore } from '#/persistence/interface/atomicDocumentStore'; +import { IQueryStore, type WriteOp } from '#/persistence/interface/queryStore'; +import { IFileSystemStorageService } from '#/persistence/interface/storage'; + +import { PARENT_SESSION_ID_KEY, type SessionSummary } from './sessionIndex'; +import { + PARENT_INDEX_NAME, + SESSION_INDEX_MANIFEST, + recencyColumn, + sessionCollection, + sessionCountersCollection, + withRecencyField, + type SessionWorkspaceCounts, +} from './sessionIndexModel'; +import { + listSessionIds, + listWorkspaceIds, + mapBounded, + readSessionSummary, + summaryEquals, +} from './sessionIndexSource'; + +const WRITE_CHUNK = 500; +const SCAN_CONCURRENCY = 16; + +export interface SessionIndexProjectorDeps { + readonly storage: IFileSystemStorageService; + readonly docs: IAtomicDocumentStore; + readonly queryStore: IQueryStore; + readonly log: ILogService; + readonly sessionsScope: string; +} + +export interface ProjectionResult { + readonly generation: number; + readonly sessions: number; +} + +export interface ReconcileResult { + readonly sessions: number; + readonly upserted: number; + readonly removed: number; +} + +export class SessionIndexProjector { + constructor(private readonly deps: SessionIndexProjectorDeps) {} + + /** Scan the authoritative set into a fresh generation and publish it. */ + async project(generation: number): Promise { + const { queryStore, log } = this.deps; + const collection = sessionCollection(generation); + const counters = sessionCountersCollection(generation); + // Clear stragglers of a crashed earlier attempt at this generation. + await queryStore.dropCollection(collection); + await queryStore.dropCollection(counters); + await queryStore.ensureIndex(collection, { + kind: 'value', + name: PARENT_INDEX_NAME, + field: `custom.${PARENT_SESSION_ID_KEY}`, + }); + + const { summaries, counts } = await this.scanAuthoritative(); + await this.batchChunks( + summaries.map((summary) => ({ + kind: 'put' as const, + collection, + key: summary.id, + value: withRecencyField(generation, summary), + columns: { [recencyColumn(generation)]: summary.updatedAt }, + })), + ); + await this.writeCounters(counters, counts); + await queryStore.setCheckpoint(SESSION_INDEX_MANIFEST, { seq: generation }); + log.info('session index generation published', { + generation, + sessions: summaries.length, + }); + + if (generation > 1) { + const staleSession = sessionCollection(generation - 1); + const staleCounters = sessionCountersCollection(generation - 1); + void queryStore + .dropCollection(staleSession) + .then(() => queryStore.dropCollection(staleCounters)) + .catch((error) => { + log.warn('failed to drop previous session index generation', { + generation: generation - 1, + error: String(error), + }); + }); + } + return { generation, sessions: summaries.length }; + } + + /** Re-scan the authoritative set and repair the published generation. */ + async reconcile(generation: number): Promise { + const { queryStore, log } = this.deps; + const collection = sessionCollection(generation); + const counters = sessionCountersCollection(generation); + const { summaries, counts } = await this.scanAuthoritative(); + const authoritativeIds = new Set(summaries.map((s) => s.id)); + + const storedKeys = await queryStore.listKeys(collection); + const stored = await queryStore.getMany( + collection, + summaries.map((s) => s.id), + ); + + const upserts: WriteOp[] = []; + for (const summary of summaries) { + const existing = stored.get(summary.id); + if (existing === undefined || !summaryEquals(existing, summary)) { + upserts.push({ + kind: 'put', + collection, + key: summary.id, + value: withRecencyField(generation, summary), + columns: { [recencyColumn(generation)]: summary.updatedAt }, + }); + } + } + const removals: WriteOp[] = storedKeys + .filter((key) => !authoritativeIds.has(key)) + .map((key) => ({ kind: 'delete' as const, collection, key })); + + await this.batchChunks([...upserts, ...removals]); + await this.writeCounters(counters, counts); + const result = { sessions: summaries.length, upserted: upserts.length, removed: removals.length }; + if (result.upserted > 0 || result.removed > 0) { + log.info('session index reconciliation repaired drift', { generation, ...result }); + } + return result; + } + + private async scanAuthoritative(): Promise<{ + summaries: SessionSummary[]; + counts: Map; + }> { + const { storage, docs, sessionsScope } = this.deps; + const summaries: SessionSummary[] = []; + const counts = new Map(); + for (const workspaceId of await listWorkspaceIds(storage, sessionsScope)) { + const sessionIds = await listSessionIds(storage, sessionsScope, workspaceId); + const found = await mapBounded(sessionIds, SCAN_CONCURRENCY, (sessionId) => + readSessionSummary(docs, sessionsScope, workspaceId, sessionId), + ); + const entry = counts.get(workspaceId) ?? { active: 0, archived: 0 }; + for (const summary of found) { + summaries.push(summary); + if (summary.archived) entry.archived += 1; + else entry.active += 1; + } + counts.set(workspaceId, entry); + } + return { summaries, counts }; + } + + private async writeCounters( + counters: string, + counts: Map, + ): Promise { + const { queryStore } = this.deps; + const ops: WriteOp[] = [...counts.entries()].map(([workspaceId, value]) => ({ + kind: 'put', + collection: counters, + key: workspaceId, + value: { active: value.active, archived: value.archived } satisfies SessionWorkspaceCounts, + })); + // Workspaces that vanished entirely lose their counter document. + const existing = await queryStore.listKeys(counters); + for (const key of existing) { + if (!counts.has(key)) ops.push({ kind: 'delete', collection: counters, key }); + } + await this.batchChunks(ops); + } + + private async batchChunks(ops: readonly WriteOp[]): Promise { + for (let start = 0; start < ops.length; start += WRITE_CHUNK) { + await this.deps.queryStore.batch(ops.slice(start, start + WRITE_CHUNK)); + } + } +} diff --git a/packages/agent-core-v2/src/app/sessionIndex/sessionIndexService.ts b/packages/agent-core-v2/src/app/sessionIndex/sessionIndexService.ts index f47a865d9b3..71dc5310961 100644 --- a/packages/agent-core-v2/src/app/sessionIndex/sessionIndexService.ts +++ b/packages/agent-core-v2/src/app/sessionIndex/sessionIndexService.ts @@ -1,99 +1,97 @@ /** - * `sessionIndex` domain — `FileSessionIndex` implementation. + * `sessionIndex` domain (L2) — `FileSessionIndex` implementation. * - * Reads the persisted session set through the `storage` access-pattern - * stores, rooted at the `sessionsDir` path layout fact. The directory tree - * `///` is the index: workspace and - * session ids are enumerated via `IFileSystemStorageService.list`, and each - * session's metadata document is read via `IAtomicDocumentStore` to build its - * summary. + * Serves session listings, point lookups, and counts. Two read paths exist: * - * One physical folder may be split across sibling buckets by legacy id - * spellings (Windows casing/slash variants minted different `workspaceId`s - * for the same directory). A list or `countActive` query takes the - * workspace-id *set*, enumerates each bucket, and merges before the single - * recency sort and `limit` step — the merged listing is observably identical - * to a single-bucket list (same sort key, same cursor shape); filtering - * options keep their meaning. + * - **Authoritative (legacy) path** — enumerates the directory tree and reads + * every `state.json` (see `sessionIndexSource`). Always correct, linear in + * the number of sessions. This is the flag-off behavior and the fallback + * whenever the read model cannot serve. + * - **Read-model path** (flag `persistence_minidb_readmodel`) — queries the + * derived `IQueryStore` read model. Recency pages walk the published + * generation's ordered recency column with keyset cursors (`O(log N + + * limit)`, no directory enumeration, no per-session document reads), point + * lookups are single gets, and counts read materialized per-workspace + * counters. * - * The session metadata document lives at `/state.json`, a layout - * shared by v1 and v2; the `version` field distinguishes them (`2` = v2, - * epoch-ms timestamps; absent = v1, ISO-string timestamps). The reader also - * falls back to the legacy `/session-meta/state.json` path for v2 - * sessions written before the layouts were unified. Both timestamp - * representations are normalized to epoch ms. + * The read model follows the lifecycle `uninitialized → preparing → ready`, + * with `degraded` whenever it cannot serve and the authoritative path takes + * over (the reason and the cumulative count are published via `status()` and + * logged — never a silent permanent fallback). `prepare()` opens the store, + * restores the published generation, runs the initial projection when none + * exists, and starts background reconciliation; read paths kick it + * single-flight when the composition root never called it. A lost manifest + * (query-store corruption rebuild) triggers an automatic reprojection — the + * model is never healed by per-request backfill. Degraded reads retry + * `prepare()` after a short backoff. * - * Read model (flag `persistence_minidb_readmodel`): when enabled, summaries are - * served from the `IQueryStore` derived read model instead of re-reading and - * re-parsing `state.json` on every call. Listing still enumerates the directory - * (a cheap `readdir`) to discover `(workspaceId, sessionId)` pairs, but each - * summary is resolved through the read model — falling back to a disk read + - * backfill on a cold miss. Writes (create / archive / metadata update) keep the - * read model warm via `SessionMetadata`; new sessions that have not been - * mirrored yet are simply a cold miss and backfilled on first read, and a - * deleted session's cached summary is evicted through `remove` so `get` stops - * answering for the id. The legacy - * N+1 path remains as the flag-off fallback — and as the runtime fallback if - * the query store ever reports `storage.locked`: the first lock warns once and - * disables the read model for the rest of the process lifetime. (The minidb - * backend is multi-process and no longer produces that error; the wiring - * stays as defense in depth.) + * Keyset pagination is canonical (`updatedAt` desc, `id` desc): a cursor is a + * session id resolved by point lookup, and the window's boundary tie group is + * re-fetched and merged so same-millisecond ties never lose or duplicate an + * item across pages. `get` falls back to the authoritative document on a + * read-model miss (mirror lag) and re-records it, and every page folds in the + * mirror's not-yet-flushed summaries (range-filtered by the cursor on cursor + * pages) — reads always see recent writes of this process. * - * This is the local-deployment backend of `ISessionIndex`; a server deployment - * would substitute a database-backed `DbSessionIndex`. Bound at App scope. + * This is the local-deployment backend of `ISessionIndex`; a server + * deployment would substitute a database-backed implementation. Bound at App + * scope. */ +import { Disposable } from '#/_base/di/lifecycle'; import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { ILogService } from '#/_base/log/log'; +import { IntervalTimer } from '#/_base/utils/timer'; import { IBootstrapService } from '#/app/bootstrap/bootstrap'; import { IFlagService } from '#/app/flag/flag'; import { IAtomicDocumentStore } from '#/persistence/interface/atomicDocumentStore'; -import { IQueryStore, type Page } from '#/persistence/interface/queryStore'; -import { IFileSystemStorageService, isStorageError, StorageErrors } from '#/persistence/interface/storage'; +import { + IQueryStore, + type Checkpoint, + type ColumnBounds, + type Page, + type QueryFilter, +} from '#/persistence/interface/queryStore'; +import { IFileSystemStorageService } from '#/persistence/interface/storage'; import { CHILD_SESSION_KIND, CHILD_SESSION_KIND_KEY, ISessionIndex, + ISessionIndexMirror, PARENT_SESSION_ID_KEY, + type SessionCountQuery, + type SessionIndexStatus, + type SessionIndexState, type SessionListQuery, type SessionSummary, } from './sessionIndex'; +import { + PARENT_INDEX_NAME, + SESSION_INDEX_MANIFEST, + recencyColumn, + sessionCollection, + sessionCountersCollection, + stripRecencyField, + type SessionWorkspaceCounts, +} from './sessionIndexModel'; +import { SessionIndexProjector } from './sessionIndexProjector'; +import { + listSessionIds, + listWorkspaceIds, + readSessionSummary, + summaryMatchesChildOf, +} from './sessionIndexSource'; -const META_SCOPE = 'session-meta'; -const META_KEY = 'state.json'; -const SESSION_COLLECTION = 'session'; const READ_MODEL_FLAG = 'persistence_minidb_readmodel'; - -function parseTime(value: unknown): number { - if (typeof value === 'number' && Number.isFinite(value)) return value; - if (typeof value === 'string') { - const parsed = Date.parse(value); - if (!Number.isNaN(parsed)) return parsed; - } - return 0; -} - -function recoverCwd(meta: Record): string | undefined { - if (typeof meta['cwd'] === 'string' && meta['cwd'].length > 0) return meta['cwd']; - if (typeof meta['workDir'] === 'string' && meta['workDir'].length > 0) { - return meta['workDir']; - } - const custom = meta['custom']; - if (custom !== null && typeof custom === 'object' && !Array.isArray(custom)) { - const fromCustom = (custom as Record)['cwd']; - if (typeof fromCustom === 'string' && fromCustom.length > 0) return fromCustom; - } - return undefined; -} - -function matchesChildOf(summary: SessionSummary, parentId: string | undefined): boolean { - if (parentId === undefined) return true; - const custom = summary.custom; - return ( - custom?.[PARENT_SESSION_ID_KEY] === parentId && - custom?.[CHILD_SESSION_KIND_KEY] === CHILD_SESSION_KIND - ); +const RECONCILE_INTERVAL_MS = 60_000; +const DEGRADED_RETRY_MS = 5_000; +const TIE_REPAIR_LIMIT = 1_000; +const UNBOUNDED = Number.MAX_SAFE_INTEGER; + +function canonicalOrder(a: SessionSummary, b: SessionSummary): number { + if (a.updatedAt !== b.updatedAt) return b.updatedAt - a.updatedAt; + return a.id < b.id ? 1 : a.id > b.id ? -1 : 0; } function isSessionSummaryShape(value: unknown): value is SessionSummary { @@ -108,11 +106,18 @@ function isSessionSummaryShape(value: unknown): value is SessionSummary { ); } -export class FileSessionIndex implements ISessionIndex { +export class FileSessionIndex extends Disposable implements ISessionIndex { declare readonly _serviceBrand: undefined; - private indexesEnsured = false; - private readModelDisabled = false; + private state: SessionIndexState = 'uninitialized'; + private generation: number | undefined; + private statusReason: string | undefined; + private degradedCount = 0; + private nextPrepareRetryAt = 0; + private prepareFlight: Promise | undefined; + private projectFlight: Promise | undefined; + private readonly reconcileTimer = this._register(new IntervalTimer({ unref: true })); + private readonly projector: SessionIndexProjector; constructor( @IBootstrapService private readonly bootstrap: IBootstrapService, @@ -120,64 +125,281 @@ export class FileSessionIndex implements ISessionIndex { @IAtomicDocumentStore private readonly docs: IAtomicDocumentStore, @IQueryStore private readonly queryStore: IQueryStore, @IFlagService private readonly flags: IFlagService, + @ISessionIndexMirror private readonly mirror: ISessionIndexMirror, @ILogService private readonly log: ILogService, - ) {} + ) { + super(); + this.projector = new SessionIndexProjector({ + storage, + docs, + queryStore, + log, + sessionsScope: bootstrap.scope('sessions'), + }); + } - async list(query: SessionListQuery): Promise> { - if (!this.readModelEnabled()) return this.listLegacy(query); - return this.withReadModelFallback( - () => this.listFromReadModel(query), - () => this.listLegacy(query), - ); + /** The reconcile loop runs only while the read model is in play — starting + * it unconditionally would spin an interval for every flag-off host. */ + private ensureReconcileTimer(): void { + if (!this.reconcileTimer.isSet()) { + this.reconcileTimer.cancelAndSet(() => void this.tick(), RECONCILE_INTERVAL_MS); + } + } + + // ---- lifecycle ------------------------------------------------------------ + + async prepare(options?: { deadlineMs?: number }): Promise { + if (!this.readModelEnabled()) return this.status(); + this.prepareFlight ??= this.doPrepare(options?.deadlineMs).finally(() => { + this.prepareFlight = undefined; + }); + return this.prepareFlight; + } + + status(): SessionIndexStatus { + return { + state: this.readModelEnabled() ? this.state : 'uninitialized', + generation: this.generation, + reason: this.statusReason, + degradedCount: this.degradedCount, + }; + } + + private async doPrepare(deadlineMs?: number): Promise { + if (this.state === 'ready') return this.status(); + this.state = 'preparing'; + try { + const manifest = await this.queryStore.getCheckpoint(SESSION_INDEX_MANIFEST); + if (manifest === undefined) { + const projection = this.ensureProjection(); + if (deadlineMs === undefined) { + await projection; + } else { + await Promise.race([ + projection, + new Promise((resolve) => { + setTimeout(resolve, deadlineMs); + }), + ]); + } + } else { + this.generation = manifest.seq; + await this.ensureSchema(manifest.seq); + } + const published = await this.queryStore.getCheckpoint(SESSION_INDEX_MANIFEST); + if (published !== undefined) { + this.generation = published.seq; + this.markReady(); + } + } catch (error) { + this.markDegraded('prepare failed', error); + } + return this.status(); + } + + private ensureProjection(): Promise { + this.projectFlight ??= this.runProjection().finally(() => { + this.projectFlight = undefined; + }); + return this.projectFlight; + } + + private async runProjection(): Promise { + try { + const manifest = await this.queryStore.getCheckpoint(SESSION_INDEX_MANIFEST); + const next = (manifest?.seq ?? 0) + 1; + const result = await this.projector.project(next); + this.generation = result.generation; + this.markReady(); + } catch (error) { + // A failed projection never publishes. When a previous generation is + // still published, readers keep flowing from it — a crashed re-projection + // must not take the read model down. + const published = await this.queryStore + .getCheckpoint(SESSION_INDEX_MANIFEST) + .catch(() => undefined); + if (published !== undefined) { + this.generation = published.seq; + this.markReady(); + this.log.warn('session index re-projection failed; staying on the previous generation', { + generation: published.seq, + error: String(error), + }); + } else { + this.markDegraded('projection failed', error); + } + } + } + + /** Test/ops hook: reconcile the published generation against disk now. */ + async reconcileNow(): Promise { + if (!this.readModelEnabled()) return; + const manifest = await this.queryStore.getCheckpoint(SESSION_INDEX_MANIFEST); + if (manifest === undefined) return; + this.generation = manifest.seq; + await this.projector.reconcile(manifest.seq); + } + + /** Test/ops hook: project a fresh generation now (single-flight). */ + async reprojectNow(): Promise { + if (!this.readModelEnabled()) return; + await this.ensureProjection(); + } + + private async tick(): Promise { + if (!this.readModelEnabled()) return; + if (this.state === 'degraded') { + void this.prepare(); + return; + } + if (this.state !== 'ready') return; + try { + const manifest = await this.queryStore.getCheckpoint(SESSION_INDEX_MANIFEST); + if (manifest === undefined) { + this.markDegraded('published generation lost'); + void this.prepare(); + return; + } + this.generation = manifest.seq; + await this.projector.reconcile(manifest.seq); + } catch (error) { + // A failed reconcile leaves reads intact; it retries on the next tick. + this.log.warn('session index reconciliation failed', { error: String(error) }); + } } + private markReady(): void { + this.state = 'ready'; + this.statusReason = undefined; + this.ensureReconcileTimer(); + } + + private markDegraded(reason: string, error?: unknown): void { + this.state = 'degraded'; + this.statusReason = reason; + this.degradedCount += 1; + this.nextPrepareRetryAt = Date.now() + DEGRADED_RETRY_MS; + this.ensureReconcileTimer(); + const detail = + error instanceof Error ? error.message : typeof error === 'string' ? error : undefined; + this.log.warn('session index read model degraded; serving authoritative reads', { + reason, + ...(detail !== undefined ? { error: detail } : {}), + degradedCount: this.degradedCount, + }); + } + + private async ensureSchema(generation: number): Promise { + await this.queryStore.ensureIndex(sessionCollection(generation), { + kind: 'value', + name: PARENT_INDEX_NAME, + field: `custom.${PARENT_SESSION_ID_KEY}`, + }); + } + + // ---- reads ------------------------------------------------------------------ + async get(id: string): Promise { - if (!this.readModelEnabled()) return this.getLegacy(id); - return this.withReadModelFallback( - () => this.getFromReadModel(id), + return this.withReadModel( + (generation) => this.getFromReadModel(generation, id), () => this.getLegacy(id), ); } - async countActive(workspaceIds: readonly string[]): Promise { - if (!this.readModelEnabled()) return this.countActiveLegacy(workspaceIds); - return this.withReadModelFallback( - () => this.countActiveFromReadModel(workspaceIds), - () => this.countActiveLegacy(workspaceIds), + async listRecent(query: SessionListQuery): Promise> { + return this.withReadModel( + (generation) => this.listRecentFromReadModel(generation, query), + () => this.listLegacy(query), + ); + } + + async count(query: SessionCountQuery): Promise { + return this.withReadModel( + (generation) => this.countFromReadModel(generation, query), + () => this.countLegacy(query), ); } + /** + * Evict a deleted session's derived state so `get` / `listRecent` stop + * answering for the id immediately: the authoritative directory is deleted + * by the caller (`sessionLifecycle.delete`), and the next projection would + * drop the entry anyway — this closes the stale-read window in between. A + * summary still queued in the mirror heals at the next projection. With the + * read model off there is no derived state to evict. + */ async remove(id: string): Promise { - if (!this.readModelEnabled() || this.readModelDisabled) return; + await this.withReadModel( + async (generation) => { + await this.queryStore.delete(sessionCollection(generation), id); + }, + () => Promise.resolve(), + ); + } + + /** + * Serve `op` from the read model when possible, else from the authoritative + * path: flag off, not prepared yet (kicked here single-flight), preparing, + * or degraded (with a throttled re-prepare). Any read-model failure demotes + * to `degraded` — logged and counted — and falls back immediately. + */ + private async withReadModel( + op: (generation: number) => Promise, + legacy: () => Promise, + ): Promise { + if (!this.readModelEnabled()) return legacy(); + if (this.state === 'uninitialized') { + void this.prepare(); + return legacy(); + } + if (this.state === 'preparing') return legacy(); + if (this.state === 'degraded') { + if (Date.now() >= this.nextPrepareRetryAt) void this.prepare(); + return legacy(); + } + let manifest: Checkpoint | undefined; try { - await this.queryStore.delete(SESSION_COLLECTION, id); + manifest = await this.queryStore.getCheckpoint(SESSION_INDEX_MANIFEST); } catch (error) { - if (!isStorageError(error, StorageErrors.codes.STORAGE_LOCKED)) throw error; - this.readModelDisabled = true; - this.log.warn('query-store locked by another process; disabling read model', { - error: String(error), - }); + this.markDegraded('read model read failed', error); + return legacy(); } - } - - private async withReadModelFallback(op: () => Promise, legacy: () => Promise): Promise { - if (this.readModelDisabled) return legacy(); + if (manifest === undefined) { + // The store lost the published generation (corruption rebuild): + // reproject automatically instead of healing by per-request backfill. + this.markDegraded('published generation lost'); + void this.prepare(); + return legacy(); + } + this.generation = manifest.seq; try { - return await op(); + return await op(manifest.seq); } catch (error) { - if (!isStorageError(error, StorageErrors.codes.STORAGE_LOCKED)) throw error; - this.readModelDisabled = true; - this.log.warn('query-store locked by another process; disabling read model', { - error: String(error), - }); + this.markDegraded('read model read failed', error); return legacy(); } } - private async listFromReadModel(query: SessionListQuery): Promise> { - await this.ensureIndexes(); + private async getFromReadModel( + generation: number, + id: string, + ): Promise { + const cached: unknown = await this.queryStore.get(sessionCollection(generation), id); + if (isSessionSummaryShape(cached)) return stripRecencyField(generation, cached); + // Mirror lag or a not-yet-projected session: probe the authoritative + // document and re-record it so the next read is warm. + const summary = await this.getLegacy(id); + if (summary !== undefined) this.mirror.record(summary); + return summary; + } + + private async listRecentFromReadModel( + generation: number, + query: SessionListQuery, + ): Promise> { + const collection = sessionCollection(generation); if (query.sessionId !== undefined) { - const summary = await this.getFromReadModel(query.sessionId); + const summary = await this.getFromReadModel(generation, query.sessionId); const items = summary !== undefined && (!summary.archived || query.includeArchived === true) ? [summary] @@ -185,73 +407,215 @@ export class FileSessionIndex implements ISessionIndex { return { items: query.limit !== undefined ? items.slice(0, query.limit) : items }; } - const workspaceIds = query.workspaceIds ?? (await this.listWorkspaceIds()); - const items: SessionSummary[] = []; - for (const workspaceId of workspaceIds) { - for (const sessionId of await this.listSessionIds(workspaceId)) { - const summary = await this.getCachedSummary(workspaceId, sessionId); - if (summary === undefined) continue; - if (summary.archived && query.includeArchived !== true) continue; - if (!matchesChildOf(summary, query.childOf)) continue; - items.push(summary); - } - } - items.sort((a, b) => b.updatedAt - a.updatedAt); - return { items: query.limit !== undefined ? items.slice(0, query.limit) : items }; + const cursor = await this.resolveCursor(generation, query); + if (cursor === undefined) return { items: [] }; + const limit = query.limit ?? UNBOUNDED; + const filter = { ...this.baseFilter(query), ...cursor.filter }; + const column = recencyColumn(generation); + const strip = (records: SessionSummary[]): SessionSummary[] => + records.map((record) => stripRecencyField(generation, record)); + + const page = + query.childOf !== undefined + ? await this.windowedPage( + (bounds, fetchLimit) => { + // The equality candidates (few children per parent) drive this + // path; only bound the column when the cursor actually constrains + // it — an unbounded column range would materialize per shard. + const base = this.queryStore + .query(collection) + .where(filter) + .orderBy('updatedAt', 'desc') + .limit(fetchLimit); + const q = + Object.keys(bounds).length > 0 ? base.whereColumn(column, bounds) : base; + return q.execute().then((p) => strip([...p.items])); + }, + cursor.bounds, + limit, + ) + : await this.windowedPage( + (bounds, fetchLimit) => + this.queryStore + .pageByColumn(collection, { + column, + dir: 'desc', + filter, + bounds, + limit: fetchLimit, + }) + .then((p) => strip([...p.items])), + cursor.bounds, + limit, + ); + return this.mergePending(page, query, cursor.position); } - private async getFromReadModel(id: string): Promise { - const cached: unknown = await this.queryStore.get(SESSION_COLLECTION, id); - if (isSessionSummaryShape(cached)) return cached; - for (const workspaceId of await this.listWorkspaceIds()) { - if (!(await this.hasSession(workspaceId, id))) continue; - return this.getCachedSummary(workspaceId, id); + private async countFromReadModel( + generation: number, + query: SessionCountQuery, + ): Promise { + const counters = sessionCountersCollection(generation); + const restricted = query.workspaceIds; + const workspaceIds = restricted ?? (await this.queryStore.listKeys(counters)); + const counts = await this.queryStore.getMany(counters, workspaceIds); + let total = 0; + for (const entry of counts.values()) { + total += query.includeArchived === true ? entry.active + entry.archived : entry.active; } - return undefined; + // Fold in the mirror queue (read-your-writes): queued creations count + // immediately, queued archive flips re-bucket, and queued updates to an + // already-counted session are a no-op. + const pending = this.mirror + .pending() + .filter((summary) => restricted === undefined || restricted.includes(summary.workspaceId)); + if (pending.length === 0) return total; + const stored = await this.queryStore.getMany( + sessionCollection(generation), + pending.map((summary) => summary.id), + ); + const weight = (archived: boolean): number => + query.includeArchived === true || !archived ? 1 : 0; + for (const summary of pending) { + const old = stored.get(summary.id); + total += weight(summary.archived) - (old === undefined ? 0 : weight(old.archived)); + } + return total; } - private async countActiveFromReadModel(workspaceIds: readonly string[]): Promise { - let count = 0; - for (const workspaceId of workspaceIds) { - for (const sessionId of await this.listSessionIds(workspaceId)) { - const summary = await this.getCachedSummary(workspaceId, sessionId); - if (summary !== undefined && !summary.archived) count += 1; - } + /** + * Canonical keyset window: fetch `limit + 1` rows under `bounds`; when the + * window is full, re-fetch the boundary tie group (`updatedAt` equal to the + * window's minimum) and merge, so a page cut inside a same-millisecond tie + * group never drops or duplicates an item across pages. Rows are re-sorted + * into the canonical (`updatedAt` desc, `id` desc) order — the engine's + * cross-shard tie order is deterministic but not canonical. + */ + private async windowedPage( + fetch: (bounds: ColumnBounds, limit: number) => Promise, + bounds: ColumnBounds, + limit: number, + ): Promise> { + const raw = await fetch(bounds, limit + 1); + if (raw.length <= limit) { + return { items: raw.toSorted(canonicalOrder) }; } - return count; + const minUpdatedAt = Math.min(...raw.map((summary) => summary.updatedAt)); + const tie = await fetch({ gte: minUpdatedAt, lte: minUpdatedAt }, TIE_REPAIR_LIMIT); + const merged = new Map(); + for (const summary of tie) merged.set(summary.id, summary); + for (const summary of raw) merged.set(summary.id, summary); + const items = [...merged.values()].toSorted(canonicalOrder); + const kept = items.slice(0, limit); + const hasMore = items.length > limit || tie.length >= TIE_REPAIR_LIMIT; + return { items: kept, nextCursor: hasMore ? kept.at(-1)!.id : undefined }; } - private readModelEnabled(): boolean { - return this.flags.enabled(READ_MODEL_FLAG); + /** + * Read-your-writes merge: pages fold in the mirror's queued summaries so a + * just-mutated session shows up before the flush lands. Cursor pages merge + * only the queued summaries that fall inside the page's canonical range + * (the queue is a tiny, transient window). + */ + private mergePending( + page: Page, + query: SessionListQuery, + position?: { u: number; id: string; before: boolean }, + ): Page { + const pending = this.mirror + .pending() + .filter( + (summary) => + (query.workspaceIds === undefined || query.workspaceIds.includes(summary.workspaceId)) && + (query.includeArchived === true || !summary.archived) && + summaryMatchesChildOf(summary, query.childOf) && + (position === undefined || + (position.before + ? summary.updatedAt < position.u || + (summary.updatedAt === position.u && summary.id < position.id) + : summary.updatedAt > position.u || + (summary.updatedAt === position.u && summary.id > position.id))), + ); + if (pending.length === 0) return page; + const merged = new Map(); + for (const summary of pending) merged.set(summary.id, summary); + for (const summary of page.items) { + if (!merged.has(summary.id)) merged.set(summary.id, summary); + } + const items = [...merged.values()].toSorted(canonicalOrder); + if (query.limit === undefined) return { items }; + const kept = items.slice(0, query.limit); + const hasMore = page.nextCursor !== undefined || items.length > query.limit; + return { items: kept, nextCursor: hasMore ? kept.at(-1)!.id : undefined }; } - private async ensureIndexes(): Promise { - if (this.indexesEnsured) return; - await this.queryStore.ensureIndex(SESSION_COLLECTION, { - kind: 'value', - name: 'byWorkspace', - field: 'workspaceId', - }); - await this.queryStore.ensureIndex(SESSION_COLLECTION, { - kind: 'compound', - name: 'byWsUpdated', - groupBy: 'workspaceId', - orderBy: 'updatedAt', - }); - this.indexesEnsured = true; + /** + * Resolve a keyset cursor id to its column bounds plus the exact + * tie-exclusion filter, in canonical order: strictly older (`before`) is + * `(updatedAt, id)` lexicographically below the cursor, strictly newer + * (`after`) is above. An unknown cursor id yields `undefined` — the caller + * answers an empty, terminal page. + */ + private async resolveCursor( + generation: number, + query: SessionListQuery, + ): Promise< + | { filter: QueryFilter; bounds: ColumnBounds; position?: { u: number; id: string; before: boolean } } + | undefined + > { + const id = query.before ?? query.after; + if (id === undefined) return { filter: {}, bounds: {} }; + // The mirror queue is consulted too: a cursor pointing at a session whose + // latest mutation has not been flushed yet must still resolve. + const storedValue: unknown = await this.queryStore.get(sessionCollection(generation), id); + const stored = isSessionSummaryShape(storedValue) ? storedValue : undefined; + const cursor = stored ?? this.mirror.pending().find((summary) => summary.id === id); + if (cursor === undefined) return undefined; + const u = cursor.updatedAt; + if (query.before !== undefined) { + return { + bounds: { lte: u }, + filter: { + $or: [ + { updatedAt: { $lt: u } }, + { updatedAt: u, id: { $lt: id } }, + ], + }, + position: { u, id, before: true }, + }; + } + return { + bounds: { gte: u }, + filter: { + $or: [ + { updatedAt: { $gt: u } }, + { updatedAt: u, id: { $gt: id } }, + ], + }, + position: { u, id, before: false }, + }; } - private async getCachedSummary( - workspaceId: string, - sessionId: string, - ): Promise { - const cached: unknown = await this.queryStore.get(SESSION_COLLECTION, sessionId); - if (isSessionSummaryShape(cached)) return cached; - const summary = await this.readSummary(workspaceId, sessionId); - if (summary !== undefined) { - await this.queryStore.put(SESSION_COLLECTION, sessionId, summary); + private baseFilter(query: SessionListQuery): QueryFilter { + const filter: Record = {}; + if (query.workspaceIds !== undefined) { + filter['workspaceId'] = + query.workspaceIds.length === 1 + ? query.workspaceIds[0] + : { $in: [...query.workspaceIds] }; } - return summary; + if (query.childOf !== undefined) { + filter[`custom.${PARENT_SESSION_ID_KEY}`] = query.childOf; + filter[`custom.${CHILD_SESSION_KIND_KEY}`] = CHILD_SESSION_KIND; + } + if (query.includeArchived !== true) filter['archived'] = { $ne: true }; + return filter; + } + + // ---- authoritative (legacy) path -------------------------------------------- + + private get sessionsScope(): string { + return this.bootstrap.scope('sessions'); } private async listLegacy(query: SessionListQuery): Promise> { @@ -264,97 +628,63 @@ export class FileSessionIndex implements ISessionIndex { return { items: query.limit !== undefined ? items.slice(0, query.limit) : items }; } - const workspaceIds = query.workspaceIds ?? (await this.listWorkspaceIds()); - const items: SessionSummary[] = []; + const workspaceIds = query.workspaceIds ?? (await listWorkspaceIds(this.storage, this.sessionsScope)); + const collected: SessionSummary[] = []; for (const workspaceId of workspaceIds) { - for (const sessionId of await this.listSessionIds(workspaceId)) { - const summary = await this.readSummary(workspaceId, sessionId); + for (const sessionId of await listSessionIds(this.storage, this.sessionsScope, workspaceId)) { + const summary = await readSessionSummary(this.docs, this.sessionsScope, workspaceId, sessionId); if (summary === undefined) continue; if (summary.archived && query.includeArchived !== true) continue; - if (!matchesChildOf(summary, query.childOf)) continue; - items.push(summary); + if (!summaryMatchesChildOf(summary, query.childOf)) continue; + collected.push(summary); } } - items.sort((a, b) => b.updatedAt - a.updatedAt); - return { items: query.limit !== undefined ? items.slice(0, query.limit) : items }; + const items = collected.toSorted(canonicalOrder); + + let start = 0; + let end = items.length; + const cursorId = query.before ?? query.after; + if (cursorId !== undefined) { + const index = items.findIndex((summary) => summary.id === cursorId); + if (index === -1) return { items: [] }; + if (query.before !== undefined) start = index + 1; + else end = index; + } + const window = items.slice(start, end); + if (query.limit === undefined) return { items: window }; + const kept = window.slice(0, query.limit); + return { + items: kept, + nextCursor: window.length > query.limit ? kept.at(-1)!.id : undefined, + }; } private async getLegacy(id: string): Promise { - for (const workspaceId of await this.listWorkspaceIds()) { - if (!(await this.hasSession(workspaceId, id))) continue; - const summary = await this.readSummary(workspaceId, id); + for (const workspaceId of await listWorkspaceIds(this.storage, this.sessionsScope)) { + const sessionIds = await listSessionIds(this.storage, this.sessionsScope, workspaceId); + if (!sessionIds.includes(id)) continue; + const summary = await readSessionSummary(this.docs, this.sessionsScope, workspaceId, id); if (summary !== undefined) return summary; } return undefined; } - private async countActiveLegacy(workspaceIds: readonly string[]): Promise { + private async countLegacy(query: SessionCountQuery): Promise { let count = 0; + const workspaceIds = + query.workspaceIds ?? (await listWorkspaceIds(this.storage, this.sessionsScope)); for (const workspaceId of workspaceIds) { - for (const sessionId of await this.listSessionIds(workspaceId)) { - const summary = await this.readSummary(workspaceId, sessionId); - if (summary !== undefined && !summary.archived) count += 1; + for (const sessionId of await listSessionIds(this.storage, this.sessionsScope, workspaceId)) { + const summary = await readSessionSummary(this.docs, this.sessionsScope, workspaceId, sessionId); + if (summary === undefined) continue; + if (query.includeArchived === true || !summary.archived) count += 1; } } return count; } - private get sessionsScope(): string { - return this.bootstrap.scope('sessions'); - } - - private async listWorkspaceIds(): Promise { - try { - return await this.storage.list(this.sessionsScope); - } catch { - return []; - } - } - - private async listSessionIds(workspaceId: string): Promise { - try { - return await this.storage.list(`${this.sessionsScope}/${workspaceId}`); - } catch { - return []; - } - } - - private async hasSession(workspaceId: string, sessionId: string): Promise { - const ids = await this.listSessionIds(workspaceId); - return ids.includes(sessionId); - } - - private async readSummary( - workspaceId: string, - sessionId: string, - ): Promise { - const base = `${this.sessionsScope}/${workspaceId}/${sessionId}`; - const meta = (await this.readMeta(base)) ?? (await this.readMeta(`${base}/${META_SCOPE}`)); - if (meta === undefined) return undefined; - const rawCustom = meta['custom']; - const custom = - rawCustom !== null && typeof rawCustom === 'object' && !Array.isArray(rawCustom) - ? (rawCustom as Record) - : undefined; - return { - id: sessionId, - workspaceId, - cwd: recoverCwd(meta), - title: typeof meta['title'] === 'string' ? meta['title'] : undefined, - lastPrompt: typeof meta['lastPrompt'] === 'string' ? meta['lastPrompt'] : undefined, - createdAt: parseTime(meta['createdAt']), - updatedAt: parseTime(meta['updatedAt']), - archived: meta['archived'] === true, - custom, - }; - } - - private async readMeta(scope: string): Promise | undefined> { - try { - return await this.docs.get>(scope, META_KEY); - } catch { - return undefined; - } + private readModelEnabled(): boolean { + return this.flags.enabled(READ_MODEL_FLAG); } } diff --git a/packages/agent-core-v2/src/app/sessionIndex/sessionIndexSource.ts b/packages/agent-core-v2/src/app/sessionIndex/sessionIndexSource.ts new file mode 100644 index 00000000000..3846c01675c --- /dev/null +++ b/packages/agent-core-v2/src/app/sessionIndex/sessionIndexSource.ts @@ -0,0 +1,188 @@ +/** + * `sessionIndex` domain (L2) — authoritative session-metadata scanning. + * + * Reads the persisted session set through the `storage` access-pattern + * stores, rooted at the `sessionsDir` path layout fact from `bootstrap`. The + * directory tree `///` is the + * authoritative index: workspace and session ids are enumerated via + * `IFileSystemStorageService.list`, and each session's metadata document is + * read via `IAtomicDocumentStore` to build its summary. + * + * The session metadata document lives at `/state.json`, a layout + * shared by v1 and v2; the `version` field distinguishes them (`2` = v2, + * epoch-ms timestamps; absent = v1, ISO-string timestamps). The reader also + * falls back to the legacy `/session-meta/state.json` path for v2 + * sessions written before the layouts were unified. Both timestamp + * representations are normalized to epoch ms. + * + * These helpers serve the index's authoritative fallback (legacy path), the + * projector's full scans, and reconciliation — pure functions over injected + * stores, owning no state themselves. + */ + +import { IAtomicDocumentStore } from '#/persistence/interface/atomicDocumentStore'; +import { IFileSystemStorageService } from '#/persistence/interface/storage'; + +import { CHILD_SESSION_KIND, CHILD_SESSION_KIND_KEY, type SessionSummary } from './sessionIndex'; + +const META_SCOPE = 'session-meta'; +const META_KEY = 'state.json'; + +export function parseTime(value: unknown): number { + if (typeof value === 'number' && Number.isFinite(value)) return value; + if (typeof value === 'string') { + const parsed = Date.parse(value); + if (!Number.isNaN(parsed)) return parsed; + } + return 0; +} + +export function recoverCwd(meta: Record): string | undefined { + if (typeof meta['cwd'] === 'string' && meta['cwd'].length > 0) return meta['cwd']; + if (typeof meta['workDir'] === 'string' && meta['workDir'].length > 0) { + return meta['workDir']; + } + const custom = meta['custom']; + if (custom !== null && typeof custom === 'object' && !Array.isArray(custom)) { + const fromCustom = (custom as Record)['cwd']; + if (typeof fromCustom === 'string' && fromCustom.length > 0) return fromCustom; + } + return undefined; +} + +/** The single construction path for summaries — field order is fixed so a + * stored summary deep-compares equal to a fresh projection of the same + * metadata document. */ +export function buildSessionSummary(fields: { + id: string; + workspaceId: string; + cwd?: string; + title?: string; + lastPrompt?: string; + createdAt: number; + updatedAt: number; + archived: boolean; + custom?: Record; +}): SessionSummary { + return { + id: fields.id, + workspaceId: fields.workspaceId, + cwd: fields.cwd, + title: fields.title, + lastPrompt: fields.lastPrompt, + createdAt: fields.createdAt, + updatedAt: fields.updatedAt, + archived: fields.archived, + custom: fields.custom, + }; +} + +export function summaryMatchesChildOf( + summary: SessionSummary, + parentId: string | undefined, +): boolean { + if (parentId === undefined) return true; + const custom = summary.custom; + return ( + custom?.['parent_session_id'] === parentId && + custom?.[CHILD_SESSION_KIND_KEY] === CHILD_SESSION_KIND + ); +} + +/** Deep-enough equality for reconciliation: the projection-relevant fields, + * with `custom` compared structurally (both sides are JSON-round-tripped + * values built by `buildSessionSummary`, so key order is stable). */ +export function summaryEquals(a: SessionSummary, b: SessionSummary): boolean { + return ( + a.id === b.id && + a.workspaceId === b.workspaceId && + a.cwd === b.cwd && + a.title === b.title && + a.lastPrompt === b.lastPrompt && + a.createdAt === b.createdAt && + a.updatedAt === b.updatedAt && + a.archived === b.archived && + JSON.stringify(a.custom) === JSON.stringify(b.custom) + ); +} + +export async function listWorkspaceIds( + storage: IFileSystemStorageService, + sessionsScope: string, +): Promise { + try { + return await storage.list(sessionsScope); + } catch { + return []; + } +} + +export async function listSessionIds( + storage: IFileSystemStorageService, + sessionsScope: string, + workspaceId: string, +): Promise { + try { + return await storage.list(`${sessionsScope}/${workspaceId}`); + } catch { + return []; + } +} + +export async function readSessionSummary( + docs: IAtomicDocumentStore, + sessionsScope: string, + workspaceId: string, + sessionId: string, +): Promise { + const base = `${sessionsScope}/${workspaceId}/${sessionId}`; + const meta = (await readMeta(docs, base)) ?? (await readMeta(docs, `${base}/${META_SCOPE}`)); + if (meta === undefined) return undefined; + const rawCustom = meta['custom']; + const custom = + rawCustom !== null && typeof rawCustom === 'object' && !Array.isArray(rawCustom) + ? (rawCustom as Record) + : undefined; + return buildSessionSummary({ + id: sessionId, + workspaceId, + cwd: recoverCwd(meta), + title: typeof meta['title'] === 'string' ? meta['title'] : undefined, + lastPrompt: typeof meta['lastPrompt'] === 'string' ? meta['lastPrompt'] : undefined, + createdAt: parseTime(meta['createdAt']), + updatedAt: parseTime(meta['updatedAt']), + archived: meta['archived'] === true, + custom, + }); +} + +async function readMeta( + docs: IAtomicDocumentStore, + scope: string, +): Promise | undefined> { + try { + return await docs.get>(scope, META_KEY); + } catch { + return undefined; + } +} + +/** Bounded-concurrency map: resolves every item through `fn`, dropping + * `undefined` results, with at most `concurrency` calls in flight. */ +export async function mapBounded( + items: readonly T[], + concurrency: number, + fn: (item: T) => Promise, +): Promise { + const out: R[] = []; + let next = 0; + const workers = Array.from({ length: Math.min(concurrency, items.length) }, async () => { + while (next < items.length) { + const item = items[next++]!; + const value = await fn(item); + if (value !== undefined) out.push(value); + } + }); + await Promise.all(workers); + return out; +} diff --git a/packages/agent-core-v2/src/app/workspaceSessions/workspaceSessionsService.ts b/packages/agent-core-v2/src/app/workspaceSessions/workspaceSessionsService.ts index 8dbaf3facf9..9e9c3492b75 100644 --- a/packages/agent-core-v2/src/app/workspaceSessions/workspaceSessionsService.ts +++ b/packages/agent-core-v2/src/app/workspaceSessions/workspaceSessionsService.ts @@ -25,14 +25,16 @@ export class WorkspaceSessionsService implements IWorkspaceSessions { async listRecent(workspaceId: string): Promise { const workspaceIds = await this.aliases.resolveAliasIds(workspaceId); - const page = await this.index.list({ workspaceIds, limit: RECENT_SESSIONS_LIMIT }); + const page = await this.index.listRecent({ workspaceIds, limit: RECENT_SESSIONS_LIMIT }); return page.items; } async count(workspaceId: string): Promise { + // One set-query over the alias set (legacy split buckets): a single merged + // count cannot double-count, and a singleton set behaves exactly as + // before. const workspaceIds = await this.aliases.resolveAliasIds(workspaceId); - const page = await this.index.list({ workspaceIds, includeArchived: true }); - return page.items.length; + return this.index.count({ workspaceIds, includeArchived: true }); } } diff --git a/packages/agent-core-v2/src/index.ts b/packages/agent-core-v2/src/index.ts index 24c61c7f74b..ac335f7685e 100644 --- a/packages/agent-core-v2/src/index.ts +++ b/packages/agent-core-v2/src/index.ts @@ -93,6 +93,7 @@ export type { export * from '#/app/sessionIndex/sessionIndex'; export * from '#/app/sessionIndex/sessionIndexService'; +export * from '#/app/sessionIndex/sessionIndexMirrorService'; export * from '#/session/sessionMetadata/sessionMetadata'; export * from '#/session/sessionMetadata/sessionMetadataService'; export * from '#/session/sessionActivity/sessionActivity'; diff --git a/packages/agent-core-v2/src/persistence/backends/minidb/miniDbQueryStore.ts b/packages/agent-core-v2/src/persistence/backends/minidb/miniDbQueryStore.ts index 7d3ddf91e89..747939b1c2b 100644 --- a/packages/agent-core-v2/src/persistence/backends/minidb/miniDbQueryStore.ts +++ b/packages/agent-core-v2/src/persistence/backends/minidb/miniDbQueryStore.ts @@ -38,6 +38,16 @@ * cluster-wide registry, and value indexes are created `sparse` so documents * from other collections (which lack the indexed field) are skipped. * + * Ordered columns map to the engine's `dt` channels: `put`/`batch` forward + * `columns` as `SetOptions.dt`, and `pageByColumn` issues a dt-bounded, + * dt-sorted, limited query — which the engine serves by walking its ordered + * column structure with early stop instead of materializing and sorting all + * candidates. `pageByColumn` deliberately sends no key prefix (a key range + * would disqualify that walk); callers keep column names collection-unique + * per the `IQueryStore` contract. `listKeys`/`dropCollection` are prefix + * scans (deletes applied in chunks); `getMany` is the cluster `mget` (one + * reader call per touched shard). + * * Bound at App scope as a peer of the other access-pattern stores. */ @@ -55,6 +65,8 @@ import { IBootstrapService } from '#/app/bootstrap/bootstrap'; import { IQueryStore, type Checkpoint, + type ColumnBounds, + type ColumnPageQuery, type IndexDef, type IQuery, type Page, @@ -68,6 +80,7 @@ const CHECKPOINT_COLLECTION = '__checkpoint__'; const STORE_SUBDIR = 'query-store'; const SHARD_COUNT = 16; const LOCK_ACQUIRE_TIMEOUT_MS = 1000; +const DROP_BATCH_SIZE = 500; function physicalKey(collection: string, key: string): string { return `${collection}${SEP}${key}`; @@ -169,8 +182,15 @@ export class MiniDbQueryStore extends Disposable implements IQueryStore { } } - async put(collection: string, key: string, value: T): Promise { - await this.withDb((db) => db.set(physicalKey(collection, key), value)); + async put( + collection: string, + key: string, + value: T, + options?: { columns?: Record }, + ): Promise { + await this.withDb((db) => + db.set(physicalKey(collection, key), value, { dt: options?.columns }), + ); } async batch(ops: readonly WriteOp[]): Promise { @@ -179,7 +199,12 @@ export class MiniDbQueryStore extends Disposable implements IQueryStore { db.batch( ops.map((op) => op.kind === 'put' - ? { op: 'set' as const, key: physicalKey(op.collection, op.key), value: op.value } + ? { + op: 'set' as const, + key: physicalKey(op.collection, op.key), + value: op.value, + dt: op.columns, + } : { op: 'del' as const, key: physicalKey(op.collection, op.key) }, ), ), @@ -194,6 +219,52 @@ export class MiniDbQueryStore extends Disposable implements IQueryStore { return this.withDb((db) => db.get(physicalKey(collection, key)) as Promise); } + async getMany(collection: string, keys: readonly string[]): Promise> { + if (keys.length === 0) return new Map(); + const values = await this.withDb((db) => + db.mget(keys.map((key) => physicalKey(collection, key))), + ); + const out = new Map(); + values.forEach((value, index) => { + if (value !== undefined) out.set(keys[index]!, value as T); + }); + return out; + } + + async pageByColumn(collection: string, query: ColumnPageQuery): Promise> { + // No key prefix: a key-range disqualifies the engine's ordered-column + // walk, and the column is only ever declared by this collection's writes, + // so the walk visits no foreign rows. Cross-collection contamination is + // prevented by the contract (column names are store-wide). + const dir = query.dir ?? 'asc'; + const rows = (await this.withDb((db) => + db.query({ + dt: { [query.column]: query.bounds ?? {} }, + filter: query.filter as Record | undefined, + sort: { [query.column]: dir === 'desc' ? -1 : 1 }, + limit: query.limit, + }), + )) as ReadonlyArray<{ value: T }>; + return { items: rows.map((row) => row.value) }; + } + + async listKeys(collection: string): Promise { + const prefix = `${collection}${SEP}`; + const entries = await this.withDb((db) => db.scan({ prefix })); + return entries.map((entry) => entry.key.slice(prefix.length)); + } + + async dropCollection(collection: string): Promise { + const prefix = `${collection}${SEP}`; + const entries = await this.withDb((db) => db.scan({ prefix })); + for (let start = 0; start < entries.length; start += DROP_BATCH_SIZE) { + const chunk = entries.slice(start, start + DROP_BATCH_SIZE); + await this.withDb((db) => + db.batch(chunk.map((entry) => ({ op: 'del' as const, key: entry.key }))), + ); + } + } + query(collection: string): IQuery { return new MiniDbQuery((op) => this.withDb(op), collection); } @@ -234,6 +305,7 @@ export class MiniDbQueryStore extends Disposable implements IQueryStore { class MiniDbQuery implements IQuery { private filter: QueryFilter = {}; + private column?: { name: string; bounds: ColumnBounds }; private sortField?: string; private sortDir: SortDir = 'asc'; private lim?: number; @@ -249,6 +321,11 @@ class MiniDbQuery implements IQuery { return this; } + whereColumn(column: string, bounds: ColumnBounds): IQuery { + this.column = { name: column, bounds }; + return this; + } + orderBy(field: string, dir: SortDir = 'asc'): IQuery { this.sortField = field; this.sortDir = dir; @@ -269,6 +346,7 @@ class MiniDbQuery implements IQuery { const prefix = `${this.collection}${SEP}`; const q: QueryOptions = { key: { prefix } }; if (Object.keys(this.filter).length > 0) q.filter = this.filter as Record; + if (this.column !== undefined) q.dt = { [this.column.name]: this.column.bounds }; if (this.sortField !== undefined) { q.sort = { [this.sortField]: this.sortDir === 'desc' ? -1 : 1 }; } diff --git a/packages/agent-core-v2/src/persistence/interface/queryStore.ts b/packages/agent-core-v2/src/persistence/interface/queryStore.ts index b90f2fa5e65..5763f3bcfc8 100644 --- a/packages/agent-core-v2/src/persistence/interface/queryStore.ts +++ b/packages/agent-core-v2/src/persistence/interface/queryStore.ts @@ -14,6 +14,15 @@ * * `collection` is a logical table (an engine may encode it as a key prefix). * Values are plain JSON-shaped objects; indexes are declared over their fields. + * + * Ordered columns: a record may carry *columns* — numeric scalars declared at + * write time — that an engine keeps in an ordered structure so + * `pageByColumn` can serve bounded, sorted pages without scanning and + * re-sorting the whole collection. A column named `x` must duplicate a + * numeric field `x` present in the value (engines may order by either), and + * column names are store-wide: two collections must not reuse the same + * column name with different semantics. Sharding, WAL offsets, and engine + * generations stay backend-private and never appear here. */ import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; @@ -43,6 +52,12 @@ export type QueryFilter = { export interface IQuery { where(filter: QueryFilter): IQuery; + /** + * Restrict to records whose ordered column `column` falls inside `bounds`. + * The column must have been declared at write time (`put`/`batch` with + * `columns`). + */ + whereColumn(column: string, bounds: ColumnBounds): IQuery; orderBy(field: string, dir?: SortDir): IQuery; limit(n: number): IQuery; cursor(cursor: string | undefined): IQuery; @@ -72,22 +87,68 @@ export interface TextIndexDef { export type IndexDef = ValueIndexDef | CompoundIndexDef | TextIndexDef; export type WriteOp = - | { readonly kind: 'put'; readonly collection: string; readonly key: string; readonly value: unknown } + | { + readonly kind: 'put'; + readonly collection: string; + readonly key: string; + readonly value: unknown; + readonly columns?: Record; + } | { readonly kind: 'delete'; readonly collection: string; readonly key: string }; export interface Checkpoint { readonly seq: number; } +/** Numeric range bounds over an ordered column; every bound is optional. */ +export interface ColumnBounds { + readonly gt?: number; + readonly gte?: number; + readonly lt?: number; + readonly lte?: number; +} + +/** + * A bounded page over an ordered column: rows whose column value falls inside + * `bounds` (all bounds optional), filtered by `filter`, ordered by the column + * in `dir` (default `'asc'`), at most `limit` rows. Rows sharing a column + * value come back in a deterministic but engine-specific order; a caller that + * needs a total order re-sorts the (bounded) page itself. + */ +export interface ColumnPageQuery { + readonly column: string; + readonly dir?: SortDir; + readonly filter?: QueryFilter; + readonly bounds?: ColumnBounds; + readonly limit: number; +} + export interface IQueryStore { readonly _serviceBrand: undefined; - put(collection: string, key: string, value: T): Promise; + put( + collection: string, + key: string, + value: T, + options?: { columns?: Record }, + ): Promise; batch(ops: readonly WriteOp[]): Promise; delete(collection: string, key: string): Promise; get(collection: string, key: string): Promise; + /** Point reads for several keys; missing keys are absent from the result. */ + getMany(collection: string, keys: readonly string[]): Promise>; query(collection: string): IQuery; + /** + * Bounded page over an ordered column (see `ColumnPageQuery`). This is the + * keyset-pagination primitive: it must stay cheap even over large + * collections (index walk, not a full scan + in-memory sort). + */ + pageByColumn(collection: string, query: ColumnPageQuery): Promise>; ensureIndex(collection: string, def: IndexDef): Promise; + /** Every key currently in the collection (engine key decoding applied). */ + listKeys(collection: string): Promise; + /** Delete the whole collection; a no-op when it does not exist. */ + dropCollection(collection: string): Promise; getCheckpoint(source: string): Promise; setCheckpoint(source: string, checkpoint: Checkpoint): Promise; close(): Promise; diff --git a/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadataService.ts b/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadataService.ts index 3c1be42969e..b3120b5fe83 100644 --- a/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadataService.ts +++ b/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadataService.ts @@ -16,12 +16,14 @@ * never reorders session listings. Bound at Session scope. * * Read-model mirroring (flag `persistence_minidb_readmodel`): after a metadata - * update is persisted, the fresh summary is mirrored into the `IQueryStore` - * derived read model so session listings can be served without re-reading - * `state.json`. Mirroring is best-effort (a failure is logged, not - * thrown) and is a no-op when the flag is off. Initial creation in `load()` is - * intentionally not mirrored — a not-yet-mirrored session is simply a cold - * read-model miss that is backfilled on first read. + * update is persisted, the fresh summary is recorded into the App-scoped + * `ISessionIndexMirror` — a bounded, coalescing queue that flushes to the + * `IQueryStore` read model off the user completion path. The mutation + * completes with the authoritative `state.json` write; it never waits on the + * derived store (no mirror flush, no query-store lock). First-time creation in + * `load()` records too — a new session must appear in listings immediately + * (the mirror's pending queue feeds the index's read-your-writes merge); + * loading an *existing* document (session resume) stays silent. */ import { Disposable } from '#/_base/di/lifecycle'; @@ -29,9 +31,9 @@ import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/ import { Emitter, type Event } from '#/_base/event'; import { ILogService } from '#/_base/log/log'; import { defineState } from '#/_base/state/stateRegistry'; -import { IFlagService } from '#/app/flag/flag'; +import { ISessionIndexMirror } from '#/app/sessionIndex/sessionIndex'; +import { buildSessionSummary } from '#/app/sessionIndex/sessionIndexSource'; import { IAtomicDocumentStore } from '#/persistence/interface/atomicDocumentStore'; -import { IQueryStore } from '#/persistence/interface/queryStore'; import { ISessionContext } from '#/session/sessionContext/sessionContext'; import { ISessionStateService } from '#/session/state/sessionState'; @@ -45,8 +47,6 @@ import { } from './sessionMetadata'; const META_KEY = 'state.json'; -const SESSION_COLLECTION = 'session'; -const READ_MODEL_FLAG = 'persistence_minidb_readmodel'; export const sessionMetadataDataKey = defineState( 'sessionMetadata.data', @@ -69,8 +69,7 @@ export class SessionMetadata extends Disposable implements ISessionMetadata { @ISessionContext private readonly ctx: ISessionContext, @IAtomicDocumentStore private readonly store: IAtomicDocumentStore, @ILogService private readonly log: ILogService, - @IQueryStore private readonly queryStore: IQueryStore, - @IFlagService private readonly flags: IFlagService, + @ISessionIndexMirror private readonly mirror: ISessionIndexMirror, ) { super(); this.states.register(sessionMetadataDataKey); @@ -100,7 +99,7 @@ export class SessionMetadata extends Disposable implements ISessionMetadata { await this.ready; this.data = { ...this.data, ...patch, updatedAt: Date.now() }; await this.store.set(this.scope, META_KEY, this.data); - await this.mirrorToReadModel(); + this.mirrorToReadModel(); this._onDidChangeMetadata.fire({ changed: Object.keys(patch) as (keyof SessionMeta)[], }); @@ -130,10 +129,9 @@ export class SessionMetadata extends Disposable implements ISessionMetadata { return run; } - private async mirrorToReadModel(): Promise { - if (!this.flags.enabled(READ_MODEL_FLAG)) return; - try { - await this.queryStore.put(SESSION_COLLECTION, this.ctx.sessionId, { + private mirrorToReadModel(): void { + this.mirror.record( + buildSessionSummary({ id: this.data.id, workspaceId: this.ctx.workspaceId, cwd: this.ctx.cwd, @@ -143,13 +141,8 @@ export class SessionMetadata extends Disposable implements ISessionMetadata { updatedAt: this.data.updatedAt, archived: this.data.archived === true, custom: this.data.custom, - }); - } catch (error) { - this.log.warn('failed to mirror session metadata to read model', { - sessionId: this.ctx.sessionId, - error: String(error), - }); - } + }), + ); } private async load(): Promise { @@ -178,6 +171,7 @@ export class SessionMetadata extends Disposable implements ISessionMetadata { custom: {}, }; await this.store.set(this.scope, META_KEY, this.data); + this.mirrorToReadModel(); this.log.debug('session metadata created', { sessionId: this.ctx.sessionId }); } } diff --git a/packages/agent-core-v2/test/app/sessionExport/sessionExport.test.ts b/packages/agent-core-v2/test/app/sessionExport/sessionExport.test.ts index d71ad574bec..5faa30643e7 100644 --- a/packages/agent-core-v2/test/app/sessionExport/sessionExport.test.ts +++ b/packages/agent-core-v2/test/app/sessionExport/sessionExport.test.ts @@ -893,9 +893,11 @@ function registerSessionExportServices( reg.defineInstance(ILogService, options.appLog ?? stubLog()); reg.defineInstance(ISessionIndex, { _serviceBrand: undefined, - list: async () => ({ items: options.summary === undefined ? [] : [options.summary] }), + prepare: async () => ({ state: 'uninitialized' as const, degradedCount: 0 }), + status: () => ({ state: 'uninitialized' as const, degradedCount: 0 }), + listRecent: async () => ({ items: options.summary === undefined ? [] : [options.summary] }), get: async () => options.summary, - countActive: async () => (options.summary === undefined || options.summary.archived ? 0 : 1), + count: async () => (options.summary === undefined || options.summary.archived ? 0 : 1), remove: async () => {}, }); reg.defineInstance(IWorkspaceLifecycleService, { diff --git a/packages/agent-core-v2/test/app/sessionIndex/sessionIndex.test.ts b/packages/agent-core-v2/test/app/sessionIndex/sessionIndex.test.ts index 366c166db06..b4aee085913 100644 --- a/packages/agent-core-v2/test/app/sessionIndex/sessionIndex.test.ts +++ b/packages/agent-core-v2/test/app/sessionIndex/sessionIndex.test.ts @@ -15,22 +15,37 @@ import { ILogService } from '#/_base/log/log'; import { encodeWorkDirKey } from '#/_base/utils/workdir-slug'; import { IBootstrapService } from '#/app/bootstrap/bootstrap'; import { IFlagService } from '#/app/flag/flag'; -import { ISessionIndex, type SessionSummary } from '#/app/sessionIndex/sessionIndex'; +import { + ISessionIndex, + ISessionIndexMirror, + type SessionSummary, +} from '#/app/sessionIndex/sessionIndex'; +import { recencyColumn, sessionCollection } from '#/app/sessionIndex/sessionIndexModel'; import { FileSessionIndex } from '#/app/sessionIndex/sessionIndexService'; +import { + drainSessionIndexMirror, + SessionIndexMirror, +} from '#/app/sessionIndex/sessionIndexMirrorService'; import { drainQueryStoreDisposals, MiniDbQueryStore } from '#/persistence/backends/minidb/miniDbQueryStore'; import { JsonAtomicDocumentStore } from '#/persistence/backends/node-fs/atomicDocumentStore'; import { FileStorageService } from '#/persistence/backends/node-fs/fileStorageService'; import { IAtomicDocumentStore } from '#/persistence/interface/atomicDocumentStore'; -import { IQueryStore } from '#/persistence/interface/queryStore'; -import { IFileSystemStorageService, StorageError, StorageErrors } from '#/persistence/interface/storage'; +import { IQueryStore, type WriteOp } from '#/persistence/interface/queryStore'; +import { IFileSystemStorageService } from '#/persistence/interface/storage'; +import { stubSessionIndexMirror } from './stubs'; import { stubBootstrap } from '../bootstrap/stubs'; import { stubFlag } from '../flag/stubs'; import { stubLog } from '../../_base/log/stubs'; import { stubQueryStore } from '../../persistence/interface/stubs'; const WORK_DIR = '/home/user/repo'; -const SESSION_COLLECTION = 'session'; + +function canonicalIds(summaries: readonly SessionSummary[]): string[] { + return [...summaries] + .sort((a, b) => (a.updatedAt !== b.updatedAt ? b.updatedAt - a.updatedAt : a.id < b.id ? 1 : -1)) + .map((s) => s.id); +} describe('FileSessionIndex (legacy)', () => { let homeDir: string; @@ -65,6 +80,7 @@ describe('FileSessionIndex (legacy)', () => { stubPair(IAtomicDocumentStore, new JsonAtomicDocumentStore(fileStorage)), stubPair(IBootstrapService, stubBootstrap(homeDir)), stubPair(IQueryStore, stubQueryStore()), + stubPair(ISessionIndexMirror, stubSessionIndexMirror()), stubPair(IFlagService, stubFlag(false)), stubPair(ILogService, stubLog()), ]); @@ -88,24 +104,24 @@ describe('FileSessionIndex (legacy)', () => { await fsp.mkdir(join(sessionsDir, wsId, sessionId), { recursive: true }); } - it('list returns non-archived sessions by default', async () => { + it('listRecent returns non-archived sessions by default', async () => { await seedSession('active', { createdAt: 1, updatedAt: 2 }); await seedSession('archived', { archived: true }); await seedEmpty('no-state'); const store = build(); - const page = await store.list({ workspaceIds: [workspaceId] }); - expect(page.items.map((s) => s.id).toSorted()).toEqual(['active']); + const page = await store.listRecent({ workspaceIds: [workspaceId] }); + expect(page.items.map((s) => s.id)).toEqual(['active']); expect(page.items[0]?.workspaceId).toBe(workspaceId); expect(page.items[0]?.archived).toBe(false); }); - it('list includes archived when requested', async () => { + it('listRecent includes archived when requested', async () => { await seedSession('active', {}); await seedSession('archived', { archived: true }); const store = build(); - const page = await store.list({ workspaceIds: [workspaceId], includeArchived: true }); + const page = await store.listRecent({ workspaceIds: [workspaceId], includeArchived: true }); expect(page.items.map((s) => s.id).toSorted()).toEqual(['active', 'archived']); }); @@ -132,22 +148,25 @@ describe('FileSessionIndex (legacy)', () => { expect((await store.get('none'))?.cwd).toBeUndefined(); }); - it('list filters by sessionId without enumerating all sessions', async () => { + it('listRecent filters by sessionId without enumerating all sessions', async () => { await seedSession('active', { title: 'hello' }); await seedSession('archived', { archived: true }); const store = build(); - const active = await store.list({ sessionId: 'active' }); + const active = await store.listRecent({ sessionId: 'active' }); expect(active.items.map((s) => s.id)).toEqual(['active']); - const archived = await store.list({ sessionId: 'archived' }); + const archived = await store.listRecent({ sessionId: 'archived' }); expect(archived.items).toEqual([]); - const archivedIncluded = await store.list({ sessionId: 'archived', includeArchived: true }); + const archivedIncluded = await store.listRecent({ + sessionId: 'archived', + includeArchived: true, + }); expect(archivedIncluded.items.map((s) => s.id)).toEqual(['archived']); }); - it('list filters by childOf using the parent_session_id + child_session_kind markers', async () => { + it('listRecent filters by childOf using the parent_session_id + child_session_kind markers', async () => { await seedSession('parent', { createdAt: 1, updatedAt: 10 }); await seedSession('child-a', { createdAt: 2, @@ -171,22 +190,23 @@ describe('FileSessionIndex (legacy)', () => { }); const store = build(); - const page = await store.list({ childOf: 'parent' }); + const page = await store.listRecent({ childOf: 'parent' }); expect(page.items.map((s) => s.id).toSorted()).toEqual(['child-a', 'child-b']); }); - it('countActive counts non-archived sessions', async () => { + it('count counts non-archived sessions by default and everything with includeArchived', async () => { await seedSession('a', {}); await seedSession('b', {}); await seedSession('archived', { archived: true }); await seedEmpty('no-state'); const store = build(); - expect(await store.countActive([workspaceId])).toBe(2); - expect(await store.countActive(['wd_unknown'])).toBe(0); + expect(await store.count({ workspaceIds: [workspaceId] })).toBe(2); + expect(await store.count({ workspaceIds: [workspaceId], includeArchived: true })).toBe(3); + expect(await store.count({ workspaceIds: ['wd_unknown'] })).toBe(0); }); - it('list merges a workspace-id set into one recency-ordered page', async () => { + it('listRecent merges a workspace-id set into one recency-ordered page', async () => { const otherId = encodeWorkDirKey('/home/user/other'); await seedSession('a1', { createdAt: 1, updatedAt: 1 }); await seedSession('a3', { createdAt: 3, updatedAt: 3 }); @@ -194,44 +214,79 @@ describe('FileSessionIndex (legacy)', () => { await seedSession('b4', { createdAt: 4, updatedAt: 4 }, otherId); const store = build(); - const page = await store.list({ workspaceIds: [workspaceId, otherId] }); + const page = await store.listRecent({ workspaceIds: [workspaceId, otherId] }); expect(page.items.map((s) => s.id)).toEqual(['b4', 'a3', 'b2', 'a1']); expect(page.items[0]?.workspaceId).toBe(otherId); }); - it('list applies limit after the cross-bucket merge', async () => { + it('listRecent applies limit after the cross-bucket merge', async () => { const otherId = encodeWorkDirKey('/home/user/other'); await seedSession('a1', { createdAt: 1, updatedAt: 1 }); await seedSession('a3', { createdAt: 3, updatedAt: 3 }); await seedSession('b2', { createdAt: 2, updatedAt: 2 }, otherId); const store = build(); - const page = await store.list({ workspaceIds: [workspaceId, otherId], limit: 2 }); + const page = await store.listRecent({ workspaceIds: [workspaceId, otherId], limit: 2 }); expect(page.items.map((s) => s.id)).toEqual(['a3', 'b2']); + expect(page.nextCursor).toBe('b2'); }); - it('list filters archived across every bucket of the id set', async () => { + it('listRecent filters archived across every bucket of the id set', async () => { const otherId = encodeWorkDirKey('/home/user/other'); await seedSession('active', {}); await seedSession('archived', { archived: true }, otherId); const store = build(); - const visible = await store.list({ workspaceIds: [workspaceId, otherId] }); + const visible = await store.listRecent({ workspaceIds: [workspaceId, otherId] }); expect(visible.items.map((s) => s.id)).toEqual(['active']); - const all = await store.list({ workspaceIds: [workspaceId, otherId], includeArchived: true }); + const all = await store.listRecent({ workspaceIds: [workspaceId, otherId], includeArchived: true }); expect(all.items.map((s) => s.id).toSorted()).toEqual(['active', 'archived']); }); - it('countActive sums over the workspace-id set', async () => { + it('count sums over the workspace-id set', async () => { const otherId = encodeWorkDirKey('/home/user/other'); await seedSession('a', {}); await seedSession('b', {}, otherId); await seedSession('archived', { archived: true }, otherId); const store = build(); - expect(await store.countActive([workspaceId, otherId])).toBe(2); - expect(await store.countActive([otherId])).toBe(1); + expect(await store.count({ workspaceIds: [workspaceId, otherId] })).toBe(2); + expect(await store.count({ workspaceIds: [otherId] })).toBe(1); + }); + + it('pages with the before/after keyset cursors', async () => { + for (let i = 0; i < 5; i++) { + await seedSession(`s${i}`, { createdAt: i, updatedAt: i }); + } + const store = build(); + + const page1 = await store.listRecent({ workspaceIds: [workspaceId], limit: 2 }); + expect(page1.items.map((s) => s.id)).toEqual(['s4', 's3']); + expect(page1.nextCursor).toBe('s3'); + + const page2 = await store.listRecent({ + workspaceIds: [workspaceId], + limit: 2, + before: page1.nextCursor, + }); + expect(page2.items.map((s) => s.id)).toEqual(['s2', 's1']); + expect(page2.nextCursor).toBe('s1'); + + const page3 = await store.listRecent({ + workspaceIds: [workspaceId], + limit: 2, + before: page2.nextCursor, + }); + expect(page3.items.map((s) => s.id)).toEqual(['s0']); + expect(page3.nextCursor).toBeUndefined(); + + const newer = await store.listRecent({ workspaceIds: [workspaceId], after: 's2' }); + expect(newer.items.map((s) => s.id)).toEqual(['s4', 's3']); + + const unknown = await store.listRecent({ workspaceIds: [workspaceId], before: 'missing' }); + expect(unknown.items).toEqual([]); + expect(unknown.nextCursor).toBeUndefined(); }); }); @@ -241,6 +296,7 @@ describe('FileSessionIndex (read model)', () => { let workspaceId: string; let disposeHost: (() => void) | undefined; let queryStore: IQueryStore; + let mirror: ISessionIndexMirror; beforeEach(async () => { _clearScopedRegistryForTests(); @@ -251,6 +307,13 @@ describe('FileSessionIndex (read model)', () => { ScopeActivation.OnDemand, 'sessionIndex', ); + registerScopedService( + LifecycleScope.App, + ISessionIndexMirror, + SessionIndexMirror, + ScopeActivation.OnDemand, + 'sessionIndex', + ); registerScopedService( LifecycleScope.App, IQueryStore, @@ -266,13 +329,14 @@ describe('FileSessionIndex (read model)', () => { afterEach(async () => { disposeHost?.(); disposeHost = undefined; - // The host's synchronous dispose() fires the query store's async close; - // await it so the rm below never races an in-flight ClusterDb close. + // The host's synchronous dispose() fires the mirror drain and the query + // store's async close; await both so the rm below never races them. + await drainSessionIndexMirror(); await drainQueryStoreDisposals(); await fsp.rm(homeDir, { recursive: true, force: true }); }); - function build(): ISessionIndex { + function build(): FileSessionIndex { const fileStorage = new FileStorageService(homeDir); const host = createScopedTestHost([ stubPair(IFileSystemStorageService, fileStorage), @@ -285,7 +349,8 @@ describe('FileSessionIndex (read model)', () => { host.dispose(); }; queryStore = host.app.accessor.get(IQueryStore); - return host.app.accessor.get(ISessionIndex); + mirror = host.app.accessor.get(ISessionIndexMirror); + return host.app.accessor.get(ISessionIndex) as FileSessionIndex; } async function seedSession( @@ -309,37 +374,110 @@ describe('FileSessionIndex (read model)', () => { }; } - it('list backfills from disk on a cold read model, then serves from it', async () => { + /** Walk every page via `before = nextCursor` and concatenate the ids. */ + async function walkPages( + store: FileSessionIndex, + query: { workspaceIds?: readonly string[]; includeArchived?: boolean }, + pageSize: number, + ): Promise { + const ids: string[] = []; + let cursor: string | undefined; + do { + const page = await store.listRecent({ ...query, limit: pageSize, before: cursor }); + ids.push(...page.items.map((s) => s.id)); + cursor = page.nextCursor; + } while (cursor !== undefined); + return ids; + } + + it('prepare projects the persisted sessions and publishes a generation', async () => { await seedSession('active', { title: 'hello', createdAt: 1, updatedAt: 2 }); await seedSession('archived', { archived: true }); const store = build(); - const first = await store.list({ workspaceIds: [workspaceId] }); - expect(first.items.map((s) => s.id)).toEqual(['active']); - expect(first.items[0]?.title).toBe('hello'); - - await queryStore.put( - SESSION_COLLECTION, - 'active', - summary('active', { title: 'renamed', updatedAt: 3 }), - ); - const second = await store.list({ workspaceIds: [workspaceId] }); - expect(second.items[0]?.title).toBe('renamed'); + expect(store.status()).toEqual({ state: 'uninitialized', degradedCount: 0 }); + + const status = await store.prepare(); + expect(status).toEqual({ state: 'ready', generation: 1, degradedCount: 0 }); + + const page = await store.listRecent({ workspaceIds: [workspaceId] }); + expect(page.items.map((s) => s.id)).toEqual(['active']); + expect(page.items[0]?.title).toBe('hello'); + expect(await store.get('active')).toMatchObject({ id: 'active', title: 'hello' }); + expect(await store.count({ workspaceIds: [workspaceId] })).toBe(1); + expect(await store.count({ workspaceIds: [workspaceId], includeArchived: true })).toBe(2); }); - it('get prefers the read model over disk', async () => { + it('serves warm reads without touching the session directories', async () => { + await seedSession('a', { title: 'a', createdAt: 1, updatedAt: 2 }); + await seedSession('b', { title: 'b', createdAt: 2, updatedAt: 3 }); + + // Count `list` calls on the byte layer: once the model is ready, the warm + // read paths must not enumerate a single session directory. + class CountingStorage extends FileStorageService { + listCalls = 0; + override async list(scope: string, prefix?: string): Promise { + this.listCalls += 1; + return super.list(scope, prefix); + } + } + const fileStorage = new CountingStorage(homeDir); + const host = createScopedTestHost([ + stubPair(IFileSystemStorageService, fileStorage), + stubPair(IAtomicDocumentStore, new JsonAtomicDocumentStore(fileStorage)), + stubPair(IBootstrapService, stubBootstrap(homeDir)), + stubPair(ILogService, stubLog()), + stubPair(IFlagService, stubFlag(true)), + ]); + disposeHost = () => { + host.dispose(); + }; + queryStore = host.app.accessor.get(IQueryStore); + mirror = host.app.accessor.get(ISessionIndexMirror); + const store = host.app.accessor.get(ISessionIndex) as FileSessionIndex; + await store.prepare(); + + fileStorage.listCalls = 0; + const page = await store.listRecent({ workspaceIds: [workspaceId], limit: 20 }); + expect(page.items).toHaveLength(2); + expect(await store.get('a')).toMatchObject({ id: 'a' }); + expect(await store.count({ workspaceIds: [workspaceId] })).toBe(2); + expect(fileStorage.listCalls).toBe(0); + }); + + it('paginates exactly through same-millisecond ties', async () => { + const specs: [string, number][] = [ + ['a', 100], + ['b', 100], + ['c', 100], + ['d', 100], + ['e', 90], + ['f', 90], + ['g', 90], + ['h', 80], + ['i', 80], + ['j', 70], + ]; + const summaries = specs.map(([id, updatedAt]) => summary(id, { updatedAt })); + for (const [id, updatedAt] of specs) { + await seedSession(id, { createdAt: updatedAt - 1, updatedAt }); + } const store = build(); - await queryStore.put(SESSION_COLLECTION, 'warm', summary('warm', { title: 'cached' })); - const got = await store.get('warm'); - expect(got?.title).toBe('cached'); + await store.prepare(); + + const walked = await walkPages(store, { workspaceIds: [workspaceId] }, 3); + expect(walked).toEqual(canonicalIds(summaries)); + expect(new Set(walked).size).toBe(specs.length); }); - it('list treats a cache entry missing required fields as a cold miss', async () => { + it('listRecent treats a cache entry missing required fields as a cold miss', async () => { await seedSession('s1', { title: 'on-disk', createdAt: 1, updatedAt: 2 }); const store = build(); + await store.prepare(); // Mirrors a poisoned entry written before `archived` was normalized to a // boolean (JSON dropped the undefined field entirely). - await queryStore.put(SESSION_COLLECTION, 's1', { + const collection = sessionCollection(1); + await queryStore.put(collection, 's1', { id: 's1', workspaceId, title: 'stale', @@ -347,27 +485,45 @@ describe('FileSessionIndex (read model)', () => { updatedAt: 2, }); - const page = await store.list({ workspaceIds: [workspaceId] }); + const page = await store.listRecent({ sessionId: 's1' }); expect(page.items).toHaveLength(1); expect(page.items[0]?.title).toBe('on-disk'); expect(page.items[0]?.archived).toBe(false); - // The bad entry is overwritten by the disk backfill. - const cached = await queryStore.get(SESSION_COLLECTION, 's1'); + // The bad entry is overwritten once the queued mirror re-record flushes. + await (mirror as SessionIndexMirror).drain(); + const cached = await queryStore.get(collection, 's1'); expect(cached?.archived).toBe(false); }); it('get falls back to disk when the cached entry fails the shape check', async () => { await seedSession('s1', { title: 'on-disk', createdAt: 1, updatedAt: 2 }); const store = build(); - await queryStore.put(SESSION_COLLECTION, 's1', { id: 's1' }); + await store.prepare(); + await queryStore.put(sessionCollection(1), 's1', { id: 's1' }); const got = await store.get('s1'); expect(got?.title).toBe('on-disk'); expect(got?.archived).toBe(false); }); - it('list filters by childOf from the read model', async () => { + it('walks all pages of a large listing without duplicates', async () => { + const specs: SessionSummary[] = []; + for (let i = 0; i < 25; i++) { + specs.push(summary(`s${String(i).padStart(2, '0')}`, { createdAt: i, updatedAt: i })); + await seedSession(`s${String(i).padStart(2, '0')}`, { createdAt: i, updatedAt: i }); + } + const store = build(); + await store.prepare(); + + const walked = await walkPages(store, { workspaceIds: [workspaceId] }, 10); + expect(walked).toEqual(canonicalIds(specs)); + + const newer = await store.listRecent({ workspaceIds: [workspaceId], after: 's20' }); + expect(newer.items.map((s) => s.id)).toEqual(['s24', 's23', 's22', 's21']); + }); + + it('listRecent filters by childOf from the read model', async () => { await seedSession('child-a', { createdAt: 2, updatedAt: 9, @@ -390,22 +546,12 @@ describe('FileSessionIndex (read model)', () => { }); const store = build(); - const page = await store.list({ childOf: 'parent' }); - expect(page.items.map((s) => s.id).toSorted()).toEqual(['child-a', 'child-b']); + await store.prepare(); + const page = await store.listRecent({ childOf: 'parent' }); + expect(page.items.map((s) => s.id)).toEqual(['child-a', 'child-b']); }); - it('countActive reflects read-model updates', async () => { - await seedSession('a', {}); - await seedSession('b', { archived: true }); - - const store = build(); - expect(await store.countActive([workspaceId])).toBe(1); - - await queryStore.put(SESSION_COLLECTION, 'a', summary('a', { archived: true })); - expect(await store.countActive([workspaceId])).toBe(0); - }); - - it('list merges a workspace-id set into one recency-ordered page', async () => { + it('listRecent merges a workspace-id set into one recency-ordered page', async () => { const otherId = encodeWorkDirKey('/home/user/other'); await seedSession('a1', { createdAt: 1, updatedAt: 1 }); await seedSession('a3', { createdAt: 3, updatedAt: 3 }); @@ -413,114 +559,308 @@ describe('FileSessionIndex (read model)', () => { await seedSession('b4', { createdAt: 4, updatedAt: 4 }, otherId); const store = build(); - const page = await store.list({ workspaceIds: [workspaceId, otherId] }); + await store.prepare(); + const page = await store.listRecent({ workspaceIds: [workspaceId, otherId] }); expect(page.items.map((s) => s.id)).toEqual(['b4', 'a3', 'b2', 'a1']); - expect(page.items[0]?.workspaceId).toBe(otherId); + expect(await store.count({ workspaceIds: [workspaceId, otherId] })).toBe(4); + expect(await store.count({ workspaceIds: [otherId] })).toBe(2); }); - it('countActive sums over the workspace-id set', async () => { - const otherId = encodeWorkDirKey('/home/user/other'); - await seedSession('a', {}); - await seedSession('b', {}, otherId); - await seedSession('archived', { archived: true }, otherId); + it('get falls back to the authoritative document for an un-mirrored session', async () => { + await seedSession('old', { title: 'projected', createdAt: 1, updatedAt: 2 }); + const store = build(); + await store.prepare(); + + // Written after the projection: not in the read model, not mirrored. + await seedSession('fresh', { title: 'from disk', createdAt: 3, updatedAt: 4 }); + const found = await store.get('fresh'); + expect(found?.title).toBe('from disk'); + // The fallback re-records it so the next read is warm. + expect(mirror.pending().map((s) => s.id)).toContain('fresh'); + }); + + it('cursor-less pages merge the mirror queue for read-your-writes', async () => { + await seedSession('a', { title: 'a', createdAt: 1, updatedAt: 2 }); + const store = build(); + await store.prepare(); + + // Recorded but not yet flushed (the mirror flushes on a 100ms timer). + mirror.record(summary('pending-one', { title: 'pending', createdAt: 3, updatedAt: 10 })); + const page = await store.listRecent({ workspaceIds: [workspaceId], limit: 20 }); + expect(page.items.map((s) => s.id)).toEqual(['pending-one', 'a']); + + await mirror.drain(); + expect(mirror.pending()).toEqual([]); + const after = await store.listRecent({ workspaceIds: [workspaceId], limit: 20 }); + expect(after.items.map((s) => s.id)).toEqual(['pending-one', 'a']); + expect(await store.count({ workspaceIds: [workspaceId] })).toBe(2); + }); + + it('resolves a keyset cursor that is still queued in the mirror', async () => { + await seedSession('a', { createdAt: 1, updatedAt: 2 }); + await seedSession('b', { createdAt: 2, updatedAt: 3 }); + const store = build(); + await store.prepare(); + + // The cursor session exists only in the mirror queue (not yet flushed). + mirror.record(summary('cursor-new', { createdAt: 3, updatedAt: 10 })); + const first = await store.listRecent({ workspaceIds: [workspaceId], limit: 1 }); + expect(first.items.map((s) => s.id)).toEqual(['cursor-new']); + expect(first.nextCursor).toBe('cursor-new'); + + const rest = await store.listRecent({ + workspaceIds: [workspaceId], + limit: 5, + before: first.nextCursor, + }); + expect(rest.items.map((s) => s.id)).toEqual(['b', 'a']); + expect(rest.nextCursor).toBeUndefined(); + + // An id that is in neither the model nor the queue stays a terminal page. + const unknown = await store.listRecent({ workspaceIds: [workspaceId], before: 'missing' }); + expect(unknown.items).toEqual([]); + }); + it('count folds the mirror queue in before the flush lands', async () => { + await seedSession('a', { createdAt: 1, updatedAt: 2 }); + await seedSession('b', { createdAt: 2, updatedAt: 3 }); const store = build(); - expect(await store.countActive([workspaceId, otherId])).toBe(2); - expect(await store.countActive([otherId])).toBe(1); - }); - - it('falls back to the legacy disk path when the query store is locked', async () => { - await seedSession('active', { title: 'from disk', createdAt: 1, updatedAt: 2 }); - - // The minidb cluster backend shares the store across processes and no - // longer produces storage.locked itself; stub it here so the - // disable-and-fall-back wiring stays under test. - const locked = new StorageError(StorageErrors.codes.STORAGE_LOCKED, 'locked by test'); - const lockedStore: IQueryStore = { - ...stubQueryStore(), - ensureIndex: async () => { throw locked; }, - get: async () => { throw locked; }, - query: () => { throw locked; }, + await store.prepare(); + expect(await store.count({ workspaceIds: [workspaceId] })).toBe(2); + + // A queued creation counts immediately; a queued archive flip re-buckets. + mirror.record(summary('new', { createdAt: 3, updatedAt: 4 })); + mirror.record(summary('a', { archived: true, updatedAt: 5 })); + expect(await store.count({ workspaceIds: [workspaceId] })).toBe(2); + expect(await store.count({ workspaceIds: [workspaceId], includeArchived: true })).toBe(3); + + await mirror.drain(); + expect(await store.count({ workspaceIds: [workspaceId] })).toBe(2); + expect(await store.count({ workspaceIds: [workspaceId], includeArchived: true })).toBe(3); + }); + + it('a crashed initial projection falls back to disk and recovers on retry', async () => { + await seedSession('a', { title: 'a', createdAt: 1, updatedAt: 2 }); + await seedSession('b', { title: 'b', createdAt: 2, updatedAt: 3 }); + + class FlakyQueryStore extends MiniDbQueryStore { + failNextBatch = false; + override async batch(ops: readonly WriteOp[]): Promise { + if (this.failNextBatch) { + this.failNextBatch = false; + throw new Error('injected projection crash'); + } + return super.batch(ops); + } + } + registerScopedService( + LifecycleScope.App, + IQueryStore, + FlakyQueryStore, + ScopeActivation.OnDemand, + 'storage', + ); + const fileStorage = new FileStorageService(homeDir); + const host = createScopedTestHost([ + stubPair(IFileSystemStorageService, fileStorage), + stubPair(IAtomicDocumentStore, new JsonAtomicDocumentStore(fileStorage)), + stubPair(IBootstrapService, stubBootstrap(homeDir)), + stubPair(ILogService, stubLog()), + stubPair(IFlagService, stubFlag(true)), + ]); + disposeHost = () => { + host.dispose(); }; - const warnings: string[] = []; - const log = { ...stubLog(), warn: (msg: string) => { warnings.push(msg); } }; + queryStore = host.app.accessor.get(IQueryStore); + mirror = host.app.accessor.get(ISessionIndexMirror); + const store = host.app.accessor.get(ISessionIndex) as FileSessionIndex; + + (queryStore as FlakyQueryStore).failNextBatch = true; + const status = await store.prepare(); + expect(status.state).toBe('degraded'); + expect(status.degradedCount).toBe(1); + // Reads still work — from the authoritative documents. + const fallback = await store.listRecent({ workspaceIds: [workspaceId] }); + expect(fallback.items.map((s) => s.id)).toEqual(['b', 'a']); + + const recovered = await store.prepare(); + expect(recovered).toEqual({ state: 'ready', generation: 1, degradedCount: 1 }); + const warm = await store.listRecent({ workspaceIds: [workspaceId] }); + expect(warm.items.map((s) => s.id)).toEqual(['b', 'a']); + }); + + it('a crashed re-projection keeps readers on the previous generation', async () => { + await seedSession('a', { createdAt: 1, updatedAt: 3 }); + await seedSession('b', { createdAt: 2, updatedAt: 2 }); + await seedSession('c', { createdAt: 3, updatedAt: 1 }); + + class FlakyQueryStore extends MiniDbQueryStore { + failNextBatch = false; + override async batch(ops: readonly WriteOp[]): Promise { + if (this.failNextBatch) { + this.failNextBatch = false; + throw new Error('injected projection crash'); + } + return super.batch(ops); + } + } + registerScopedService( + LifecycleScope.App, + IQueryStore, + FlakyQueryStore, + ScopeActivation.OnDemand, + 'storage', + ); const fileStorage = new FileStorageService(homeDir); const host = createScopedTestHost([ stubPair(IFileSystemStorageService, fileStorage), stubPair(IAtomicDocumentStore, new JsonAtomicDocumentStore(fileStorage)), stubPair(IBootstrapService, stubBootstrap(homeDir)), - stubPair(IQueryStore, lockedStore), - stubPair(ILogService, log), + stubPair(ILogService, stubLog()), stubPair(IFlagService, stubFlag(true)), ]); - disposeHost = () => { host.dispose(); }; - const store = host.app.accessor.get(ISessionIndex); - // The read model throws storage.locked; the index serves from disk. - const page = await store.list({ workspaceIds: [workspaceId] }); - expect(page.items.map((s) => s.id)).toEqual(['active']); - expect(page.items[0]?.title).toBe('from disk'); - expect(await store.get('active')).toMatchObject({ id: 'active', title: 'from disk' }); - expect(await store.countActive([workspaceId])).toBe(1); - // The lock is warned about once, then the read model stays disabled. - expect(warnings).toEqual(['query-store locked by another process; disabling read model']); + disposeHost = () => { + host.dispose(); + }; + queryStore = host.app.accessor.get(IQueryStore); + mirror = host.app.accessor.get(ISessionIndexMirror); + const store = host.app.accessor.get(ISessionIndex) as FileSessionIndex; + await store.prepare(); + expect(store.status().state).toBe('ready'); + + // The authoritative set changes, then the re-projection dies mid-write. + await fsp.rm(join(sessionsDir, workspaceId, 'b'), { recursive: true, force: true }); + (queryStore as FlakyQueryStore).failNextBatch = true; + await store.reprojectNow(); + + // Readers never noticed the crash: generation 1 is still served, with the + // session that no longer exists on disk. + expect(store.status()).toEqual({ state: 'ready', generation: 1, degradedCount: 0 }); + const page = await store.listRecent({ workspaceIds: [workspaceId] }); + expect(page.items.map((s) => s.id)).toEqual(['a', 'b', 'c']); + + await store.reprojectNow(); + expect(store.status()).toEqual({ state: 'ready', generation: 2, degradedCount: 0 }); + const rebuilt = await store.listRecent({ workspaceIds: [workspaceId] }); + expect(rebuilt.items.map((s) => s.id)).toEqual(['a', 'c']); }); - // -- stage-1 performance baselines ------------------------------------------ + it('reprojects automatically after the store is wiped, without per-request backfill', async () => { + await seedSession('a', { title: 'a', createdAt: 1, updatedAt: 3 }); + await seedSession('b', { title: 'b', createdAt: 2, updatedAt: 2 }); + + const first = build(); + await first.prepare(); + expect(first.status().state).toBe('ready'); + + // Simulate a corruption rebuild: the whole query-store directory is wiped + // (minidb's own rebuild machinery is covered in its package tests). + disposeHost?.(); + disposeHost = undefined; + await drainSessionIndexMirror(); + await drainQueryStoreDisposals(); + await fsp.rm(join(homeDir, 'cache', 'query-store'), { recursive: true, force: true }); + + const second = build(); + // The first read falls back to the authoritative documents and kicks the + // reprojection; once ready, the model is complete again. + const fallback = await second.listRecent({ workspaceIds: [workspaceId] }); + expect(fallback.items.map((s) => s.id)).toEqual(['a', 'b']); + const status = await second.prepare(); + expect(status.state).toBe('ready'); + const warm = await second.listRecent({ workspaceIds: [workspaceId] }); + expect(warm.items.map((s) => s.id)).toEqual(['a', 'b']); + expect(await second.count({ workspaceIds: [workspaceId] })).toBe(2); + }); + + it('reconciliation repairs external edits and deletions of state.json', async () => { + await seedSession('keep', { title: 'before', createdAt: 1, updatedAt: 3 }); + await seedSession('archived', { createdAt: 2, updatedAt: 2 }); + await seedSession('gone', { createdAt: 3, updatedAt: 1 }); + + const store = build(); + await store.prepare(); + expect(await store.count({ workspaceIds: [workspaceId] })).toBe(3); + + // External modification and deletion, behind the mirror's back. + await seedSession('keep', { title: 'after', createdAt: 1, updatedAt: 4 }); + await fsp.rm(join(sessionsDir, workspaceId, 'gone'), { recursive: true, force: true }); + await store.reconcileNow(); + + const page = await store.listRecent({ workspaceIds: [workspaceId] }); + expect(page.items.map((s) => s.id)).toEqual(['keep', 'archived']); + expect(page.items[0]?.title).toBe('after'); + expect(await store.get('gone')).toBeUndefined(); + expect(await store.count({ workspaceIds: [workspaceId] })).toBe(2); + }); + + // -- stage-3 performance baselines ------------------------------------------ // Not tight CI thresholds: numbers are logged as JSON for phase-to-phase - // comparison, and only a loose complexity budget is asserted so an - // accidental quadratic regression trips the test anywhere. + // comparison, and only loose complexity budgets are asserted so an + // accidental linear regression trips the test anywhere. - it('baseline: warm list() at 300 vs 1200 sessions stays within a linear budget', async () => { + it('baseline: warm listRecent(limit=20) at 1k vs 10k vs 50k sessions', async () => { const store = build(); - // Session ids are enumerated from disk; summaries come from the read - // model. Seed empty session dirs + batch-put the summaries so the list - // path serves fully warm reads. - const seed = async (from: number, to: number): Promise => { - const ops = []; - for (let i = from; i < to; i++) { - await fsp.mkdir(join(sessionsDir, workspaceId, `s${i}`), { recursive: true }); - ops.push({ - kind: 'put' as const, - collection: SESSION_COLLECTION, - key: `s${i}`, - value: summary(`s${i}`, { title: `session ${i}`, createdAt: i, updatedAt: i }), - }); + // A small on-disk seed publishes generation 1; scale rows are written + // directly into the generation (the mirror path is covered elsewhere). + await seedSession('seed', { createdAt: 0, updatedAt: 0 }); + await store.prepare(); + const collection = sessionCollection(1); + + const seedRows = async (from: number, to: number): Promise => { + for (let start = from; start < to; start += 500) { + const ops = []; + for (let i = start; i < Math.min(start + 500, to); i++) { + ops.push({ + kind: 'put' as const, + collection, + key: `s${i}`, + value: { + ...summary(`s${i}`, { title: `session ${i}`, createdAt: i, updatedAt: i + 1 }), + [recencyColumn(1)]: i + 1, + }, + columns: { [recencyColumn(1)]: i + 1 }, + }); + } + await queryStore.batch(ops); } - await queryStore.batch(ops); }; - const medianListMs = async (): Promise => { + const median = async (run: () => Promise, repeats = 5): Promise => { const runs: number[] = []; - for (let r = 0; r < 5; r++) { + for (let r = 0; r < repeats; r++) { const t0 = performance.now(); - const page = await store.list({ workspaceIds: [workspaceId], limit: 50 }); - expect(page.items.length).toBe(50); + await run(); runs.push(performance.now() - t0); } runs.sort((a, b) => a - b); return runs[(runs.length / 2) | 0]!; }; + const measure = async (): Promise<{ list: number; get: number; count: number }> => { + const list = await median(async () => { + const page = await store.listRecent({ workspaceIds: [workspaceId], limit: 20 }); + expect(page.items).toHaveLength(20); + }); + const get = await median(() => store.get('s0')); + const count = await median(() => store.count({ workspaceIds: [workspaceId] })); + return { list, get, count }; + }; - await seed(0, 300); - const small = await medianListMs(); - await seed(300, 1200); - const large = await medianListMs(); + await seedRows(0, 1_000); + const at1k = await measure(); + await seedRows(1_000, 10_000); + const at10k = await measure(); + await seedRows(10_000, 50_000); + const at50k = await measure(); console.log( - `[baseline] sessionIndex warm list ${JSON.stringify({ sessions: [300, 1200], medianMs: [small, large] })}`, + `[baseline] sessionIndex read-model ${JSON.stringify({ sessions: [1000, 10000, 50000], list: [at1k.list, at10k.list, at50k.list], get: [at1k.get, at10k.get, at50k.get], count: [at1k.count, at10k.count, at50k.count] })}`, ); - // 4x the data must cost well under 10x the time (a linear top page is ~4x). - expect(large).toBeLessThan(small * 10 + 100); - }, 60_000); - it('baseline: cold backfill over 200 session dirs', async () => { - for (let i = 0; i < 200; i++) { - await seedSession(`s${i}`, { title: `session ${i}`, createdAt: i, updatedAt: i }); - } - const store = build(); - const t0 = performance.now(); - const page = await store.list({ workspaceIds: [workspaceId], limit: 50 }); - const ms = performance.now() - t0; - expect(page.items.length).toBe(50); - expect(page.items[0]?.id).toBe('s199'); - console.log(`[baseline] sessionIndex cold backfill ${JSON.stringify({ sessions: 200, ms })}`); - }); + // The acceptance budgets (p95): list < 100ms, get < 50ms, count < 50ms. + // The medians asserted here sit far below; the complexity check is the + // real guard: 50x the rows must not cost ~50x the time. + expect(at50k.list).toBeLessThan(100); + expect(at50k.get).toBeLessThan(50); + expect(at50k.count).toBeLessThan(50); + expect(at50k.list).toBeLessThan(at1k.list * 10 + 50); + }, 120_000); }); diff --git a/packages/agent-core-v2/test/app/sessionIndex/sessionIndexMirror.test.ts b/packages/agent-core-v2/test/app/sessionIndex/sessionIndexMirror.test.ts new file mode 100644 index 00000000000..aec5af222ef --- /dev/null +++ b/packages/agent-core-v2/test/app/sessionIndex/sessionIndexMirror.test.ts @@ -0,0 +1,216 @@ +import { promises as fsp } from 'node:fs'; +import os from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { + LifecycleScope, + ScopeActivation, + _clearScopedRegistryForTests, + registerScopedService, +} from '#/_base/di/scope'; +import { createScopedTestHost, stubPair } from '#/_base/di/test'; +import { ILogService } from '#/_base/log/log'; +import { IBootstrapService } from '#/app/bootstrap/bootstrap'; +import { IFlagService } from '#/app/flag/flag'; +import { ISessionIndexMirror } from '#/app/sessionIndex/sessionIndex'; +import { + SESSION_INDEX_MANIFEST, + sessionCollection, + sessionCountersCollection, + type SessionWorkspaceCounts, +} from '#/app/sessionIndex/sessionIndexModel'; +import { + drainSessionIndexMirror, + SessionIndexMirror, +} from '#/app/sessionIndex/sessionIndexMirrorService'; +import { drainQueryStoreDisposals, MiniDbQueryStore } from '#/persistence/backends/minidb/miniDbQueryStore'; +import { IQueryStore } from '#/persistence/interface/queryStore'; + +import { stubBootstrap } from '../bootstrap/stubs'; +import { stubFlag } from '../flag/stubs'; +import { stubLog } from '../../_base/log/stubs'; + +const WORKSPACE = 'wd_test'; +const GENERATION = 1; + +function summary(id: string, overrides: Record = {}) { + return { + id, + workspaceId: WORKSPACE, + createdAt: 1, + updatedAt: 2, + archived: false, + ...overrides, + }; +} + +describe('SessionIndexMirror', () => { + let homeDir: string; + let disposeHost: (() => void) | undefined; + let queryStore: IQueryStore; + let mirror: ISessionIndexMirror; + + beforeEach(async () => { + _clearScopedRegistryForTests(); + registerScopedService( + LifecycleScope.App, + ISessionIndexMirror, + SessionIndexMirror, + ScopeActivation.OnDemand, + 'sessionIndex', + ); + registerScopedService( + LifecycleScope.App, + IQueryStore, + MiniDbQueryStore, + ScopeActivation.OnDemand, + 'storage', + ); + homeDir = await fsp.mkdtemp(join(os.tmpdir(), 'session-mirror-')); + }); + + afterEach(async () => { + disposeHost?.(); + disposeHost = undefined; + await drainSessionIndexMirror(); + await drainQueryStoreDisposals(); + await fsp.rm(homeDir, { recursive: true, force: true }); + }); + + async function publishGeneration(): Promise { + await queryStore.setCheckpoint(SESSION_INDEX_MANIFEST, { seq: GENERATION }); + } + + function build(flagEnabled = true): ISessionIndexMirror { + const host = createScopedTestHost([ + stubPair(IBootstrapService, stubBootstrap(homeDir)), + stubPair(ILogService, stubLog()), + stubPair(IFlagService, stubFlag(flagEnabled)), + ]); + disposeHost = () => { + host.dispose(); + }; + queryStore = host.app.accessor.get(IQueryStore); + mirror = host.app.accessor.get(ISessionIndexMirror); + return mirror; + } + + it('coalesces updates per session and drains summaries with counters', async () => { + build(); + await publishGeneration(); + + mirror.record(summary('a', { title: 'first', updatedAt: 1 })); + mirror.record(summary('a', { title: 'latest', updatedAt: 5 })); + mirror.record(summary('b', { archived: true, updatedAt: 3 })); + expect(mirror.pending().map((s) => s.id).sort()).toEqual(['a', 'b']); + + await mirror.drain(); + expect(mirror.pending()).toEqual([]); + + const stored = await queryStore.getMany<{ title?: string; archived: boolean }>( + sessionCollection(GENERATION), + ['a', 'b'], + ); + expect(stored.get('a')).toMatchObject({ title: 'latest', archived: false }); + expect(stored.get('b')).toMatchObject({ archived: true }); + + const counters = await queryStore.getMany( + sessionCountersCollection(GENERATION), + [WORKSPACE], + ); + expect(counters.get(WORKSPACE)).toEqual({ active: 1, archived: 1 }); + }); + + it('tracks archive transitions against the stored summary', async () => { + build(); + await publishGeneration(); + await queryStore.put(sessionCollection(GENERATION), 'a', summary('a'), { + columns: { updatedAt: 2 }, + }); + await queryStore.put(sessionCountersCollection(GENERATION), WORKSPACE, { + active: 1, + archived: 0, + } satisfies SessionWorkspaceCounts); + + mirror.record(summary('a', { archived: true, updatedAt: 9 })); + await mirror.drain(); + + const counters = await queryStore.getMany( + sessionCountersCollection(GENERATION), + [WORKSPACE], + ); + expect(counters.get(WORKSPACE)).toEqual({ active: 0, archived: 1 }); + }); + + it('is a no-op when the read-model flag is off', async () => { + build(false); + mirror.record(summary('a')); + expect(mirror.pending()).toEqual([]); + await mirror.drain(); + }); + + it('never blocks record on the query store', async () => { + const host = createScopedTestHost([ + stubPair(IBootstrapService, stubBootstrap(homeDir)), + stubPair(ILogService, stubLog()), + stubPair(IFlagService, stubFlag(true)), + ]); + disposeHost = () => { + host.dispose(); + }; + queryStore = host.app.accessor.get(IQueryStore); + // Hang the store: every manifest read takes a second. record() must stay + // synchronous regardless — the user mutation path never waits. + const real = queryStore.getCheckpoint.bind(queryStore); + queryStore.getCheckpoint = async (source: string) => { + await new Promise((resolve) => setTimeout(resolve, 1000)); + return real(source); + }; + mirror = host.app.accessor.get(ISessionIndexMirror); + + const t0 = performance.now(); + for (let i = 0; i < 600; i++) { + mirror.record(summary(`s${i}`, { updatedAt: i })); + } + const elapsed = performance.now() - t0; + expect(elapsed).toBeLessThan(500); + expect(mirror.pending().length).toBe(600); + }, 10_000); + + it('keeps entries queued when no generation is published yet', async () => { + build(); + mirror.record(summary('a')); + await mirror.drain(); + // Nothing lost: without a published generation the entries stay queued + // (the running projection covers them from the authoritative documents). + expect(mirror.pending().map((s) => s.id)).toEqual(['a']); + }); + + it('retries a failed flush instead of dropping entries', async () => { + build(); + await publishGeneration(); + + const realBatch = queryStore.batch.bind(queryStore); + let failures = 1; + queryStore.batch = async (ops) => { + if (failures > 0) { + failures -= 1; + throw new Error('injected flush failure'); + } + return realBatch(ops); + }; + + mirror.record(summary('a', { updatedAt: 7 })); + await mirror.drain(); + // The first drain attempt failed; the entries must still be queued. + expect(mirror.pending().map((s) => s.id)).toEqual(['a']); + + await mirror.drain(); + expect(mirror.pending()).toEqual([]); + expect(await queryStore.get(sessionCollection(GENERATION), 'a')).toMatchObject({ + id: 'a', + }); + }); +}); diff --git a/packages/agent-core-v2/test/app/sessionIndex/stubs.ts b/packages/agent-core-v2/test/app/sessionIndex/stubs.ts new file mode 100644 index 00000000000..a4caf45a527 --- /dev/null +++ b/packages/agent-core-v2/test/app/sessionIndex/stubs.ts @@ -0,0 +1,26 @@ +/** + * `sessionIndex` test stubs — capturing no-op `ISessionIndexMirror` for unit + * tests. + * + * Lives under `test/` (not `src/`). Import from a relative path. + */ + +import { + ISessionIndexMirror, + type SessionSummary, +} from '#/app/sessionIndex/sessionIndex'; + +export function stubSessionIndexMirror(): ISessionIndexMirror & { + readonly recorded: SessionSummary[]; +} { + const recorded: SessionSummary[] = []; + return { + _serviceBrand: undefined, + recorded, + record: (summary) => { + recorded.push(summary); + }, + pending: () => recorded, + drain: async () => {}, + }; +} diff --git a/packages/agent-core-v2/test/app/workspaceLifecycle/workspaceLifecycle.test.ts b/packages/agent-core-v2/test/app/workspaceLifecycle/workspaceLifecycle.test.ts index 7e4883930a4..40d355ad604 100644 --- a/packages/agent-core-v2/test/app/workspaceLifecycle/workspaceLifecycle.test.ts +++ b/packages/agent-core-v2/test/app/workspaceLifecycle/workspaceLifecycle.test.ts @@ -106,9 +106,11 @@ function catalogStub() { function sessionIndexStub(): ISessionIndex { return { _serviceBrand: undefined, - list: () => Promise.resolve({ items: [], total: 0, hasMore: false }), + prepare: () => Promise.resolve({ state: 'ready', generation: 0, degradedCount: 0 }), + status: () => ({ state: 'ready', generation: 0, degradedCount: 0 }), get: () => Promise.resolve(undefined), - countActive: () => Promise.resolve(0), + listRecent: () => Promise.resolve({ items: [] }), + count: () => Promise.resolve(0), remove: () => Promise.resolve(), }; } diff --git a/packages/agent-core-v2/test/app/workspaceSessions/workspaceSessionsService.test.ts b/packages/agent-core-v2/test/app/workspaceSessions/workspaceSessionsService.test.ts index fc12dbcfcbe..77c0636d323 100644 --- a/packages/agent-core-v2/test/app/workspaceSessions/workspaceSessionsService.test.ts +++ b/packages/agent-core-v2/test/app/workspaceSessions/workspaceSessionsService.test.ts @@ -9,6 +9,8 @@ import { import { createScopedTestHost, stubPair } from '#/_base/di/test'; import { ISessionIndex, + type SessionCountQuery, + type SessionIndexStatus, type SessionListQuery, type SessionSummary, } from '#/app/sessionIndex/sessionIndex'; @@ -22,12 +24,20 @@ import { WorkspaceSessionsService } from '#/app/workspaceSessions/workspaceSessi class FakeSessionIndex implements ISessionIndex { readonly _serviceBrand: undefined; lastListQuery: SessionListQuery | undefined; - listQueries: SessionListQuery[] = []; + lastCountQuery: SessionCountQuery | undefined; items: readonly SessionSummary[] = []; + countResult = 0; - async list(query: SessionListQuery) { + async prepare(): Promise { + return this.status(); + } + + status(): SessionIndexStatus { + return { state: 'uninitialized', degradedCount: 0 }; + } + + async listRecent(query: SessionListQuery) { this.lastListQuery = query; - this.listQueries.push(query); return { items: this.items }; } @@ -35,8 +45,9 @@ class FakeSessionIndex implements ISessionIndex { return undefined; } - async countActive(_workspaceIds: readonly string[]): Promise { - return 0; + async count(query: SessionCountQuery): Promise { + this.lastCountQuery = query; + return this.countResult; } async remove(_id: string): Promise {} @@ -119,14 +130,10 @@ describe('WorkspaceSessionsService', () => { it('count folds aliases and includes archived sessions', async () => { const { sessions, index, aliases } = build(); aliases.aliases['wd_abc'] = ['wd_abc', 'wd_abc_legacy']; - index.items = [ - summary('s3', 'wd_abc_legacy', 300), - summary('s2', 'wd_abc', 200), - summary('s1', 'wd_abc', 100), - ]; + index.countResult = 3; await expect(sessions.count('wd_abc')).resolves.toBe(3); - expect(index.lastListQuery).toEqual({ + expect(index.lastCountQuery).toEqual({ workspaceIds: ['wd_abc', 'wd_abc_legacy'], includeArchived: true, }); 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 9c0e720510f..1f1ae364049 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 @@ -187,4 +187,148 @@ describe('MiniDbQueryStore', () => { expect(entries).toContain(`shard-${String(i).padStart(2, '0')}`); } }); + + it('getMany returns present values and skips missing keys', async () => { + const store = build(); + await store.batch([ + { kind: 'put', collection: COLLECTION, key: 'a', value: { v: 1 } }, + { kind: 'put', collection: COLLECTION, key: 'b', value: { v: 2 } }, + ]); + const found = await store.getMany<{ v: number }>(COLLECTION, ['a', 'missing', 'b']); + expect([...found.keys()].sort()).toEqual(['a', 'b']); + expect(found.get('a')).toEqual({ v: 1 }); + expect(found.get('b')).toEqual({ v: 2 }); + expect(await store.getMany(COLLECTION, [])).toEqual(new Map()); + }); + + it('pageByColumn walks the ordered column with bounds, filter and limit', async () => { + const store = build(); + const seed = []; + for (let i = 0; i < 50; i++) { + seed.push({ + kind: 'put' as const, + collection: COLLECTION, + key: `s${i}`, + value: { id: `s${i}`, ws: i % 2 === 0 ? 'x' : 'y', updatedAt: i }, + columns: { updatedAt: i }, + }); + } + await store.batch(seed); + + const first = await store.pageByColumn<{ id: string; updatedAt: number }>(COLLECTION, { + column: 'updatedAt', + dir: 'desc', + limit: 20, + }); + expect(first.items).toHaveLength(20); + expect(first.items[0]?.updatedAt).toBe(49); + expect(first.items[19]?.updatedAt).toBe(30); + + const bounded = await store.pageByColumn<{ id: string; updatedAt: number }>(COLLECTION, { + column: 'updatedAt', + dir: 'desc', + bounds: { lt: 30 }, + filter: { ws: 'x' }, + limit: 3, + }); + expect(bounded.items.map((i) => i.updatedAt)).toEqual([28, 26, 24]); + + const asc = await store.pageByColumn<{ id: string; updatedAt: number }>(COLLECTION, { + column: 'updatedAt', + bounds: { gte: 45 }, + limit: 10, + }); + expect(asc.items.map((i) => i.updatedAt)).toEqual([45, 46, 47, 48, 49]); + }); + + it('pageByColumn stays cheap as the collection grows', async () => { + const store = build(); + const seed = async (from: number, to: number): Promise => { + for (let start = from; start < to; start += 500) { + const ops = []; + for (let i = start; i < Math.min(start + 500, to); i++) { + ops.push({ + kind: 'put' as const, + collection: COLLECTION, + key: `s${i}`, + value: { id: `s${i}`, updatedAt: i }, + columns: { updatedAt: i }, + }); + } + await store.batch(ops); + } + }; + const medianPageMs = async (): Promise => { + const runs: number[] = []; + for (let r = 0; r < 5; r++) { + const t0 = performance.now(); + const page = await store.pageByColumn(COLLECTION, { + column: 'updatedAt', + dir: 'desc', + limit: 20, + }); + expect(page.items).toHaveLength(20); + runs.push(performance.now() - t0); + } + runs.sort((a, b) => a - b); + return runs[(runs.length / 2) | 0]!; + }; + + await seed(0, 1_000); + const small = await medianPageMs(); + await seed(1_000, 10_000); + const large = await medianPageMs(); + console.log( + `[baseline] queryStore pageByColumn ${JSON.stringify({ rows: [1000, 10000], medianMs: [small, large] })}`, + ); + // 10x the rows must not cost 10x the time: the ordered-column walk is + // O(log N + limit), not a full scan. + expect(large).toBeLessThan(small * 10 + 100); + }, 60_000); + + it('listKeys and dropCollection operate on the whole collection', async () => { + const store = build(); + await store.batch([ + { kind: 'put', collection: COLLECTION, key: 'a', value: { v: 1 } }, + { kind: 'put', collection: COLLECTION, key: 'b', value: { v: 2 } }, + { kind: 'put', collection: 'other', key: 'c', value: { v: 3 } }, + ]); + expect((await store.listKeys(COLLECTION)).toSorted()).toEqual(['a', 'b']); + + await store.dropCollection(COLLECTION); + expect(await store.listKeys(COLLECTION)).toEqual([]); + expect(await store.get(COLLECTION, 'a')).toBeUndefined(); + expect(await store.get('other', 'c')).toEqual({ v: 3 }); + await store.dropCollection(COLLECTION); + }); + + it('whereColumn bounds the query range alongside equality filters', async () => { + const store = build(); + await store.ensureIndex(COLLECTION, { kind: 'value', name: 'byParent', field: 'parent' }); + await store.batch( + ( + [ + ['a', 'p', 1], + ['b', 'p', 5], + ['c', 'p', 9], + ['d', 'q', 7], + ] as const + ).map(([id, parent, n]) => ({ + kind: 'put' as const, + collection: COLLECTION, + key: id, + value: { id, parent, updatedAt: n }, + columns: { updatedAt: n }, + })), + ); + + const page = await store + .query<{ id: string; parent: string; updatedAt: number }>(COLLECTION) + .where({ parent: 'p' }) + .whereColumn('updatedAt', { lt: 6 }) + .orderBy('updatedAt', 'desc') + .limit(10) + .execute(); + expect(page.items.map((i) => i.id)).toEqual(['b', 'a']); + }); }); diff --git a/packages/agent-core-v2/test/persistence/interface/stubs.ts b/packages/agent-core-v2/test/persistence/interface/stubs.ts index 77eeea5c057..16f642c6a44 100644 --- a/packages/agent-core-v2/test/persistence/interface/stubs.ts +++ b/packages/agent-core-v2/test/persistence/interface/stubs.ts @@ -7,6 +7,7 @@ import { IQueryStore, type Checkpoint, + type ColumnPageQuery, type IQuery, type Page, } from '#/persistence/interface/queryStore'; @@ -18,8 +19,12 @@ export function stubQueryStore(): IQueryStore { batch: async (_ops) => {}, delete: async (_c: string, _k: string) => {}, get: async (_c: string, _k: string) => undefined as T | undefined, + getMany: async (_c: string, _keys: readonly string[]) => new Map(), query: (_c: string) => emptyQuery(), + pageByColumn: async (_c: string, _q: ColumnPageQuery) => ({ items: [] as T[] }), ensureIndex: async (_c, _d) => {}, + listKeys: async (_c: string) => [], + dropCollection: async (_c: string) => {}, getCheckpoint: async (_s: string) => undefined as Checkpoint | undefined, setCheckpoint: async (_s: string, _c: Checkpoint) => {}, close: async () => {}, @@ -30,6 +35,7 @@ function emptyQuery(): IQuery { const page: Page = { items: [] }; const q: IQuery = { where: () => q, + whereColumn: () => q, orderBy: () => q, limit: () => q, cursor: () => q, diff --git a/packages/agent-core-v2/test/session/sessionMetadata/sessionMetadata.test.ts b/packages/agent-core-v2/test/session/sessionMetadata/sessionMetadata.test.ts index ca8a2cd787b..78a0506dc72 100644 --- a/packages/agent-core-v2/test/session/sessionMetadata/sessionMetadata.test.ts +++ b/packages/agent-core-v2/test/session/sessionMetadata/sessionMetadata.test.ts @@ -4,8 +4,8 @@ import { SyncDescriptor } from '#/_base/di/descriptors'; import { DisposableStore } from '#/_base/di/lifecycle'; import { ServiceCollection } from '#/_base/di/serviceCollection'; import { TestInstantiationService } from '#/_base/di/test'; -import { IFlagService } from '#/app/flag/flag'; import { ILogService } from '#/_base/log/log'; +import { ISessionIndexMirror } from '#/app/sessionIndex/sessionIndex'; import { ISessionContext, makeSessionContext } from '#/session/sessionContext/sessionContext'; import { ISessionMetadata } from '#/session/sessionMetadata/sessionMetadata'; import { SessionMetadata } from '#/session/sessionMetadata/sessionMetadataService'; @@ -15,11 +15,9 @@ import { JsonAtomicDocumentStore } from '#/persistence/backends/node-fs/atomicDo import { IFileSystemStorageService } from '#/persistence/interface/storage'; import { IAtomicDocumentStore } from '#/persistence/interface/atomicDocumentStore'; import { InMemoryStorageService } from '#/persistence/backends/memory/inMemoryStorageService'; -import { IQueryStore } from '#/persistence/interface/queryStore'; -import { stubFlag } from '../../app/flag/stubs'; +import { stubSessionIndexMirror } from '../../app/sessionIndex/stubs'; import { stubLog } from '../../_base/log/stubs'; -import { stubQueryStore } from '../../persistence/interface/stubs'; const META_SCOPE = 'sessions/wd_test/s1/session-meta'; @@ -46,14 +44,15 @@ function makeContext(): ISessionContext { describe('SessionMetadata', () => { let disposables: DisposableStore; let ix: TestInstantiationService; + let mirror: ReturnType; beforeEach(() => { disposables = new DisposableStore(); ix = disposables.add(new TestInstantiationService()); + mirror = stubSessionIndexMirror(); ix.stub(ILogService, stubLog()); ix.stub(ISessionContext, makeContext()); - ix.stub(IQueryStore, stubQueryStore()); - ix.stub(IFlagService, stubFlag(false)); + ix.stub(ISessionIndexMirror, mirror); ix.set(ISessionStateService, new SyncDescriptor(SessionStateService)); ix.set(IFileSystemStorageService, new SyncDescriptor(InMemoryStorageService)); ix.set(IAtomicDocumentStore, new SyncDescriptor(JsonAtomicDocumentStore)); @@ -107,20 +106,15 @@ describe('SessionMetadata', () => { custom: {}, }); - const writes: unknown[] = []; - ix.stub(IQueryStore, { - ...stubQueryStore(), - put: async (_c: string, _k: string, value: unknown) => { - writes.push(value); - }, - }); - ix.stub(IFlagService, stubFlag(true)); - const meta = ix.get(ISessionMetadata); + await meta.ready; + // A resume loads silently; only mutations reach the mirror. + expect(mirror.recorded).toEqual([]); + await meta.update({ title: 'x' }); - expect(writes).toHaveLength(1); - expect(writes[0]).toMatchObject({ id: 's1', archived: false }); + expect(mirror.recorded).toHaveLength(1); + expect(mirror.recorded[0]).toMatchObject({ id: 's1', archived: false }); }); it('persists across instances', async () => { @@ -290,4 +284,44 @@ describe('SessionMetadata', () => { expect(next.agents?.['main']?.labels).toEqual({ swarmItem: 'src/a.ts' }); expect(next.updatedAt).toBeGreaterThan(before); }); + + it('records the fresh summary into the session index mirror on update', async () => { + const meta = ix.get(ISessionMetadata); + await meta.ready; + // First-time creation is recorded (a new session must list immediately). + expect(mirror.recorded).toHaveLength(1); + + await meta.update({ title: 'mirrored' }); + + expect(mirror.recorded).toHaveLength(2); + expect(mirror.recorded[1]).toMatchObject({ + id: 's1', + workspaceId: 'wd_test', + title: 'mirrored', + archived: false, + }); + expect(mirror.recorded[1]?.updatedAt).toBe((await meta.read()).updatedAt); + }); + + it('does not re-record when loading an existing document', async () => { + const store = ix.get(IAtomicDocumentStore); + await store.set(META_SCOPE, 'state.json', { + id: 's1', + version: 2, + createdAt: 1700000000000, + updatedAt: 1700000000000, + archived: false, + agents: {}, + custom: {}, + }); + + const meta = ix.get(ISessionMetadata); + await meta.ready; + // A resume loads silently; only mutations reach the mirror. + expect(mirror.recorded).toEqual([]); + + await meta.setArchived(true); + expect(mirror.recorded).toHaveLength(1); + expect(mirror.recorded[0]?.archived).toBe(true); + }); }); diff --git a/packages/agent-core-v2/test/workspace/sessionLifecycle/sessionLifecycle.test.ts b/packages/agent-core-v2/test/workspace/sessionLifecycle/sessionLifecycle.test.ts index 2466cf44905..10a88d87c01 100644 --- a/packages/agent-core-v2/test/workspace/sessionLifecycle/sessionLifecycle.test.ts +++ b/packages/agent-core-v2/test/workspace/sessionLifecycle/sessionLifecycle.test.ts @@ -287,9 +287,11 @@ function persistentWorkspaceStub(): IWorkspaceService { function sessionIndexStub(): ISessionIndex { return { _serviceBrand: undefined, - list: () => Promise.resolve({ items: [], total: 0, hasMore: false }), + prepare: () => Promise.resolve({ state: 'uninitialized', degradedCount: 0 }), + status: () => ({ state: 'uninitialized', degradedCount: 0 }), + listRecent: () => Promise.resolve({ items: [] }), get: () => Promise.resolve(undefined), - countActive: () => Promise.resolve(0), + count: () => Promise.resolve(0), remove: () => Promise.resolve(), }; } @@ -309,9 +311,11 @@ function sessionIndexWithSummary( }; return { _serviceBrand: undefined, - list: () => Promise.resolve({ items: [summary], total: 1, hasMore: false }), + prepare: () => Promise.resolve({ state: 'uninitialized', degradedCount: 0 }), + status: () => ({ state: 'uninitialized', degradedCount: 0 }), + listRecent: () => Promise.resolve({ items: [summary] }), get: (id) => Promise.resolve(id === sessionId ? summary : undefined), - countActive: () => Promise.resolve(1), + count: () => Promise.resolve(1), remove: () => Promise.resolve(), }; } diff --git a/packages/kap-server/src/routes/sessions.ts b/packages/kap-server/src/routes/sessions.ts index 70e8ec95c08..d290bc95499 100644 --- a/packages/kap-server/src/routes/sessions.ts +++ b/packages/kap-server/src/routes/sessions.ts @@ -103,6 +103,7 @@ import { type ContextMessage, type IAgentScopeHandle, type Scope, + type SessionSummary, } from '@moonshot-ai/agent-core-v2'; import { ErrorCode } from '../protocol/error-codes'; import { pageResponseSchema } from '../protocol/pagination'; @@ -167,14 +168,14 @@ const booleanQueryParam = z.preprocess((value) => { const DEFAULT_SESSION_LIST_PAGE_SIZE = 20; // NOTE: mirrors v1's `GET /sessions` query. `before_id`/`after_id` id-cursors -// and `page_size` ARE applied in the route handler (the `FileSessionIndex` does -// not implement `cursor`, so we page over its recency-sorted result); `status` -// filters the projected page (post-page, matching v1). `include_archive` → -// `includeArchived`; `archived_only` forces `includeArchived` and then keeps -// only archived sessions; `workspace_id` → `workspaceIds` after -// `resolveAliasIds` expands the alias set of the directory (legacy split -// buckets list as one workspace); `exclude_empty` drops sessions with no -// prompt. +// and `page_size` are pushed down to `ISessionIndex.listRecent` as keyset +// cursor + limit (the route drains bounded pages until the wire page fills); +// `status` filters the projected page (post-page, matching v1). +// `include_archive` → `includeArchived`; `archived_only` forces +// `includeArchived` and then keeps only archived sessions; `workspace_id` → +// `workspaceIds` after `resolveAliasIds` expands the alias set of the +// directory (legacy split buckets list as one workspace); `exclude_empty` +// drops sessions with no prompt. const sessionsListQueryCoercion = z .object({ before_id: z.string().min(1).optional(), @@ -369,7 +370,6 @@ export function registerSessionsRoutes(app: SessionRouteHost, core: Scope): void }, async (req, reply) => { const raw = req.query; - const pageSize = raw.page_size; const archivedOnly = raw.archived_only === true; const workspaces = await core.accessor.get(IWorkspaceService).list(); @@ -391,78 +391,108 @@ export function registerSessionsRoutes(app: SessionRouteHost, core: Scope): void return; } - // `FileSessionIndex` does not implement `cursor` (gap G5 closed here), so - // we fetch the full recency-sorted set (no `limit`) and apply the id - // cursor in this handler. `list()` already orders by `updatedAt` desc and - // filters across the workspace-id set / archived. `archived_only` forces - // archived rows into the set, then the filter below keeps only them. const workspaceIds = raw.workspace_id === undefined ? undefined : await core.accessor.get(IWorkspaceAliases).resolveAliasIds(raw.workspace_id); - const page = await core.accessor.get(ISessionIndex).list({ - workspaceIds, - includeArchived: archivedOnly ? true : raw.include_archive, - }); + const index = core.accessor.get(ISessionIndex); + const includeArchived = archivedOnly ? true : raw.include_archive; - // Filter down to the sequence the client can page over BEFORE computing - // the cursor position. `cwd` is read from the session's own summary first - // (gap G3 closed — an unregistered workspace no longer drops the session); - // the registry `roots` map is only a back-compat fallback for sessions - // written before `cwd` was persisted. A session with no recoverable cwd is - // still skipped. - const eligible: { - readonly summary: (typeof page.items)[number]; + interface Eligible { + readonly summary: SessionSummary; readonly cwd: string; readonly facts?: SessionFacts; - }[] = []; - for (const summary of page.items) { - const cwd = summary.cwd ?? roots.get(summary.workspaceId); - if (cwd === undefined) continue; - if (raw.exclude_empty === true && (summary.lastPrompt ?? '').length === 0) continue; - eligible.push({ summary, cwd }); } - // `before_id` = strictly older than this id (forward / default paging); - // `after_id` = strictly newer. An unknown cursor resolves to an empty, - // terminal page (`has_more: false`) so a client cannot spin on a cursor - // the server cannot advance (this was the boot-time request storm). - let start = 0; - let end = eligible.length; - const cursorId = raw.before_id ?? raw.after_id; - if (cursorId !== undefined) { - const idx = eligible.findIndex((e) => e.summary.id === cursorId); - if (idx === -1) { - reply.send(okEnvelope({ items: [], has_more: false }, req.id)); - return; + // Keyset pages are pulled from the index and filtered at the edge + // (`cwd` recoverability, `exclude_empty`; `archived_only` also applies + // its busy filter here so it can drain to a full page, matching v1) — + // a bounded `page_size` request never materializes the full session + // set. An unknown cursor resolves to an empty, terminal page (this was + // the boot-time request storm). The index pages with ONE cursor per + // call (`before` wins when both are set), so the drain can only advance + // `before`; the `after` lower bound is re-applied at the edge instead — + // the first candidate no longer strictly newer than the cursor ends + // the window, so a heavily filtered stretch can never pull in sessions + // at/older than the original `after_id`. + const collect = async (pageSize: number): Promise<{ visible: Eligible[]; hasMore: boolean }> => { + const wanted = pageSize + 1; + const collected: Eligible[] = []; + let before = raw.before_id; + const after = raw.after_id; + const afterCursor = after !== undefined ? await index.get(after) : undefined; + const newerThanCursor = (summary: SessionSummary): boolean => + afterCursor === undefined || + summary.updatedAt > afterCursor.updatedAt || + (summary.updatedAt === afterCursor.updatedAt && summary.id > afterCursor.id); + while (collected.length < wanted) { + const page = await index.listRecent({ + workspaceIds, + includeArchived, + limit: wanted - collected.length, + before, + after: before === undefined ? after : undefined, + }); + if (page.items.length === 0) break; + let exhausted = false; + for (const summary of page.items) { + if (!newerThanCursor(summary)) { + exhausted = true; + break; + } + const cwd = summary.cwd ?? roots.get(summary.workspaceId); + if (cwd === undefined) continue; + if (raw.exclude_empty === true && (summary.lastPrompt ?? '').length === 0) continue; + if (archivedOnly) { + if (!summary.archived) continue; + const facts = resolveSessionFacts(core, summary.id); + if (raw.busy !== undefined && facts.busy !== raw.busy) continue; + collected.push({ summary, cwd, facts }); + } else { + collected.push({ summary, cwd }); + } + } + if (exhausted || page.nextCursor === undefined) break; + before = page.nextCursor; } - if (raw.before_id !== undefined) start = idx + 1; - else end = idx; - } + return { visible: collected.slice(0, pageSize), hasMore: collected.length > pageSize }; + }; - const window = eligible.slice(start, end); - let visible = window; - if (archivedOnly) { - visible = - raw.busy === undefined - ? window.filter((entry) => entry.summary.archived === true) - : window.flatMap((entry) => { - if (entry.summary.archived !== true) return []; - const facts = resolveSessionFacts(core, entry.summary.id); - return facts.busy === raw.busy ? [{ ...entry, facts }] : []; - }); - } - const limit = archivedOnly - ? (pageSize ?? DEFAULT_SESSION_LIST_PAGE_SIZE) - : (pageSize ?? visible.length); - const hasMore = visible.length > limit; - const projected: Session[] = visible - .slice(0, limit) - .map(({ summary, cwd, facts }) => - toWireSession(summary, cwd, facts ?? resolveSessionFacts(core, summary.id)), + if (!archivedOnly && raw.page_size === undefined) { + // v1 wire default: an unpaged list returns the whole (cursor-bounded) + // set with has_more=false. + const page = await index.listRecent({ + workspaceIds, + includeArchived, + before: raw.before_id, + after: raw.after_id, + }); + const eligible: Eligible[] = []; + for (const summary of page.items) { + const cwd = summary.cwd ?? roots.get(summary.workspaceId); + if (cwd === undefined) continue; + if (raw.exclude_empty === true && (summary.lastPrompt ?? '').length === 0) continue; + eligible.push({ summary, cwd }); + } + const projected = eligible.map(({ summary, cwd }) => + toWireSession(summary, cwd, resolveSessionFacts(core, summary.id)), ); + // v1 filters ordinary lists by the busy fact post-page. + const items = + raw.busy !== undefined + ? projected.filter((session) => session.busy === raw.busy) + : projected; + reply.send(okEnvelope({ items, has_more: false }, req.id)); + return; + } + + const pageSize = raw.page_size ?? DEFAULT_SESSION_LIST_PAGE_SIZE; + const { visible, hasMore } = await collect(pageSize); + const projected = visible.map(({ summary, cwd, facts }) => + toWireSession(summary, cwd, facts ?? resolveSessionFacts(core, summary.id)), + ); // v1 filters ordinary lists by the busy fact post-page; `archived_only` - // already applied it before pagination above so it can drain to a full page. + // already applied it during the drain above. const items = raw.busy !== undefined && !archivedOnly ? projected.filter((session) => session.busy === raw.busy) @@ -838,30 +868,18 @@ export function registerSessionsRoutes(app: SessionRouteHost, core: Scope): void } // The index filters by the child markers (`parent_session_id` + - // `child_session_kind`) and returns the recency-sorted children. The - // id-cursor, page-size, and status projection/filter stay at the edge - // (v1 wire concerns; status needs live handles). - const children = (await core.accessor.get(ISessionIndex).list({ childOf: session_id })) - .items; - - let pivotIndex = -1; - if (req.query.before_id !== undefined) { - pivotIndex = children.findIndex((s) => s.id === req.query.before_id); - } else if (req.query.after_id !== undefined) { - pivotIndex = children.findIndex((s) => s.id === req.query.after_id); - } - let slice: typeof children; - if (req.query.before_id !== undefined && pivotIndex >= 0) { - slice = children.slice(pivotIndex + 1); - } else if (req.query.after_id !== undefined && pivotIndex >= 0) { - slice = children.slice(0, pivotIndex); - } else { - slice = children; - } - // `page_size` is already clamped to [1, 100] by the query coercion; 100 - // is the v1 default when omitted. + // `child_session_kind`) and returns keyset pages in recency order — + // the id-cursor and page-size go down to the index, the busy + // projection/filter stays at the edge (v1 wire concerns; status needs + // live handles). const pageSize = req.query.page_size ?? 100; - const window = slice.slice(0, pageSize); + const page = await core.accessor.get(ISessionIndex).listRecent({ + childOf: session_id, + before: req.query.before_id, + after: req.query.after_id, + limit: pageSize + 1, + }); + const window = page.items.slice(0, pageSize); // `cwd` is read from the child's own summary first (gap G3 closed); the // registry is only a back-compat fallback for sessions written before @@ -882,7 +900,7 @@ export function registerSessionsRoutes(app: SessionRouteHost, core: Scope): void req.query.busy !== undefined ? projected.filter((session) => session.busy === req.query.busy) : projected; - reply.send(okEnvelope({ items, has_more: slice.length > pageSize }, req.id)); + reply.send(okEnvelope({ items, has_more: page.nextCursor !== undefined }, req.id)); } catch (error) { sendMappedError(reply, req, error); } diff --git a/packages/kap-server/src/routes/tools.ts b/packages/kap-server/src/routes/tools.ts index a5d151a54e6..0d706e99018 100644 --- a/packages/kap-server/src/routes/tools.ts +++ b/packages/kap-server/src/routes/tools.ts @@ -229,7 +229,7 @@ async function resolveEffectiveAgent(core: Scope, sessionId: string | undefined) /** Pick the most-recently-created session id, mirroring v1's fallback. */ async function mostRecentSessionId(core: Scope): Promise { - const page = await core.accessor.get(ISessionIndex).list({}); + const page = await core.accessor.get(ISessionIndex).listRecent({}); const [first, ...rest] = page.items; if (first === undefined) return undefined; let newest = first; diff --git a/packages/kap-server/src/search/searchService.ts b/packages/kap-server/src/search/searchService.ts index b1f8a78427c..c8fea180f38 100644 --- a/packages/kap-server/src/search/searchService.ts +++ b/packages/kap-server/src/search/searchService.ts @@ -762,7 +762,7 @@ export class GlobalSearchService implements IGlobalSearchService { const out: SessionSummary[] = []; let cursor: string | undefined; do { - const page = await this.sessionIndex.list({ cursor, limit: SESSION_PAGE_SIZE }); + const page = await this.sessionIndex.listRecent({ before: cursor, limit: SESSION_PAGE_SIZE }); out.push(...page.items); cursor = page.nextCursor; } while (cursor !== undefined); diff --git a/packages/kap-server/src/start.ts b/packages/kap-server/src/start.ts index 4e42774f299..1ba84690aad 100644 --- a/packages/kap-server/src/start.ts +++ b/packages/kap-server/src/start.ts @@ -10,9 +10,12 @@ import { bootstrap, drainQueryStoreDisposals, + drainSessionIndexMirror, IConfigService, IEventService, IProviderDiscoveryService, + ISessionIndex, + ISessionIndexMirror, IWorkspaceService, logSeed, resolveConfigPath, @@ -309,6 +312,20 @@ export async function startServer(opts: ServerStartOptions): Promise { it('lists sessions via GET', async () => { const { body } = await call<{ items: unknown[]; has_more: boolean }>( 'GET', - rpc('core', ISessionIndex, 'list'), + rpc('core', ISessionIndex, 'listRecent'), {}, ); expect(body.code).toBe(0); @@ -257,8 +257,8 @@ describe('server-v2 /api/v1/debug RPC', () => { await createSession(cwd); const { body } = await call( 'POST', - rpc('core', ISessionIndex, 'countActive'), - [[created.body.data.id]], + rpc('core', ISessionIndex, 'count'), + [{ workspaceIds: [created.body.data.id] }], ); expect(body.code).toBe(0); expect(body.data).toBeGreaterThanOrEqual(1); @@ -558,7 +558,7 @@ describe('server-v2 /api/v1/debug RPC', () => { const cwd = home as string; await createSession(cwd); - const listed = await call<{ items: { id: string }[] }>('POST', rpc('core', ISessionIndex, 'list'), {}); + const listed = await call<{ items: { id: string }[] }>('POST', rpc('core', ISessionIndex, 'listRecent'), {}); expect(listed.body.code).toBe(0); expect(listed.body.data.items.length).toBeGreaterThanOrEqual(1); @@ -601,7 +601,7 @@ describe('server-v2 /api/v1/debug RPC', () => { let rejected = false; let code: number | undefined; try { - const res = await fetch(`${base}${rpc('core', ISessionIndex, 'list')}`, { + const res = await fetch(`${base}${rpc('core', ISessionIndex, 'listRecent')}`, { method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` }, body: JSON.stringify({ big: huge }), @@ -657,14 +657,14 @@ describe('server-v2 /api/v1/debug RPC auth', () => { }); it('rejects calls without a token (40101)', async () => { - const res = await fetch(`${base}${rpc('core', ISessionIndex, 'list')}`, { method: 'POST' }); + const res = await fetch(`${base}${rpc('core', ISessionIndex, 'listRecent')}`, { method: 'POST' }); expect(res.status).toBe(401); const body = (await res.json()) as Envelope; expect(body.code).toBe(40101); }); it('accepts calls with the correct rpcToken', async () => { - const res = await fetch(`${base}${rpc('core', ISessionIndex, 'list')}`, { + const res = await fetch(`${base}${rpc('core', ISessionIndex, 'listRecent')}`, { method: 'POST', headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' }, body: JSON.stringify({}), @@ -675,7 +675,7 @@ describe('server-v2 /api/v1/debug RPC auth', () => { it('accepts the persistent token on /api/v1/debug', async () => { const persistent = (server as RunningServer).authTokenService.getToken(); - const res = await fetch(`${base}${rpc('core', ISessionIndex, 'list')}`, { + const res = await fetch(`${base}${rpc('core', ISessionIndex, 'listRecent')}`, { method: 'POST', headers: { authorization: `Bearer ${persistent}`, 'content-type': 'application/json' }, body: JSON.stringify({}), @@ -685,7 +685,7 @@ describe('server-v2 /api/v1/debug RPC auth', () => { }); it('rejects a wrong token (40101)', async () => { - const res = await fetch(`${base}${rpc('core', ISessionIndex, 'list')}`, { + const res = await fetch(`${base}${rpc('core', ISessionIndex, 'listRecent')}`, { method: 'POST', headers: { authorization: 'Bearer wrong' }, }); @@ -774,7 +774,7 @@ describe('server-v2 /api/v1/debug RPC (dev-only, whitelist-free)', () => { it('also reaches whitelisted Services by the same wire names', async () => { const { body } = await call<{ items: unknown[] }>( 'POST', - `/api/v1/debug/${String(ISessionIndex)}/list`, + `/api/v1/debug/${String(ISessionIndex)}/listRecent`, [{ limit: 1 }], ); expect(body.code).toBe(0); diff --git a/packages/kap-server/test/search/searchRoute.test.ts b/packages/kap-server/test/search/searchRoute.test.ts index a4b88f96176..8176e8f73e6 100644 --- a/packages/kap-server/test/search/searchRoute.test.ts +++ b/packages/kap-server/test/search/searchRoute.test.ts @@ -47,9 +47,11 @@ const WS = 'ws_route'; function stubSessionIndex(summaries: SessionSummary[]): ISessionIndex { return { _serviceBrand: undefined, - list: async () => ({ items: summaries, nextCursor: undefined }), + prepare: async () => ({ state: 'uninitialized', degradedCount: 0 }), + status: () => ({ state: 'uninitialized', degradedCount: 0 }), + listRecent: async () => ({ items: summaries, nextCursor: undefined }), get: async () => undefined, - countActive: async () => summaries.length, + count: async () => summaries.length, remove: async () => {}, }; } diff --git a/packages/kap-server/test/search/searchService.test.ts b/packages/kap-server/test/search/searchService.test.ts index 96c0ff77024..402f8341964 100644 --- a/packages/kap-server/test/search/searchService.test.ts +++ b/packages/kap-server/test/search/searchService.test.ts @@ -39,12 +39,14 @@ function makeBootstrap(home: string): IBootstrapService { } as unknown as IBootstrapService; } -function makeSessionIndex(list: ISessionIndex['list']): ISessionIndex { +function makeSessionIndex(list: ISessionIndex['listRecent']): ISessionIndex { return { _serviceBrand: undefined, - list, + prepare: async () => ({ state: 'uninitialized', degradedCount: 0 }), + status: () => ({ state: 'uninitialized', degradedCount: 0 }), + listRecent: list, get: async () => undefined, - countActive: async () => 0, + count: async () => 0, remove: async () => {}, }; } @@ -946,9 +948,11 @@ describe('GlobalSearchService', () => { const byId = new Map(summaries.map((s) => [s.id, s])); return { _serviceBrand: undefined, - list: async () => ({ items: summaries, nextCursor: undefined }), + prepare: async () => ({ state: 'uninitialized', degradedCount: 0 }), + status: () => ({ state: 'uninitialized', degradedCount: 0 }), + listRecent: async () => ({ items: summaries, nextCursor: undefined }), get: async (id) => byId.get(id), - countActive: async () => summaries.length, + count: async () => summaries.length, remove: async () => {}, }; } diff --git a/packages/kap-server/test/sessions.test.ts b/packages/kap-server/test/sessions.test.ts index c8049bcd414..dd767b1b079 100644 --- a/packages/kap-server/test/sessions.test.ts +++ b/packages/kap-server/test/sessions.test.ts @@ -876,6 +876,34 @@ describe('server-v2 /api/v1/sessions', () => { expect(second.body.data.has_more).toBe(false); }); + it('keeps the after_id lower bound while a filtered drain pages for more candidates', async () => { + const cwd = home as string; + const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + + // Oldest → newest: an archived cursor session, a stretch of live + // (filtered-out) sessions, then one archived hit. + const archivedOlder = await postJson('/api/v1/sessions', { metadata: { cwd } }); + await postJson<{ archived: boolean }>(`/api/v1/sessions/${archivedOlder.body.data.id}:archive`); + await sleep(5); + for (let i = 0; i < 3; i++) { + const { body } = await postJson('/api/v1/sessions', { metadata: { cwd } }); + expect(body.code).toBe(0); + await sleep(5); + } + const archivedNewer = await postJson('/api/v1/sessions', { metadata: { cwd } }); + await postJson<{ archived: boolean }>(`/api/v1/sessions/${archivedNewer.body.data.id}:archive`); + + // archived_only drops the whole live stretch, so the drain must page past + // it for more candidates — and must not slide below the after_id cursor + // while doing so (the cursor session itself is NOT strictly newer). + const page = await getJson( + `/api/v1/sessions?archived_only=true&page_size=2&after_id=${archivedOlder.body.data.id}`, + ); + expect(page.body.code).toBe(0); + expect(page.body.data.items.map((s) => s.id)).toEqual([archivedNewer.body.data.id]); + expect(page.body.data.has_more).toBe(false); + }); + it('rejects archived_only combined with include_archive (40001)', async () => { const { body } = await getJson( '/api/v1/sessions?archived_only=true&include_archive=true', @@ -1320,3 +1348,121 @@ describe('server-v2 /api/v1/sessions status context window', () => { expect(body.data.context_usage).toBe(0); }); }); + +describe('server-v2 /api/v1/sessions (minidb read model)', () => { + let server: RunningServer | undefined; + let home: string | undefined; + let base: string; + + const READ_MODEL_CONFIG = [ + 'default_model = "stub"', + '', + '[providers.stub]', + 'type = "openai"', + 'base_url = "http://127.0.0.1:9999"', + 'api_key = "stub"', + '', + '[models.stub]', + 'provider = "stub"', + 'model = "stub"', + 'max_context_size = 1000', + '', + '[experimental]', + 'persistence_minidb_readmodel = true', + '', + ].join('\n'); + + beforeEach(async () => { + home = await mkdtemp(join(tmpdir(), 'kimi-server-v2-sessions-rm-')); + await writeFile(join(home, 'config.toml'), READ_MODEL_CONFIG, 'utf8'); + server = await startServer({ + hostIdentity: TEST_HOST_IDENTITY, + host: '127.0.0.1', + port: 0, + homeDir: home, + logLevel: 'silent', + debugEndpoints: true, + }); + base = `http://127.0.0.1:${server.port}`; + }); + + afterEach(async () => { + if (server !== undefined) { + await server.close(); + server = undefined; + } + if (home !== undefined) { + await new Promise((resolve) => setTimeout(resolve, 25)); + await rm(home, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 } as never); + home = undefined; + } + }); + + async function postJson( + path: string, + body?: unknown, + ): Promise<{ status: number; body: Envelope }> { + const hasBody = body !== undefined; + const res = await fetch(`${base}${path}`, { + method: 'POST', + headers: authHeaders( + server as RunningServer, + hasBody ? { 'content-type': 'application/json' } : {}, + ), + body: hasBody ? JSON.stringify(body) : undefined, + } as never); + return { status: res.status, body: (await res.json()) as Envelope }; + } + + async function getJson(path: string): Promise<{ status: number; body: Envelope }> { + const res = await fetch(`${base}${path}`, { + headers: authHeaders(server as RunningServer), + } as never); + return { status: res.status, body: (await res.json()) as Envelope }; + } + + it('prepares the read model at boot and serves immediate reads', async () => { + const status = await getJson<{ state: string; generation?: number }>( + '/api/v1/debug/sessionIndex/status', + ); + expect(status.body.code).toBe(0); + expect(status.body.data.state).toBe('ready'); + + // A freshly created session lists, counts, and pages immediately — the + // mutation path never waited for the read model, the read path folds the + // mirror queue back in. + const created = await postJson('/api/v1/sessions', { + metadata: { cwd: home as string }, + }); + const id = created.body.data.id; + + const listed = await getJson('/api/v1/sessions'); + expect(listed.body.data.items.some((s) => s.id === id)).toBe(true); + + const workspaces = await getJson<{ items: { session_count: number }[] }>('/api/v1/workspaces'); + expect(workspaces.body.data.items[0]?.session_count).toBe(1); + + const paged = await getJson(`/api/v1/sessions?page_size=1&before_id=${id}`); + expect(paged.body.data.items).toEqual([]); + expect(paged.body.data.has_more).toBe(false); + + await postJson<{ archived: boolean }>(`/api/v1/sessions/${id}:archive`); + const archivedOnly = await getJson('/api/v1/sessions?archived_only=true'); + expect(archivedOnly.body.data.items.map((s) => s.id)).toEqual([id]); + + // A restart re-projects from the authoritative documents (the persisted + // read model may also be reused; either way the listing is complete). + await (server as RunningServer).close(); + server = await startServer({ + hostIdentity: TEST_HOST_IDENTITY, + host: '127.0.0.1', + port: 0, + homeDir: home, + logLevel: 'silent', + debugEndpoints: true, + }); + base = `http://127.0.0.1:${server.port}`; + const relisted = await getJson('/api/v1/sessions?include_archive=true'); + expect(relisted.body.data.items.map((s) => s.id)).toEqual([id]); + }); +}); diff --git a/packages/kap-server/test/setup.ts b/packages/kap-server/test/setup.ts new file mode 100644 index 00000000000..b22ac8598a0 --- /dev/null +++ b/packages/kap-server/test/setup.ts @@ -0,0 +1,18 @@ +/** + * Vitest setup — hermetic experimental flags. + * + * The kap-server suites pin the default (flag-off) behavior of the engine: + * several scenarios assert wire semantics that an experimental flag + * deliberately changes (e.g. the minidb session read model makes externally + * written sessions eventually consistent). A developer shell exporting + * `KIMI_CODE_EXPERIMENTAL_FLAG` (or a single-flag variant) must not flip the + * whole suite — scrub the env here; a test that wants a flag enables it + * explicitly through the boot config. + */ + +delete process.env['KIMI_CODE_EXPERIMENTAL_FLAG']; +for (const key of Object.keys(process.env)) { + if (key.startsWith('KIMI_CODE_EXPERIMENTAL_')) { + delete process.env[key]; + } +} diff --git a/packages/kap-server/vitest.config.ts b/packages/kap-server/vitest.config.ts index af7349cd318..8580fc92fb5 100644 --- a/packages/kap-server/vitest.config.ts +++ b/packages/kap-server/vitest.config.ts @@ -9,5 +9,6 @@ export default defineConfig({ test: { name: 'kap-server', include: ['test/**/*.{test,e2e}.ts'], + setupFiles: ['test/setup.ts'], }, }); diff --git a/packages/klient/src/contract/global/sessions.ts b/packages/klient/src/contract/global/sessions.ts index 27b6b6cca74..02a093da73a 100644 --- a/packages/klient/src/contract/global/sessions.ts +++ b/packages/klient/src/contract/global/sessions.ts @@ -24,13 +24,19 @@ export const sessionListQuerySchema = z.object({ workspaceIds: z.array(z.string()).optional(), sessionId: z.string().optional(), includeArchived: z.boolean().optional(), - cursor: z.string().optional(), limit: z.number().optional(), childOf: z.string().optional(), + before: z.string().optional(), + after: z.string().optional(), +}); + +export const sessionCountQuerySchema = z.object({ + workspaceIds: z.array(z.string()).optional(), + includeArchived: z.boolean().optional(), }); export const sessionsContract = { - list: { input: z.tuple([sessionListQuerySchema]), output: pageOf(sessionSummarySchema) }, + listRecent: { input: z.tuple([sessionListQuerySchema]), output: pageOf(sessionSummarySchema) }, get: { input: z.tuple([z.string()]), output: maybe(sessionSummarySchema) }, - countActive: { input: z.tuple([z.array(z.string())]), output: z.number() }, + count: { input: z.tuple([sessionCountQuerySchema]), output: z.number() }, } satisfies ServiceContract; diff --git a/packages/klient/src/core/facade/global.ts b/packages/klient/src/core/facade/global.ts index 302f3b7d9c5..088ab9a470c 100644 --- a/packages/klient/src/core/facade/global.ts +++ b/packages/klient/src/core/facade/global.ts @@ -295,10 +295,11 @@ export function createGlobalFacade(scoped: ScopedCaller, scopedStream: ScopedStr return { sessions: { - list: (query) => call('sessionIndex', 'list', [query]) as Promise>, + list: (query) => + call('sessionIndex', 'listRecent', [query]) as Promise>, get: (id) => call('sessionIndex', 'get', [id]) as Promise, countActive: (workspaceIds) => - call('sessionIndex', 'countActive', [workspaceIds]) as Promise, + call('sessionIndex', 'count', [{ workspaceIds }]) as Promise, create: async ({ workDir, additionalDirs, title, mcpServers }) => { // The workspace handler owns session creation: materialize (or reuse) // the handler for the root, then create under it. From 9923520a5706806b67dc259c28252cc655c69f63 Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Sun, 2 Aug 2026 21:44:09 +0800 Subject: [PATCH 04/15] feat(kap-server): bound search sync lifecycle, pagination, and query budgets - split search requests from sync work: searchIndex() no longer awaits runSync/reopen/reindex; a single-flight sync coordinator with debounce and backpressure runs in the background, stale generations keep serving with explicit stale/degraded state, and refresh/sync/reindex failures surface via lastRefreshError instead of being swallowed - scope file-meta keys by session id (\0meta\file\\) with lazy + one-shot background migration from the legacy hash-only keys, so one session sync only touches its own meta rows - make authoritative scans incremental (mtime/ino/size rescan conditions, unchanged files no longer rewrite meta) and read wire deltas in 1 MiB chunks instead of whole-file buffer + split - replace offset pagination with versioned v2 keyset page tokens (fingerprint + index generation + sort boundary); generation changes fail old tokens with invalid_page_token, legacy v1 offset tokens are served once and upgraded, and pages collect via bounded top-K instead of full sort + offset skip - add query budgets enforced at the postings/score stage: max query terms, literal length cap, postings visit budget (minidb searchBounded/maxVisits with prefix decoding that never fabricates hits and skips the postings LRU), candidate caps, deadline and text budget; truncation is reported via incomplete reasons candidate_cap/postings_budget/deadline - reopen read-only dbs by opening the next handle before closing the previous one so a failed refresh keeps the old generation serving; failed opens now self-heal through search traffic 100k-message bench: first page p95 < 300ms and page-100 cost on par with page 1; event-loop delay during queries stays sub-millisecond. --- AGENTS.md | 2 +- .../kap-server/src/protocol/rest-search.ts | 4 +- packages/kap-server/src/routes/search.ts | 2 + packages/kap-server/src/search/contract.ts | 58 +- .../kap-server/src/search/searchService.ts | 1242 +++++++++++++---- .../test/search/searchService.test.ts | 644 ++++++++- packages/minidb/src/index.ts | 21 +- packages/minidb/src/text-index.ts | 128 +- packages/minidb/src/text-postings.ts | 22 +- packages/minidb/test/text-index.test.ts | 114 ++ 10 files changed, 1910 insertions(+), 327 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index f6314bd0158..37160a4a5d4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,7 +26,7 @@ This is a TypeScript monorepo built for agent-assisted development. Keep the roo - `packages/oauth`: Kimi OAuth and managed auth utilities. - `packages/telemetry`: shared client-side telemetry infrastructure. - `packages/transcript`: the isomorphic transcript rendering data layer — agent-granular L1 store, idempotent L2 operations, `off/turn/block/delta` L3 subscription granularity, framework-free L4 view registry, and turn-cursor pagination. Pure TypeScript (browser-safe, no engine imports) and the sole owner of all transcript contract types (`src/contract/`); consumed by `packages/kap-server` (engine events → transcript, REST + WS surface; live stores backfill history from the persisted per-agent wire records — main on first attach, any agent on demand, cold sessions rebuild any agent — with 0-based turn ordinals matching the engine's). The cold rebuild is a two-level fold over `wire.jsonl` as the single source of truth: `history/groupTurns.ts` (context messages → turn tree) plus `history/foldFacts.ts` (non-context records → tasks, interactions, todos, goal/plan/swarm meta, and end-appended markers/taskrefs; interactions left pending at shutdown fold to `cancelled`). Plan content is a recorded fact too: each ExitPlanMode review submission offloads the document to `agents//plan//v.md` and persists a reference-only `plan.revision` record (`{id, version, path, sha256, bytes}`), which projects — live and cold — to a `plan.revision` marker and the `modes.plan` badge (`{reviewPath, version}`). It also owns the op-batch sequencing contract (`transcriptSeqSchema` in `contract/schema.ts`): a per-(session, agent) monotonic batch `seq` on `transcript.ops` / `transcript.reset` / the REST transcript response, the `transcript_since` subscription cursor, and the `GET .../transcript/ops` catch-up response shape — every field optional so pre-seq peers fall back to loss-signal-driven refreshes. Beyond the timeline, the model carries wire-equivalent detail: steps carry `usage` / `finishReason` / `timing` (LLM latencies) / `retry` / interrupt reason, turns carry `durationMs` / `error` / `usage`, tool frames carry the streamed `inputText` and the latest `progress`, tasks carry subagent `resultSummary` / `error` / `stateReason` / `usage`, `meta.agent` mirrors the agent status slices (model / usage / context / permission / phase), a global `prompts` entity (op `prompt.upsert`) tracks the prompt queue, and `hook.result` lands as a `'hook'` marker. These live-projected fields are NOT backfilled by the cold rebuild (known limitation). -- `packages/kap-server`: the Kimi Code server, backed by the DI × Scope agent engine (`@moonshot-ai/agent-core-v2` — four scopes, App/Workspace/Session/Agent; session create/resume/fork routes compose `ISessionIndex` → `IWorkspaceLifecycleService.handlerFor` → the handler's `ISessionLifecycleService`, and the fs routes resolve session → handler → the Workspace-scope fs services, with one exception: `fs:search` also accepts a workspace reference (registered id or absolute root) in the `{session_id}` slot, so a not-yet-created draft session's `@` file mention resolves the workspace handler directly; the first-class session-less form is `POST /api/v1/workspace/fs:search` (the workspace reference travels in the body)). Exposes sessions over REST + WebSocket (`/api/v1` + `/api/v1/ws`); bootstrapped from `src/start.ts` and consumed by `apps/kimi-code`. The RPC surface is `/api/v1/debug/*` — a reflection dispatcher over the ENTIRE scoped DI registry (every Service callable, no whitelist, Workspace scope addressable alongside App/Session/Agent; `src/transport/registerDebugRoutes.ts` + `serviceDispatcherRoutes.ts`), mounted only with `--debug-endpoints` on a loopback bind and gated by the global bearer auth; repo dev scripts pass the flag. Its transcript surface implements the op-batch sequencing contract: `TranscriptService.dispatchOps` assigns every dispatched batch a per-agent consecutive `seq` and retains it in a bounded in-memory journal (`TRANSCRIPT_OPS_JOURNAL_CAPACITY`, dies with the live store); WS `transcript.ops`/`transcript.reset` payloads carry the seq/watermark, a `transcript_since` subscription cursor (carried, with the per-agent grades, by the `subscribe_v2` control frame — the only transcript subscription channel; its agent-grained counterpart `unsubscribe_v2` detaches listed agents' streams, or the whole session's when `agent_ids` is absent, letting the detached agents' legacy events flow again) replays journaled batches instead of a baseline reset when the journal covers it, and `GET /sessions/{id}/transcript/ops?since_seq=` serves point-to-point catch-up (`complete: false` = journal can't cover or session cold → caller falls back to a full refresh). Beside the paged route, `GET /sessions/{id}/transcript/plan?agent_id=[&tool_call_id=]` projects an agent's ExitPlanMode plan info (content / path / options / review outcome; `tool_call_id` narrows to one call, omitted lists every recoverable plan) from the first available fact — the linked approval interaction's persisted request display, the live tool frame's display, or the tool result output text. The baseline `transcript.reset` itself is items-empty (`TRANSCRIPT_RESET_TAIL_TURNS = 0`): it carries only global state + the watermark + `has_more_older`, because history always pages in over REST. When a WS connection subscribes to the transcript protocol (grade ≠ `off` for an agent), the broadcaster suppresses the transcript-projected `session_event` types for that connection × agent (`TRANSCRIPT_PROJECTED_EVENT_TYPES` + `suppressedByTranscript` in `sessionEventBroadcaster.ts`; cursor replay via `getBufferedSince` applies the same filter). Suppression is only a per-connection send view — the journal still records everything, and connections without transcript grades are unaffected. The session's work aggregate behind `event.session.work_changed` (`busy` / `main_turn_active` / `pending_interaction` / `last_turn_reason`) is owned by the core's `ISessionActivityView` (`sessionActivity` domain, Session scope): the broadcaster only schedules the wire emission around turn frames (`busy:false` lands after `turn.ended`), and `resolveSessionFacts` (`src/routes/sessions.ts`) reads the same view — never fold per-agent activity at the edge. Delivery split on `/api/v1/ws`: global events (`session.meta.updated` and the `event.session.*` / `event.workspace.*` / `event.config.*` families, including every activated session's `event.session.work_changed`) fan out to EVERY established connection — `WsConnectionV1` registers itself via `broadcaster.addGlobalTarget` on construction and unregisters on close — while session/agent-grained events only reach connections subscribed to that session (subject to `agent_filter` and the transcript suppression above); transcript frames are a separate channel governed by the per-agent grades alone and bypass `agent_filter` entirely. The global search surface is `POST /api/v1/search` (`src/search/` + `src/routes/search.ts`): a cross-session full-text search over user messages, assistant text, and session titles, backed by a single minidb database at `/search-index` (`IGlobalSearchService`, App scope — the write-lock holder is the indexer, other processes open read-only and catch up via WAL). It serves two modes: `terms` (the default — minidb's inverted text index over ASCII words + CJK uni/bigrams, no positions, term-level AND) and `literal` (substring-exact search: a hashed 2/3-gram index supplies candidates, every candidate's text is then confirmed with `includes`, so hits carry zero false positives; literal ignores `sort` and returns newest-first, and a candidate set truncated at `LITERAL_CANDIDATE_CAP` is flagged `incomplete: 'candidate_cap'`). When `container.session_id` is provided and that session is live in this process (`TranscriptService.forSessionLive` returns a store, wired via `setLiveTranscriptSource` in `start.ts`), BOTH modes instead scan the in-memory transcript store (turn prompts + assistant text frames, history established via `whenReady`/`ensureAgentHistory`) — no index involved; terms-mode live hits are scored Σ log(1+tf) (comparable only within a route, per the `GlobalSearchSource` contract), live-route errors never fall back to the index, and the response's `source: 'live' | 'index'` field (also mixed into the page-token fingerprint, so a mid-pagination route flip invalidates the old token) tells the caller which route served the page. +- `packages/kap-server`: the Kimi Code server, backed by the DI × Scope agent engine (`@moonshot-ai/agent-core-v2` — four scopes, App/Workspace/Session/Agent; session create/resume/fork routes compose `ISessionIndex` → `IWorkspaceLifecycleService.handlerFor` → the handler's `ISessionLifecycleService`, and the fs routes resolve session → handler → the Workspace-scope fs services, with one exception: `fs:search` also accepts a workspace reference (registered id or absolute root) in the `{session_id}` slot, so a not-yet-created draft session's `@` file mention resolves the workspace handler directly; the first-class session-less form is `POST /api/v1/workspace/fs:search` (the workspace reference travels in the body)). Exposes sessions over REST + WebSocket (`/api/v1` + `/api/v1/ws`); bootstrapped from `src/start.ts` and consumed by `apps/kimi-code`. The RPC surface is `/api/v1/debug/*` — a reflection dispatcher over the ENTIRE scoped DI registry (every Service callable, no whitelist, Workspace scope addressable alongside App/Session/Agent; `src/transport/registerDebugRoutes.ts` + `serviceDispatcherRoutes.ts`), mounted only with `--debug-endpoints` on a loopback bind and gated by the global bearer auth; repo dev scripts pass the flag. Its transcript surface implements the op-batch sequencing contract: `TranscriptService.dispatchOps` assigns every dispatched batch a per-agent consecutive `seq` and retains it in a bounded in-memory journal (`TRANSCRIPT_OPS_JOURNAL_CAPACITY`, dies with the live store); WS `transcript.ops`/`transcript.reset` payloads carry the seq/watermark, a `transcript_since` subscription cursor (carried, with the per-agent grades, by the `subscribe_v2` control frame — the only transcript subscription channel; its agent-grained counterpart `unsubscribe_v2` detaches listed agents' streams, or the whole session's when `agent_ids` is absent, letting the detached agents' legacy events flow again) replays journaled batches instead of a baseline reset when the journal covers it, and `GET /sessions/{id}/transcript/ops?since_seq=` serves point-to-point catch-up (`complete: false` = journal can't cover or session cold → caller falls back to a full refresh). Beside the paged route, `GET /sessions/{id}/transcript/plan?agent_id=[&tool_call_id=]` projects an agent's ExitPlanMode plan info (content / path / options / review outcome; `tool_call_id` narrows to one call, omitted lists every recoverable plan) from the first available fact — the linked approval interaction's persisted request display, the live tool frame's display, or the tool result output text. The baseline `transcript.reset` itself is items-empty (`TRANSCRIPT_RESET_TAIL_TURNS = 0`): it carries only global state + the watermark + `has_more_older`, because history always pages in over REST. When a WS connection subscribes to the transcript protocol (grade ≠ `off` for an agent), the broadcaster suppresses the transcript-projected `session_event` types for that connection × agent (`TRANSCRIPT_PROJECTED_EVENT_TYPES` + `suppressedByTranscript` in `sessionEventBroadcaster.ts`; cursor replay via `getBufferedSince` applies the same filter). Suppression is only a per-connection send view — the journal still records everything, and connections without transcript grades are unaffected. The session's work aggregate behind `event.session.work_changed` (`busy` / `main_turn_active` / `pending_interaction` / `last_turn_reason`) is owned by the core's `ISessionActivityView` (`sessionActivity` domain, Session scope): the broadcaster only schedules the wire emission around turn frames (`busy:false` lands after `turn.ended`), and `resolveSessionFacts` (`src/routes/sessions.ts`) reads the same view — never fold per-agent activity at the edge. Delivery split on `/api/v1/ws`: global events (`session.meta.updated` and the `event.session.*` / `event.workspace.*` / `event.config.*` families, including every activated session's `event.session.work_changed`) fan out to EVERY established connection — `WsConnectionV1` registers itself via `broadcaster.addGlobalTarget` on construction and unregisters on close — while session/agent-grained events only reach connections subscribed to that session (subject to `agent_filter` and the transcript suppression above); transcript frames are a separate channel governed by the per-agent grades alone and bypass `agent_filter` entirely. The global search surface is `POST /api/v1/search` (`src/search/` + `src/routes/search.ts`): a cross-session full-text search over user messages, assistant text, and session titles, backed by a single minidb database at `/search-index` (`IGlobalSearchService`, App scope — the write-lock holder is the indexer, other processes open read-only and catch up via WAL). It serves two modes: `terms` (the default — minidb's inverted text index over ASCII words + CJK uni/bigrams, no positions, term-level AND) and `literal` (substring-exact search: a hashed 2/3-gram index supplies candidates, every candidate's text is then confirmed with `includes`, so hits carry zero false positives; literal ignores `sort` and returns newest-first). The index route is fully bounded (stage 4): a search request serves the currently published generation and never awaits a sync/reopen/reindex — it kicks the single-flight + debounced background coordinator instead, and reports `index_state.stale` / `index_state.degraded` when serving a behind view or after a failed refresh; every query runs under explicit budgets (max terms, postings visits via `MiniDb.searchBounded`, candidate caps, confirmation text volume, a match deadline) with over-budget pages flagged `incomplete: 'candidate_cap' | 'postings_budget' | 'deadline'`; pagination is keyset over `(time, key)` / `(score, time, key)` with versioned v2 tokens pinning the index generation (a rebuild/reopen/rescan invalidates old tokens with `invalid_page_token`; legacy v1 offset tokens are still accepted and upgraded), and per-session sync scans only that session's file-meta keys (`\0meta\file\\`, migrated from the pre-v2 hash-only keys by a one-time background pass). When `container.session_id` is provided and that session is live in this process (`TranscriptService.forSessionLive` returns a store, wired via `setLiveTranscriptSource` in `start.ts`), BOTH modes instead scan the in-memory transcript store (turn prompts + assistant text frames, history established via `whenReady`/`ensureAgentHistory`) — no index involved; terms-mode live hits are scored Σ log(1+tf) (comparable only within a route, per the `GlobalSearchSource` contract), live-route errors never fall back to the index, and the response's `source: 'live' | 'index'` field (also mixed into the page-token fingerprint, so a mid-pagination route flip invalidates the old token) tells the caller which route served the page. - `packages/klient`: the client SDK — a contract-driven facade over agent-core-v2 with aggregated `global.*` / `session(id).*` / `agent(id).*` methods, zod validation on every call, and klient-level typed event forwarding. Transport is chosen once at creation via subpath entry (`@moonshot-ai/klient/ipc|memory`); both return the same `Klient`. The package also hosts the e2e suites: the legacy `/api/v1` live suites (`test/e2e/legacy/`) and the docker e2e runner (`pnpm --filter @moonshot-ai/klient docker:e2e`). See `packages/klient/AGENTS.md`. - `packages/server-e2e`: live e2e tests and scenarios against a running server (`KIMI_SERVER_URL`, default `http://127.0.0.1:58627`). See `packages/server-e2e/AGENTS.md`. - `packages/tree-sitter-bash`: a pure-TypeScript bash parser (no runtime deps, no wasm) that produces a syntax tree with tree-sitter-bash 0.25.0 named-node type names and UTF-16 code-unit offsets. `parse(source, { timeoutMs, maxNodes })` runs under a deterministic budget (default 50 ms / 50k nodes, plus per-chain recursion depth caps) and returns a discriminated `ParseResult` (`{ ok, rootNode, hasError }` or `{ ok: false, reason: 'aborted' }`) — callers must treat aborted/hasError trees as "cannot analyze" and degrade. Parser only, no safety judgments; consumers (e.g. Bash tool permission matching) live elsewhere. Known deviations from the reference are tracked in the package README's "Known differences" section, pinned by differential fixtures tested against the real `tree-sitter-bash` wasm (dev-only). diff --git a/packages/kap-server/src/protocol/rest-search.ts b/packages/kap-server/src/protocol/rest-search.ts index 4424d7f71a3..8445ea096b2 100644 --- a/packages/kap-server/src/protocol/rest-search.ts +++ b/packages/kap-server/src/protocol/rest-search.ts @@ -44,12 +44,14 @@ export const searchMessagesResponseSchema = z.object({ items: z.array(searchMessageHitSchema), has_more: z.boolean(), page_token: z.string().optional(), - incomplete: z.enum(['candidate_cap']).optional(), + incomplete: z.enum(['candidate_cap', 'postings_budget', 'deadline']).optional(), index_state: z.object({ state: z.enum(['building', 'ready', 'readonly']), indexed_sessions: z.number(), total_sessions: z.number(), documents: z.number(), + stale: z.boolean().optional(), + degraded: z.string().optional(), }), source: z.enum(['live', 'index']), }); diff --git a/packages/kap-server/src/routes/search.ts b/packages/kap-server/src/routes/search.ts index 3942a2f696f..df7d1a01fea 100644 --- a/packages/kap-server/src/routes/search.ts +++ b/packages/kap-server/src/routes/search.ts @@ -78,6 +78,8 @@ function toWirePage(page: GlobalSearchPage): SearchMessagesResponse { indexed_sessions: page.indexState.indexedSessions, total_sessions: page.indexState.totalSessions, documents: page.indexState.documents, + stale: page.indexState.stale, + degraded: page.indexState.degraded, }, source: page.source, }; diff --git a/packages/kap-server/src/search/contract.ts b/packages/kap-server/src/search/contract.ts index 7d7dc1693cb..bd2303a97c0 100644 --- a/packages/kap-server/src/search/contract.ts +++ b/packages/kap-server/src/search/contract.ts @@ -15,6 +15,29 @@ * This file is the single source of truth for the request/response shapes, * shared by the Service interface (`searchService.ts`) and the REST zod * schemas (`protocol/rest-search.ts`). + * + * Pagination & generation semantics (v2 page tokens): + * - Tokens are keyset cursors: they carry the sort boundary of the last + * returned hit — (time, key) for time sorts and literal mode, (score, + * time, key) for score sort — never an offset, so a deep page costs + * proportionally to pageSize, not to the full match set. + * - Index-route tokens also pin the index generation they were issued by. + * The generation changes when the published base is swapped (initial + * open, a read-only full reopen, a reindex) or when a sync pass REPLACED + * already-indexed documents (a shrunk/rebuilt wire file rescan, a title + * overwrite). A token from an older generation fails with + * `invalid_page_token` — the client's signal to restart the search. + * - Weak consistency within one generation: additive indexing (new + * sessions, appended wire bytes) and deletions do NOT change the + * generation, and keyset pagination stays exact under them for time + * sorts (a hit added after the cursor sorts behind it and is simply not + * surfaced — snapshot-at-first-page semantics). Score sorts may drift + * because IDF depends on corpus size; restart the search for a fresh + * ranking. + * - Legacy v1 offset tokens (pre-versioning, `{f, s}`) are still accepted + * for a transition window and answered with offset semantics; the + * response always issues a v2 keyset token back, so clients upgrade on + * the next page. */ // ---- request --------------------------------------------------------------- @@ -90,13 +113,26 @@ export interface GlobalSearchIndexState { * building — the first full sync has not finished yet, results may be * incomplete; ready — a full sync completed in this process; * readonly — another process holds the index write lock, this process only - * reads (incrementally catching up from the WAL before each search). + * reads (catching up from the WAL in the background). */ readonly state: 'building' | 'ready' | 'readonly'; /** Progress counters behind `state`. */ readonly indexedSessions: number; readonly totalSessions: number; readonly documents: number; + /** + * True when the served page comes from a generation the service already + * knows to be behind: a newer index version was detected on disk + * (read-only refresh pending) or a background sync is in flight/queued. + * The results are still valid — just potentially not the freshest. + */ + readonly stale?: boolean; + /** + * Set when the last background refresh/sync/reindex FAILED and the page is + * served from the previous (stale) generation: the error message, for + * observability. Absent when the last refresh succeeded. + */ + readonly degraded?: string; } /** @@ -109,16 +145,26 @@ export interface GlobalSearchIndexState { */ export type GlobalSearchSource = 'live' | 'index'; +/** + * Why a page may miss real hits (the query was bounded, never silently + * truncated): + * - 'candidate_cap' — the candidate set exceeded the confirmation cap, so + * confirmation stopped at the cap; + * - 'postings_budget' — the postings-visit budget stopped the index-side + * candidate scan early (hot term/n-gram), so candidates are a subset; + * - 'deadline' — the query's work budget (wall-clock deadline or processed + * text volume) ran out during matching/confirmation. + * A page token from a changed index generation is NOT reported here — it + * fails the request with `invalid_page_token` (see the file header). + */ +export type GlobalSearchIncomplete = 'candidate_cap' | 'postings_budget' | 'deadline'; + export interface GlobalSearchPage { readonly items: GlobalSearchHit[]; readonly hasMore: boolean; /** Present iff `hasMore`. */ readonly pageToken?: string; - /** - * 'candidate_cap' — the literal-mode candidate set exceeded the cap, so - * confirmation was truncated and the page may miss real hits. - */ - readonly incomplete?: 'candidate_cap'; + readonly incomplete?: GlobalSearchIncomplete; readonly indexState: GlobalSearchIndexState; /** * The route that produced this page. The page token's fingerprint covers diff --git a/packages/kap-server/src/search/searchService.ts b/packages/kap-server/src/search/searchService.ts index c8fea180f38..9ad6adf1df4 100644 --- a/packages/kap-server/src/search/searchService.ts +++ b/packages/kap-server/src/search/searchService.ts @@ -6,23 +6,43 @@ * session titles, backed by a single minidb database at * `/search-index`. * + * Request/sync split — "requests serve a published generation, never wait": + * - A search request reads the currently published index generation and + * returns immediately — with `building` semantics when no generation has + * been published yet. It may KICK a background sync/refresh but never + * awaits one. + * - The background coordinator (single-flight + debounce + one queued + * follow-up) detects authoritative changes (session/wire enumeration), + * projects them incrementally, and publishes new checkpoints; a sync + * that REPLACED indexed documents (shrink rescan, title overwrite) also + * bumps the generation, invalidating older page tokens. + * - Refresh/sync failures are recorded in `lastRefreshError` and surface + * as `indexState.degraded` while the previous generation keeps serving. + * * Concurrency model — "the lock is the election": * - `MiniDb.open({ onLockFail: 'readonly' })`: the process that grabs the * exclusive write lock becomes the indexer (build + incremental sync); * every other process opens read-only and never rescans wire files. - * - A read-only instance refreshes before each search via a cheap file - * fingerprint (db.wal / db.snapshot / db.textindexes.json): unchanged → - * serve the in-memory view; WAL pure-append on the same inode → - * `MiniDb.catchUpFromWal` incremental replay; anything else → close + - * full reopen. When the indexer dies, the next opener takes the lock and - * becomes the new indexer. + * - A read-only instance checks a cheap file fingerprint (db.wal / + * db.snapshot / db.textindexes.json) per search: unchanged → serve the + * in-memory view; changed → refresh in the BACKGROUND (WAL pure-append → + * `MiniDb.catchUpFromWal` incremental replay; anything else → open the + * replacement db first, then swap — a failed reopen keeps the stale + * generation servable). When the indexer dies, the next opener takes the + * lock and becomes the new indexer. * - In-process, syncs are serialized behind a single-flight promise. * * Incremental indexing anchors on wire.jsonl byte offsets (the files are - * append-only JSONL): a `\0meta\file\` key per wire file records how - * far it has been indexed; growth re-reads only the new byte range, shrinkage - * drops the file's docs and rescans. Session title docs (`/$title`) are - * overwritten each sync; disappeared sessions are dropped by key prefix. + * append-only JSONL): a `\0meta\file\\` key per wire + * file records how far it has been indexed plus the file's size/mtime/inode; + * growth re-reads only the new byte range (in bounded chunks), shrinkage or + * an inode/mtime change drops the file's docs and rescans. Session title + * docs (`/$title`) are overwritten each sync; disappeared sessions are + * dropped by key prefix. Pre-v2 hash-only file-meta keys + * (`\0meta\file\`) are migrated to the session-scoped format by a + * one-time background pass (`migrateFileMetaKeys`) and opportunistically on + * per-file lookup; readers never enumerate the global meta namespace per + * session, so one session's sync touches only that session's metas. * * Registration: this module is side-effect-imported by `start.ts` BEFORE * `bootstrap()` runs, so the module-level `registerScopedService` below lands @@ -53,6 +73,7 @@ import type { TranscriptStore } from '@moonshot-ai/transcript'; import type { GlobalSearchHit, GlobalSearchIndexState, + GlobalSearchIncomplete, GlobalSearchPage, GlobalSearchQuery, GlobalSearchSource, @@ -75,13 +96,34 @@ const FILE_META_PREFIX = '\0meta\\file\\'; const SESSION_META_PREFIX = '\0meta\\session\\'; const STATS_KEY = '\0meta\\stats'; +function hashPath(filePath: string): string { + return createHash('sha256').update(filePath).digest('hex').slice(0, 32); +} + /** * minidb keys are limited to 128 bytes, far shorter than an absolute wire - * path — the file meta key is a hash of the path (the path itself, and the - * owning session, live in the value). + * path — the file meta key carries the owning session id plus a hash of the + * path (the path itself lives in the value). The session segment makes a + * per-session prefix scan (`fileMetaPrefixFor`) touch only that session's + * metas instead of the global meta namespace. + */ +function fileMetaKey(sessionId: string, filePath: string): string { + return `${FILE_META_PREFIX}${sessionId}\\${hashPath(filePath)}`; +} + +/** All file-meta keys of one session (prefix-scan argument). */ +function fileMetaPrefixFor(sessionId: string): string { + return `${FILE_META_PREFIX}${sessionId}\\`; +} + +/** + * Pre-v2 file-meta key: hash-only, the owning session identifiable only via + * the value — a per-session lookup required scanning every file meta. + * Read side of the migration: `syncWireFile` still resolves it by point + * lookup; `migrateFileMetaKeys` rewrites the rest in one background pass. */ -function fileMetaKey(filePath: string): string { - return FILE_META_PREFIX + createHash('sha256').update(filePath).digest('hex').slice(0, 32); +function legacyFileMetaKey(filePath: string): string { + return FILE_META_PREFIX + hashPath(filePath); } /** Cap one indexed document's text so huge pastes do not bloat the index. */ @@ -97,6 +139,38 @@ const LITERAL_CANDIDATE_CAP = 10_000; /** Sessions are listed in pages of this size. */ const SESSION_PAGE_SIZE = 500; +// -- query budgets (service knobs; the defaults are the production values) ---- + +/** Max distinct query terms in terms mode. */ +const MAX_QUERY_TERMS = 32; +/** Max literal-query length in normalized code points (bounds n-gram terms). */ +const MAX_LITERAL_QUERY_CHARS = 1_024; +/** + * Max posting entries the index may visit for one query (both modes). A hot + * term/n-gram whose postings overflow the budget contributes a prefix and + * the page is flagged `incomplete: 'postings_budget'` — the budget applies + * at the postings/score stage, not just at final confirmation. + */ +const MAX_POSTINGS_VISITS = 250_000; +/** Wall-clock budget for the in-memory match/confirm phase of one query. */ +const QUERY_DEADLINE_MS = 500; +/** Max document text processed by literal confirmation per query (UTF-16 + * code units) — the backstop for pathological huge-document corpora. */ +const QUERY_TEXT_BUDGET_CHARS = 16_000_000; +/** How often the match loop re-checks the deadline (candidate iterations). */ +const DEADLINE_CHECK_STRIDE = 64; + +/** One wire-delta read slice: growth is consumed in bounded chunks instead + * of one `size - offset` allocation. */ +const WIRE_READ_CHUNK_BYTES = 1 << 20; +/** Flush doc ops to the db in batches of this size while scanning a delta. */ +const WIRE_BATCH_OPS = 1_000; +const EMPTY_BUFFER = Buffer.alloc(0); + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + interface MessageDoc { readonly kind: 'message'; readonly sessionId: string; @@ -138,11 +212,20 @@ interface FileMetaDoc { /** Doc-key coordinates of this file's documents (see `docKeyPrefix`). */ readonly agentId: string; readonly source: 'root' | 'agents'; - /** Absolute wire path (debugging aid; the key is its hash). */ + /** Absolute wire path (debugging aid; the key is its session + hash). */ readonly path: string; /** Byte offset up to which the wire file has been indexed. */ readonly offset: number; readonly size: number; + /** + * File mtime/inode at the last sync pass. A changed inode (atomic + * replacement) or a bumped mtime at an unchanged size (in-place rewrite) + * forces a rescan even when `size === offset`. Absent in metas written + * before change tracking — such metas are simply refreshed with the + * current stat on the next pass, without a rescan. + */ + readonly mtimeMs?: number; + readonly ino?: number; /** * Turn counter state at `offset` — persisted with the watermark so an * incremental pass resumes counting instead of restarting at turn 0. @@ -330,7 +413,15 @@ export interface IGlobalSearchService { search(query: GlobalSearchQuery): Promise; /** Full rebuild: wipe the index and rescan every wire file. */ reindex(): Promise<{ sessions: number; documents: number }>; - status(): Promise<{ sessions: number; documents: number; lastIndexedAt: number | null }>; + status(): Promise<{ + sessions: number; + documents: number; + lastIndexedAt: number | null; + /** Identity of the published base; bumps invalidate v2 page tokens. */ + generation: number; + /** Last background refresh/sync/reindex failure, if serving stale. */ + degraded?: string; + }>; /** * Wire the live-transcript source for the in-memory search route. Called * once from the composition root (start.ts) after `TranscriptService` is @@ -388,7 +479,7 @@ interface NormalizedQuery { readonly pageSize: number; } -function normalizeQuery(input: GlobalSearchQuery): NormalizedQuery { +function normalizeQuery(input: GlobalSearchQuery, maxQueryTerms: number): NormalizedQuery { const mode = input.mode ?? 'terms'; // Literal matching is byte-exact (mod NFKC/case) — whitespace is part of // the query, so it is never trimmed. @@ -401,6 +492,13 @@ function normalizeQuery(input: GlobalSearchQuery): NormalizedQuery { // (`searchIndex`) — it is a constraint of the n-gram candidate index, not of // literal matching itself. The live route (pure in-memory scan) accepts any // non-empty literal query, down to a single code point. + const termsQuery = mode === 'terms' ? [...new Set(tokenize(query))] : undefined; + if (termsQuery !== undefined && termsQuery.length > maxQueryTerms) { + throw new GlobalSearchError( + 'invalid_query', + `query has too many terms (${termsQuery.length} > ${maxQueryTerms}); narrow it down`, + ); + } const pageSize = input.pageSize ?? 20; if (!Number.isInteger(pageSize) || pageSize < 1 || pageSize > 50) { throw new GlobalSearchError('invalid_query', 'pageSize must be an integer between 1 and 50'); @@ -409,7 +507,7 @@ function normalizeQuery(input: GlobalSearchQuery): NormalizedQuery { query, mode, literalQuery, - termsQuery: mode === 'terms' ? [...new Set(tokenize(query))] : undefined, + termsQuery, op: input.op ?? 'AND', container: input.container, role: input.role, @@ -421,12 +519,12 @@ function normalizeQuery(input: GlobalSearchQuery): NormalizedQuery { } /** - * The page token encodes a fingerprint of the query conditions plus the skip - * offset — changing conditions mid-pagination invalidates the token (same - * rule as Lark's search API). The serving route (`source`) is part of the - * fingerprint: a route flip mid-pagination (e.g. the container session - * closed and the live route fell away) invalidates the token too, so the - * client restarts the search instead of silently switching result sets. + * The page token encodes a fingerprint of the query conditions — changing + * conditions mid-pagination invalidates the token (same rule as Lark's + * search API). The serving route (`source`) is part of the fingerprint: a + * route flip mid-pagination (e.g. the container session closed and the live + * route fell away) invalidates the token too, so the client restarts the + * search instead of silently switching result sets. */ function tokenFingerprint(q: NormalizedQuery, source: GlobalSearchSource): string { const basis = JSON.stringify([ @@ -444,18 +542,50 @@ function tokenFingerprint(q: NormalizedQuery, source: GlobalSearchSource): strin return createHash('sha256').update(basis).digest('base64url').slice(0, 16); } -function encodePageToken(q: NormalizedQuery, source: GlobalSearchSource, skip: number): string { - return Buffer.from(JSON.stringify({ f: tokenFingerprint(q, source), s: skip })).toString( - 'base64url', - ); +// --------------------------------------------------------------------------- +// Page tokens v2 — keyset cursor + generation, legacy v1 offset compat +// --------------------------------------------------------------------------- + +const PAGE_TOKEN_VERSION = 2; + +/** + * Sort boundary of the last returned hit — the keyset cursor: + * - literal mode / `time_desc` / `time_asc`: `[time, key]`; + * - `score` (terms mode): `[score, time, key]`. + * The key is the doc's stable identity: the minidb key on the index route, a + * synthetic per-frame key on the live route. + */ +type SortBoundary = readonly (number | string)[]; + +type DecodedPage = + | { readonly kind: 'first' } + | { readonly kind: 'keyset'; readonly boundary: SortBoundary } + /** Legacy v1 offset token, accepted during the transition window. */ + | { readonly kind: 'legacy'; readonly skip: number }; + +/** Boundary tuple width for the query's effective sort order. */ +function boundaryWidth(q: NormalizedQuery): 2 | 3 { + return q.mode !== 'literal' && q.sort === 'score' ? 3 : 2; +} + +function encodePageToken( + q: NormalizedQuery, + source: GlobalSearchSource, + boundary: SortBoundary, + generation: number | undefined, +): string { + return Buffer.from( + JSON.stringify({ v: PAGE_TOKEN_VERSION, f: tokenFingerprint(q, source), g: generation, b: boundary }), + ).toString('base64url'); } function decodePageToken( q: NormalizedQuery, source: GlobalSearchSource, token: string | undefined, -): number { - if (token === undefined) return 0; + generation: number | undefined, +): DecodedPage { + if (token === undefined) return { kind: 'first' }; let parsed: unknown; try { parsed = JSON.parse(Buffer.from(token, 'base64url').toString('utf8')); @@ -465,17 +595,157 @@ function decodePageToken( if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) { throw new GlobalSearchError('invalid_page_token', 'pageToken is malformed'); } - const p = parsed as { f?: unknown; s?: unknown }; + const p = parsed as { v?: unknown; f?: unknown; s?: unknown; g?: unknown; b?: unknown }; if (p.f !== tokenFingerprint(q, source)) { throw new GlobalSearchError( 'invalid_page_token', 'pageToken does not match the query conditions; query conditions must not change mid-pagination', ); } - if (typeof p.s !== 'number' || !Number.isInteger(p.s) || p.s < 0) { + if (p.v === undefined) { + // Legacy v1 offset token (`{f, s}`) — transition window: answer it with + // offset semantics; the response issues a v2 keyset token back. + if (typeof p.s !== 'number' || !Number.isInteger(p.s) || p.s < 0) { + throw new GlobalSearchError('invalid_page_token', 'pageToken is malformed'); + } + return { kind: 'legacy', skip: p.s }; + } + if (p.v !== PAGE_TOKEN_VERSION) { + throw new GlobalSearchError('invalid_page_token', 'pageToken has an unsupported version'); + } + if (generation !== undefined && p.g !== generation) { + throw new GlobalSearchError( + 'invalid_page_token', + 'pageToken was issued by an older index generation (the index was rebuilt, reopened or rescanned); restart the search', + ); + } + const width = boundaryWidth(q); + if ( + !Array.isArray(p.b) || + p.b.length !== width || + typeof p.b[0] !== 'number' || + typeof p.b[width - 1] !== 'string' || + (width === 3 && typeof p.b[1] !== 'number') + ) { throw new GlobalSearchError('invalid_page_token', 'pageToken is malformed'); } - return p.s; + return { kind: 'keyset', boundary: p.b as SortBoundary }; +} + +// --------------------------------------------------------------------------- +// Sort order, boundary filtering and bounded collection (both routes) +// --------------------------------------------------------------------------- + +/** One matched document with its stable key and match context. */ +interface MatchedRow { + readonly key: string; + readonly value: MessageDoc | TitleDoc; + readonly score: number; + /** Literal mode: offset of the confirmed match, reused as snippet anchor. */ + readonly anchor?: number; +} + +/** Per-query work budget for the match/confirm phase (both routes). */ +interface MatchBudget { + /** Date.now() timestamp after which matching stops with 'deadline'. */ + readonly deadlineAt: number; + /** Remaining document text (UTF-16 code units) literal confirmation may + * process before stopping with 'deadline'. */ + textCharsLeft: number; +} + +function cmpKey(a: string, b: string): number { + return a < b ? -1 : a > b ? 1 : 0; +} + +/** + * The query's total order (negative = `a` ranks before `b`): + * - literal mode (sort is a terms-mode concept) and `time_desc`: + * (time desc, key asc); + * - `time_asc`: (time asc, key asc); + * - `score`: (score desc, time desc, key asc). + */ +function compareRows(q: NormalizedQuery, a: MatchedRow, b: MatchedRow): number { + if (q.mode !== 'literal' && q.sort === 'score') { + return b.score - a.score || b.value.time - a.value.time || cmpKey(a.key, b.key); + } + if (q.mode !== 'literal' && q.sort === 'time_asc') { + return a.value.time - b.value.time || cmpKey(a.key, b.key); + } + return b.value.time - a.value.time || cmpKey(a.key, b.key); +} + +/** The boundary tuple of a row — the keyset cursor payload. */ +function boundaryOf(q: NormalizedQuery, row: MatchedRow): SortBoundary { + return boundaryWidth(q) === 3 ? [row.score, row.value.time, row.key] : [row.value.time, row.key]; +} + +/** Whether the row ranks strictly AFTER the boundary in the sort order. */ +function rowAfterBoundary(q: NormalizedQuery, row: MatchedRow, boundary: SortBoundary): boolean { + let cmp: number; + if (boundary.length === 3) { + const [bs, bt, bk] = boundary as readonly [number, number, string]; + cmp = bs - row.score || bt - row.value.time || cmpKey(row.key, bk); + } else { + const [bt, bk] = boundary as readonly [number, string]; + cmp = + q.mode !== 'literal' && q.sort === 'time_asc' + ? row.value.time - bt || cmpKey(row.key, bk) + : bt - row.value.time || cmpKey(row.key, bk); + } + return cmp > 0; +} + +/** + * Bounded collector for the K best rows in the query's sort order — same + * worst-at-root heap shape as minidb's TopK: O(log K) per row and K rows in + * memory instead of an O(E log E) sort over every eligible row. Deep pages + * stay proportional to pageSize. + */ +class RowTopK { + private readonly a: MatchedRow[] = []; + + constructor( + private readonly q: NormalizedQuery, + private readonly k: number, + ) {} + + private worse(x: MatchedRow, y: MatchedRow): boolean { + return compareRows(this.q, x, y) > 0; // x ranks after y + } + + offer(row: MatchedRow): void { + const a = this.a; + if (a.length < this.k) { + a.push(row); + let i = a.length - 1; + while (i > 0) { + const p = (i - 1) >> 1; + if (!this.worse(a[i]!, a[p]!)) break; + [a[p], a[i]] = [a[i]!, a[p]!]; + i = p; + } + return; + } + if (this.k === 0 || !this.worse(a[0]!, row)) return; // must beat the worst kept + a[0] = row; + let i = 0; + for (;;) { + let w = i; + const l = 2 * i + 1; + const r = 2 * i + 2; + if (l < a.length && this.worse(a[l]!, a[w]!)) w = l; + if (r < a.length && this.worse(a[r]!, a[w]!)) w = r; + if (w === i) break; + [a[w], a[i]] = [a[i]!, a[w]!]; + i = w; + } + } + + /** The kept rows in final rank order. */ + sorted(): MatchedRow[] { + return this.a.sort((x, y) => compareRows(this.q, x, y)); + } } // --------------------------------------------------------------------------- @@ -491,6 +761,21 @@ export class GlobalSearchService implements IGlobalSearchService { /** Literal-mode candidate cap (test knob, see LITERAL_CANDIDATE_CAP). */ literalCandidateCap = LITERAL_CANDIDATE_CAP; + /** Terms-mode candidate cap (test knob, see MAX_TEXT_HITS). */ + maxTextHits = MAX_TEXT_HITS; + + /** Postings-visit budget per query (test knob, see MAX_POSTINGS_VISITS). */ + postingsVisitBudget = MAX_POSTINGS_VISITS; + + /** Match/confirm wall-clock budget per query (test knob). */ + queryDeadlineMs = QUERY_DEADLINE_MS; + + /** Literal-confirmation text-volume budget per query (test knob). */ + queryTextBudgetChars = QUERY_TEXT_BUDGET_CHARS; + + /** Max distinct query terms in terms mode (test knob). */ + maxQueryTerms = MAX_QUERY_TERMS; + private db: MiniDb | null = null; private openPromise: Promise | null = null; private syncPromise: Promise | null = null; @@ -506,6 +791,27 @@ export class GlobalSearchService implements IGlobalSearchService { private reindexing = false; /** Live-transcript source for the in-memory route; null until start.ts wires it. */ private liveSource: LiveTranscriptSource | null = null; + /** + * Identity of the published index base: bumped on every open/reopen + * (initial open, read-only swap, reindex) and on a sync pass that REPLACED + * already-indexed documents (shrink rescan, title overwrite). Page tokens + * pin it; additive/deletion-only passes deliberately keep it stable so + * keyset pagination over a live index is not constantly restarted (see + * contract.ts for the weak-consistency semantics). + */ + private generation = 0; + /** Set by a sync pass when it replaced indexed documents → generation bump. */ + private syncReplaced = false; + /** One queued follow-up pass behind the in-flight one (backpressure). */ + private syncQueued = false; + /** Trailing-pass timer behind the debounce window. */ + private syncTimer: ReturnType | null = null; + /** Last background refresh/sync/reindex failure — surfaced as degraded. */ + private lastRefreshError: { at: number; message: string } | null = null; + /** Last open failure — a search with no published generation fails fast. */ + private openError: string | null = null; + /** One-time per-process migration flag for pre-v2 file-meta keys. */ + private fileMetaMigrated = false; constructor( @ISessionIndex private readonly sessionIndex: ISessionIndex, @@ -514,7 +820,7 @@ export class GlobalSearchService implements IGlobalSearchService { ) { // App-scope OnScopeCreated activation: kick the first full sync off in the // background so server bootstrap never blocks on indexing. - this.kickBackgroundSync(); + this.requestSync(); } setLiveTranscriptSource(source: LiveTranscriptSource): void { @@ -528,10 +834,16 @@ export class GlobalSearchService implements IGlobalSearchService { } private ensureOpen(): Promise { - this.openPromise ??= this.openDb().catch((error: unknown) => { - this.openPromise = null; - throw error; - }); + this.openPromise ??= this.openDb().then( + () => { + this.openError = null; + }, + (error: unknown) => { + this.openPromise = null; + this.openError = errorMessage(error); + throw error; + }, + ); return this.openPromise; } @@ -545,25 +857,46 @@ export class GlobalSearchService implements IGlobalSearchService { await db.close().catch(() => {}); throw new GlobalSearchError('index_unavailable', 'search service is disposed'); } - this.db = db; - this.walOffset = db.recoveryInfo?.walScanEnd ?? 0; - if (!db.readOnly) { - // Both indexes are created here (not at first write) so a pre-existing - // db gets the tri index built over its current documents on first open - // after the upgrade, and a read-only peer only ever reopens on the - // definitions-file fingerprint change. - for (const [name, options] of [ - [TEXT_INDEX_NAME, { fields: ['text'] }], - [TRI_INDEX_NAME, { fields: ['text'], tokenizer: 'ngram' }], - ] as const) { - try { - await db.createTextIndex(name, options); - } catch (error) { - if (!(error instanceof Error && error.message.includes('already exists'))) throw error; + await this.publishDb(db, null); + } + + /** + * Swap a freshly opened db in as the new published generation: writer-side + * text-index definitions and the (handle-independent) fingerprint are + * computed BEFORE the swap, so a failure closes `next` and leaves `prev` + * (or the no-db state) untouched; the swap itself is one synchronous + * segment with no failure point between publishing `next` and closing + * `prev`. + */ + private async publishDb(next: MiniDb, prev: MiniDb | null): Promise { + let fingerprint: string; + try { + if (!next.readOnly) { + // Both indexes are created here (not at first write) so a + // pre-existing db gets the tri index built over its current documents + // on first open after the upgrade, and a read-only peer only ever + // reopens on the definitions-file fingerprint change. + for (const [name, options] of [ + [TEXT_INDEX_NAME, { fields: ['text'] }], + [TRI_INDEX_NAME, { fields: ['text'], tokenizer: 'ngram' }], + ] as const) { + try { + await next.createTextIndex(name, options); + } catch (error) { + if (!(error instanceof Error && error.message.includes('already exists'))) throw error; + } } } + fingerprint = await this.computeFingerprint(); + } catch (error) { + await next.close().catch(() => {}); + throw error; } - this.fingerprint = await this.computeFingerprint(); + this.db = next; + this.walOffset = next.recoveryInfo?.walScanEnd ?? 0; + this.generation++; + this.fingerprint = fingerprint; + if (prev !== null) await prev.close().catch(() => {}); } /** @@ -609,6 +942,10 @@ export class GlobalSearchService implements IGlobalSearchService { dispose(): void { this.disposed = true; + if (this.syncTimer !== null) { + clearTimeout(this.syncTimer); + this.syncTimer = null; + } // DI disposal is synchronous, but closing a MiniDb is not: wait for any // in-flight open to settle, then close the handle. The promise is // registered module-level so the server shutdown path @@ -643,14 +980,26 @@ export class GlobalSearchService implements IGlobalSearchService { /** * Bring a read-only instance up to date with the indexer's committed * writes. Unchanged fingerprint → zero IO; WAL pure-append → incremental - * `catchUpFromWal`; anything else → close + full reopen (which may also - * promote this process to indexer when the old writer's lock is gone). + * `catchUpFromWal`; anything else → open the replacement db and swap (which + * may also promote this process to indexer when the old writer's lock is + * gone). Single-flight; a failure is recorded in `lastRefreshError` and + * the stale generation keeps serving (surfaced as `indexState.degraded`). */ private refreshReadonly(): Promise { this.refreshPromise ??= this.doRefreshReadonly() - .catch(() => { - // A failed refresh must not fail the search — serve the stale view. - }) + .then( + () => { + this.lastRefreshError = null; + }, + (error: unknown) => { + // A failed refresh must not fail the search — serve the stale view, + // but no longer swallow the error silently. + this.lastRefreshError = { at: Date.now(), message: errorMessage(error) }; + this.log.warn('global search: read-only refresh failed; serving the stale view', { + error: errorMessage(error), + }); + }, + ) .finally(() => { this.refreshPromise = null; }); @@ -673,23 +1022,66 @@ export class GlobalSearchService implements IGlobalSearchService { } } // WAL rotated/truncated, snapshot or index definitions changed, or the - // watermark no longer aligns: close and reopen from scratch. - await db.close().catch(() => {}); - if (this.db === db) { - this.db = null; - this.openPromise = null; - await this.ensureOpen(); + // watermark no longer aligns: reopen from scratch. The replacement is + // opened and published BEFORE the stale handle closes, so a failed + // reopen leaves the previous generation servable instead of dropping + // the index out from under in-flight searches. + const next = await this.openSearchDb(); + if (this.disposed) { + await next.close().catch(() => {}); + return; + } + if (this.db !== db) { + // A concurrent refresh already swapped: just close the duplicate. + await next.close().catch(() => {}); + return; } + await this.publishDb(next, db); } - // -- sync (indexer only) -------------------------------------------------------- + // -- sync coordinator (indexer only) ------------------------------------------- + // + // Requests never await a sync; they ask the coordinator to schedule one. + // Single-flight serializes passes, the debounce window coalesces bursts, + // and backpressure is one queued follow-up behind the in-flight pass. - private kickBackgroundSync(): void { - void this.ensureSyncStarted().catch((error: unknown) => { - this.log.warn('global search: background sync failed', { - error: error instanceof Error ? error.message : String(error), - }); - }); + private requestSync(): void { + if (this.disposed || this.reindexing) return; + if (this.syncPromise !== null) { + // A pass is already running: queue exactly one follow-up. + this.syncQueued = true; + return; + } + const wait = this.syncDebounceMs - (Date.now() - this.lastSyncStartedAt); + if (wait > 0) { + // Inside the debounce window: coalesce requests into one trailing pass. + if (this.syncTimer === null) { + this.syncTimer = setTimeout(() => { + this.syncTimer = null; + this.requestSync(); + }, wait); + this.syncTimer.unref?.(); + } + return; + } + this.startSyncPass(); + } + + private startSyncPass(): void { + this.syncQueued = false; + void this.ensureSyncStarted().then( + () => { + this.lastRefreshError = null; + if (this.syncQueued) { + this.syncQueued = false; + this.requestSync(); + } + }, + (error: unknown) => { + this.lastRefreshError = { at: Date.now(), message: errorMessage(error) }; + this.log.warn('global search: background sync failed', { error: errorMessage(error) }); + }, + ); } /** Single-flight: concurrent callers share the in-flight sync. */ @@ -707,6 +1099,7 @@ export class GlobalSearchService implements IGlobalSearchService { // `reindexing`: a rebuild is swapping the db out — this pass is a no-op; // the rebuild itself runs the authoritative sync when done. if (this.disposed || this.reindexing) return; + this.syncReplaced = false; const sessions = await this.listAllSessions(); // Nothing to index and no index on disk yet: don't even create the // `/search-index` directory — it would show up in the fs folder @@ -723,6 +1116,11 @@ export class GlobalSearchService implements IGlobalSearchService { if (!db || db.readOnly || this.disposed) return; this.lastSyncStartedAt = Date.now(); + // One-time rewrite of pre-v2 hash-only file-meta keys, inside the + // background pass — never in the query path. After it, every per-session + // lookup below scans only that session's meta prefix. + await this.migrateFileMetaKeys(db); + this.summaries = new Map(sessions.map((s) => [s.id, s])); const currentIds = new Set(sessions.map((s) => s.id)); @@ -756,6 +1154,34 @@ export class GlobalSearchService implements IGlobalSearchService { }; await db.set(STATS_KEY, stats); this.fullSyncDone = true; + if (this.syncReplaced) { + // The pass REPLACED indexed documents (shrink rescan / title + // overwrite), so their sort keys may have moved: page tokens from the + // previous generation must restart instead of drifting. + this.generation++; + } + } + + /** + * One-time per-process migration of pre-v2 hash-only file-meta keys to the + * session-scoped format (`fileMetaKey`). A single full prefix scan of the + * meta namespace; per-session work afterwards only scans that session's + * keys. Idempotent — a crash mid-migration just rescans on the next pass. + */ + private async migrateFileMetaKeys(db: MiniDb): Promise { + if (this.fileMetaMigrated) return; + const ops: BatchInputOp[] = []; + for (const row of db.query({ key: { prefix: FILE_META_PREFIX }, project: [] })) { + const rest = row.key.slice(FILE_META_PREFIX.length); + if (rest.includes('\\')) continue; // already session-scoped + const meta = row.value; + if (meta.kind !== 'fileMeta') continue; + ops.push({ op: 'set', key: fileMetaKey(meta.sessionId, meta.path), value: meta }); + ops.push({ op: 'del', key: row.key }); + } + // Batch the rewrite instead of one op per key; empty on every later pass. + if (ops.length > 0) await db.batch(ops); + this.fileMetaMigrated = true; } private async listAllSessions(): Promise { @@ -773,10 +1199,8 @@ export class GlobalSearchService implements IGlobalSearchService { for (const row of db.query({ key: { prefix: `${sessionId}/` }, project: [] })) { await db.del(row.key); } - for (const row of db.query({ key: { prefix: FILE_META_PREFIX } })) { - if (row.value.kind === 'fileMeta' && row.value.sessionId === sessionId) { - await db.del(row.key); - } + for (const row of db.query({ key: { prefix: fileMetaPrefixFor(sessionId) }, project: [] })) { + await db.del(row.key); } await db.del(SESSION_META_PREFIX + sessionId); } @@ -792,10 +1216,12 @@ export class GlobalSearchService implements IGlobalSearchService { // A wire file that vanished on its own (e.g. one agent's log deleted // while the session lives on): drop its docs and meta. Session-level - // disappearance is handled separately in runSync. - for (const row of db.query({ key: { prefix: FILE_META_PREFIX } })) { + // disappearance is handled separately in runSync. The scan is scoped to + // THIS session's meta prefix — O(files of this session), independent of + // the global session count. + for (const row of db.query({ key: { prefix: fileMetaPrefixFor(summary.id) } })) { const meta = row.value; - if (meta.kind !== 'fileMeta' || meta.sessionId !== summary.id) continue; + if (meta.kind !== 'fileMeta') continue; if (seenPaths.has(meta.path)) continue; await this.deleteFileDocs(db, meta); await db.del(row.key); @@ -821,6 +1247,9 @@ export class GlobalSearchService implements IGlobalSearchService { time: summary.updatedAt, }; await db.set(titleKey, doc); + // Overwriting an existing title doc moves its sort key mid-pagination + // — a replacing change, unlike the additive first-time create. + if (existing !== undefined) this.syncReplaced = true; } } else if (existing !== undefined) { await db.del(titleKey); @@ -844,19 +1273,31 @@ export class GlobalSearchService implements IGlobalSearchService { summary: SessionSummary, file: WireFileRef, ): Promise { - let size: number; + let st: { size: number; mtimeMs: number; ino: number }; try { - size = (await stat(file.path)).size; + st = await stat(file.path); } catch { return; // transiently unreadable — retry next pass } - const metaKey = fileMetaKey(file.path); - const meta = db.get(metaKey); - let offset = meta?.kind === 'fileMeta' ? meta.offset : 0; - let turnState: TurnCounterState = - meta?.kind === 'fileMeta' ? (meta.turnState ?? initialTurnState()) : initialTurnState(); - let stepState: StepTrackerState = - meta?.kind === 'fileMeta' ? (meta.stepState ?? initialStepState()) : initialStepState(); + const size = st.size; + const metaKey = fileMetaKey(summary.id, file.path); + // New session-scoped key first, then the pre-v2 hash-only key (a cheap + // point lookup, not a scan): metas written before the key migration are + // honored and opportunistically rewritten under the new key. + let meta = db.get(metaKey); + let legacyKey: string | null = null; + if (meta?.kind !== 'fileMeta') { + const oldKey = legacyFileMetaKey(file.path); + const legacy = db.get(oldKey); + if (legacy?.kind === 'fileMeta') { + meta = legacy; + legacyKey = oldKey; + } + } + const known = meta?.kind === 'fileMeta' ? meta : undefined; + let offset = known?.offset ?? 0; + let turnState: TurnCounterState = known?.turnState ?? initialTurnState(); + let stepState: StepTrackerState = known?.stepState ?? initialStepState(); const fileMeta = ( nextOffset: number, turns: TurnCounterState, @@ -869,99 +1310,178 @@ export class GlobalSearchService implements IGlobalSearchService { path: file.path, offset: nextOffset, size, + mtimeMs: st.mtimeMs, + ino: st.ino, turnState: turns, stepState: steps, }); // Metas written before step tracking carry no `stepState`: rescan the // file from scratch so stepIds are all-or-nothing per file rather than // drifting mid-file (the shrink path does exactly this). - const legacyMeta = meta?.kind === 'fileMeta' && meta.stepState === undefined; - if (size < offset || legacyMeta) { + const legacyMeta = known !== undefined && known.stepState === undefined; + // An inode change means the file was replaced (atomic rewrite); a bumped + // mtime at an unchanged size means an in-place rewrite. Both invalidate + // the byte-offset watermark even though the size alone would not. + const replacedFile = known?.ino !== undefined && known.ino !== st.ino; + const rewrittenInPlace = + known?.mtimeMs !== undefined && size === known.offset && st.mtimeMs > known.mtimeMs; + if (size < offset || legacyMeta || replacedFile || rewrittenInPlace) { // File was rebuilt/truncated: drop its docs and rescan from scratch — - // the turn counter and step tracker restart with it. + // the turn counter and step tracker restart with it. A replacing + // change: the docs' sort keys may move → bump the generation. + this.syncReplaced = true; await this.deleteFileDocs(db, fileMeta(0, initialTurnState(), initialStepState())); offset = 0; turnState = initialTurnState(); stepState = initialStepState(); } if (size === offset) { - await db.set(metaKey, fileMeta(offset, turnState, stepState)); + // No growth: only rewrite the meta when something actually changed + // (first sight, stat refresh after an upgrade, legacy key cleanup) — + // an unchanged file must not cost a WAL record per pass. + if ( + legacyKey !== null || + known === undefined || + known.size !== size || + known.mtimeMs !== st.mtimeMs || + known.ino !== st.ino || + known.offset !== offset + ) { + const ops: BatchInputOp[] = [ + { op: 'set', key: metaKey, value: fileMeta(offset, turnState, stepState) }, + ]; + if (legacyKey !== null) ops.push({ op: 'del', key: legacyKey }); + await db.batch(ops); + } return; } - // Read only the new byte range; consume up to the last complete line. A - // short read (the file was truncated between stat and read) just defers - // the remainder to the next pass — the watermark below never advances - // past bytes that were actually read. + // Read only the new byte range, in bounded chunks, consuming complete + // lines; a trailing partial line (or a short read from a mid-read + // truncation) is left for the next pass — the watermark below never + // advances past bytes that were actually consumed. The line loop keeps + // only line-sized strings alive instead of one `size - offset` buffer + // plus a full split array. const handle = await open(file.path, 'r'); - let buf: Buffer; + const ops: BatchInputOp[] = []; + let byteCursor = offset; try { - buf = Buffer.allocUnsafe(size - offset); - const { bytesRead } = await handle.read(buf, 0, buf.length, offset); - buf = buf.subarray(0, bytesRead); + let position = offset; + let pending: Buffer = EMPTY_BUFFER; // partial-line bytes starting at byteCursor + const chunk = Buffer.allocUnsafe(WIRE_READ_CHUNK_BYTES); + while (position < size) { + if (this.disposed) return; // meta not advanced: the next pass redoes the file + const { bytesRead } = await handle.read( + chunk, + 0, + Math.min(chunk.length, size - position), + position, + ); + if (bytesRead === 0) break; + const slice = chunk.subarray(0, bytesRead); + position += bytesRead; + let start = 0; + for (;;) { + const nl = slice.indexOf(0x0a, start); + if (nl === -1) break; + const lineBuf = + pending.length > 0 + ? Buffer.concat([pending, slice.subarray(start, nl)]) + : slice.subarray(start, nl); + pending = EMPTY_BUFFER; + const lineOffset = byteCursor; + byteCursor += lineBuf.length + 1; + ({ turnState, stepState } = this.collectWireLine( + ops, + summary, + file, + lineBuf.toString('utf8'), + lineOffset, + { turnState, stepState }, + )); + start = nl + 1; + } + // The chunk buffer is reused, so the unconsumed tail must be copied. + pending = + pending.length > 0 + ? Buffer.concat([pending, slice.subarray(start)]) + : Buffer.from(slice.subarray(start)); + if (ops.length >= WIRE_BATCH_OPS) { + await db.batch(ops); + ops.length = 0; + } + } } finally { await handle.close(); } - const lastNl = buf.lastIndexOf(0x0a); - if (lastNl === -1) return; // no complete line yet - const complete = buf.subarray(0, lastNl + 1).toString('utf8'); - const ops: BatchInputOp[] = []; - let byteCursor = offset; - for (const line of complete.split('\n')) { - const lineBytes = Buffer.byteLength(line, 'utf8') + 1; - const lineOffset = byteCursor; - byteCursor += lineBytes; - const analysis = analyzeWireLine(line); - // Turn counting runs independently of indexing: every line moves the - // counter (a text-less user message still opens a turn). - const advanced = advanceTurnCounter(turnState, analysis.turn); - // A turn boundary invalidates the step mapping: a new turn opens - // (`open`, or `ensure` opening a fallback turn from no-turn), or an - // `undo` rewinds the counter mid-turn. - if ( - analysis.turn.kind === 'open' || - analysis.turn.kind === 'undo' || - (analysis.turn.kind === 'ensure' && !turnState.hasTurn) - ) { - stepState = initialStepState(); - } - turnState = advanced.state; - stepState = advanceStepTracker(stepState, analysis.step); - const extracted = analysis.messages; - for (let i = 0; i < extracted.length; i++) { - const e = extracted[i]!; - const stepOrdinal = e.stepUuid !== undefined ? stepState.byUuid[e.stepUuid] : undefined; - const doc: MessageDoc = { - kind: 'message', - sessionId: summary.id, - workspaceId: summary.workspaceId, - sessionTitle: summary.title ?? '', - agentId: file.agentId, - role: e.role, - text: e.text.length > MAX_DOC_TEXT_CHARS ? e.text.slice(0, MAX_DOC_TEXT_CHARS) : e.text, - time: e.time ?? summary.updatedAt, - turn: advanced.docTurn, - // A doc whose step cannot be resolved (no `step.begin` seen, or a - // turn boundary invalidated the mapping) just omits the id. - stepId: - advanced.docTurn !== undefined && stepOrdinal !== undefined - ? `t${advanced.docTurn}.${stepOrdinal}` - : undefined, - }; - // A line can yield several docs — the per-line index keeps keys unique. - ops.push({ - op: 'set', - key: `${docKeyPrefix(summary.id, file)}${lineOffset}:${i}`, - value: doc, - }); - } - } - const newOffset = offset + Buffer.byteLength(complete, 'utf8'); - ops.push({ op: 'set', key: metaKey, value: fileMeta(newOffset, turnState, stepState) }); + if (byteCursor === offset && legacyKey === null) return; // no complete line yet + ops.push({ op: 'set', key: metaKey, value: fileMeta(byteCursor, turnState, stepState) }); + if (legacyKey !== null) ops.push({ op: 'del', key: legacyKey }); await db.batch(ops); } + /** + * Turn/step counting and doc extraction for one complete wire line. + * Returns the counter states advanced by the line (they are immutable and + * replaced per line, so the caller threads them through the chunk loop). + */ + private collectWireLine( + ops: BatchInputOp[], + summary: SessionSummary, + file: WireFileRef, + line: string, + lineOffset: number, + counters: { turnState: TurnCounterState; stepState: StepTrackerState }, + ): { turnState: TurnCounterState; stepState: StepTrackerState } { + let { turnState, stepState } = counters; + const analysis = analyzeWireLine(line); + // Turn counting runs independently of indexing: every line moves the + // counter (a text-less user message still opens a turn). + const advanced = advanceTurnCounter(turnState, analysis.turn); + // A turn boundary invalidates the step mapping: a new turn opens + // (`open`, or `ensure` opening a fallback turn from no-turn), or an + // `undo` rewinds the counter mid-turn. + if ( + analysis.turn.kind === 'open' || + analysis.turn.kind === 'undo' || + (analysis.turn.kind === 'ensure' && !turnState.hasTurn) + ) { + stepState = initialStepState(); + } + turnState = advanced.state; + stepState = advanceStepTracker(stepState, analysis.step); + const extracted = analysis.messages; + for (let i = 0; i < extracted.length; i++) { + const e = extracted[i]!; + const stepOrdinal = e.stepUuid !== undefined ? stepState.byUuid[e.stepUuid] : undefined; + const doc: MessageDoc = { + kind: 'message', + sessionId: summary.id, + workspaceId: summary.workspaceId, + sessionTitle: summary.title ?? '', + agentId: file.agentId, + role: e.role, + text: e.text.length > MAX_DOC_TEXT_CHARS ? e.text.slice(0, MAX_DOC_TEXT_CHARS) : e.text, + time: e.time ?? summary.updatedAt, + turn: advanced.docTurn, + // A doc whose step cannot be resolved (no `step.begin` seen, or a + // turn boundary invalidated the mapping) just omits the id. + stepId: + advanced.docTurn !== undefined && stepOrdinal !== undefined + ? `t${advanced.docTurn}.${stepOrdinal}` + : undefined, + }; + // A line can yield several docs — the per-line index keeps keys unique. + ops.push({ + op: 'set', + key: `${docKeyPrefix(summary.id, file)}${lineOffset}:${i}`, + value: doc, + }); + } + return { turnState, stepState }; + } + // -- public API --------------------------------------------------------------- /** @@ -972,7 +1492,7 @@ export class GlobalSearchService implements IGlobalSearchService { * alive, so a scan failure is a real error, not a degradation signal. */ async search(input: GlobalSearchQuery): Promise { - const q = normalizeQuery(input); + const q = normalizeQuery(input, this.maxQueryTerms); const sessionId = q.container?.sessionId; const liveStore = sessionId !== undefined ? this.liveSource?.forSessionLive(sessionId) : undefined; if (liveStore !== undefined && sessionId !== undefined) { @@ -989,7 +1509,11 @@ export class GlobalSearchService implements IGlobalSearchService { store: TranscriptStore, pageToken: string | undefined, ): Promise { - const skip = decodePageToken(q, 'live', pageToken); + // The live route has no published generations — the store mutates + // continuously — so its keyset tokens carry no `g` and no generation + // check applies; the (time, key) cursor itself is what keeps pages + // consistent under concurrent appends. + const page = decodePageToken(q, 'live', pageToken, undefined); const source = this.liveSource; if (source === null) { // Unreachable (the router only enters with a source-wired store), but a @@ -1007,6 +1531,11 @@ export class GlobalSearchService implements IGlobalSearchService { await source.ensureAgentHistory(sessionId, agentId); } const docs = await this.collectLiveDocs(sessionId, store, agentIds); + const budget: MatchBudget = { + deadlineAt: Date.now() + this.queryDeadlineMs, + textCharsLeft: this.queryTextBudgetChars, + }; + const boundary = page.kind === 'keyset' ? page.boundary : undefined; // Literal mode needs no candidate index: every in-memory document is a // candidate and the shared confirmation pass decides. Terms mode runs the // in-memory AND match first, scoring each hit. @@ -1014,10 +1543,12 @@ export class GlobalSearchService implements IGlobalSearchService { q.mode === 'literal' ? this.matchDocs( q, - docs.map((value) => ({ value, score: 0 })), + docs.map(({ key, value }) => ({ key, value, score: 0 })), + boundary, + budget, ) - : this.matchDocs(q, matchLiveTerms(q.termsQuery ?? [], docs)); - return this.toPage(q, 'live', skip, matched, undefined, { + : this.matchDocs(q, matchLiveTerms(q.termsQuery ?? [], docs), boundary, budget); + return this.toPage(q, 'live', page, matched.rows, matched.incomplete, { state: 'ready', indexedSessions: 1, totalSessions: 1, @@ -1027,7 +1558,8 @@ export class GlobalSearchService implements IGlobalSearchService { /** * Flatten the live transcript store into the same document shape the index - * route searches (`MessageDoc` / `TitleDoc`): + * route searches (`MessageDoc` / `TitleDoc`), each with a stable synthetic + * key for keyset pagination: * - one user doc per non-empty `turn.prompt` (turn ordinal + turn time); * - one assistant doc per assistant-role text frame (turn ordinal + * stepId); thinking / tool / notice frames are skipped; @@ -1039,7 +1571,7 @@ export class GlobalSearchService implements IGlobalSearchService { sessionId: string, store: TranscriptStore, agentIds: readonly string[], - ): Promise<(MessageDoc | TitleDoc)[]> { + ): Promise<{ key: string; value: MessageDoc | TitleDoc }[]> { const summary = await this.sessionIndex.get(sessionId); const workspaceId = summary?.workspaceId ?? ''; const sessionTitle = summary?.title ?? ''; @@ -1049,7 +1581,7 @@ export class GlobalSearchService implements IGlobalSearchService { const ms = Date.parse(iso); return Number.isNaN(ms) ? fallbackTime : ms; }; - const docs: (MessageDoc | TitleDoc)[] = []; + const docs: { key: string; value: MessageDoc | TitleDoc }[] = []; for (const agentId of agentIds) { const transcript = store.getAgent(agentId); if (transcript === undefined) continue; @@ -1059,16 +1591,19 @@ export class GlobalSearchService implements IGlobalSearchService { const prompt = item.prompt?.trim() ?? ''; if (prompt.length > 0) { docs.push({ - kind: 'message', - sessionId, - workspaceId, - sessionTitle, - agentId, - role: 'user', - text: prompt.length > MAX_DOC_TEXT_CHARS ? prompt.slice(0, MAX_DOC_TEXT_CHARS) : prompt, - time: turnTime, - turn: item.ordinal, - stepId: undefined, + key: `${sessionId}/${agentId}/live/u/t${item.ordinal}`, + value: { + kind: 'message', + sessionId, + workspaceId, + sessionTitle, + agentId, + role: 'user', + text: prompt.length > MAX_DOC_TEXT_CHARS ? prompt.slice(0, MAX_DOC_TEXT_CHARS) : prompt, + time: turnTime, + turn: item.ordinal, + stepId: undefined, + }, }); } for (const step of item.steps) { @@ -1078,16 +1613,19 @@ export class GlobalSearchService implements IGlobalSearchService { const text = frame.text.trim(); if (text.length === 0) continue; docs.push({ - kind: 'message', - sessionId, - workspaceId, - sessionTitle, - agentId, - role: 'assistant', - text: text.length > MAX_DOC_TEXT_CHARS ? text.slice(0, MAX_DOC_TEXT_CHARS) : text, - time: stepTime, - turn: item.ordinal, - stepId: step.stepId, + key: `${sessionId}/${agentId}/live/a/${frame.frameId}`, + value: { + kind: 'message', + sessionId, + workspaceId, + sessionTitle, + agentId, + role: 'assistant', + text: text.length > MAX_DOC_TEXT_CHARS ? text.slice(0, MAX_DOC_TEXT_CHARS) : text, + time: stepTime, + turn: item.ordinal, + stepId: step.stepId, + }, }); } } @@ -1095,14 +1633,17 @@ export class GlobalSearchService implements IGlobalSearchService { } if (sessionTitle.length > 0) { docs.push({ - kind: 'title', - sessionId, - workspaceId, - sessionTitle, - agentId: '', - role: 'title', - text: sessionTitle, - time: fallbackTime, + key: `${sessionId}/$title`, + value: { + kind: 'title', + sessionId, + workspaceId, + sessionTitle, + agentId: '', + role: 'title', + text: sessionTitle, + time: fallbackTime, + }, }); } return docs; @@ -1114,58 +1655,146 @@ export class GlobalSearchService implements IGlobalSearchService { q: NormalizedQuery, pageToken: string | undefined, ): Promise { - const skip = decodePageToken(q, 'index', pageToken); - - await this.ensureOpen(); - if (this.db?.readOnly === true) { - await this.refreshReadonly(); + // Query validation comes before any index-state handling: an invalid + // query must fail the same way whether or not a generation is published. + if (q.mode === 'literal') { + // The n-gram index cannot confirm queries shorter than 2 normalized + // code points. Judged AFTER normalization on purpose: NFKC can change + // the length (the ligature 'ff' folds to 'ff' and becomes legal). The + // live route has no such constraint — it never reaches this branch. + const literalLength = Array.from(q.literalQuery ?? '').length; + if (literalLength < 2) { + throw new GlobalSearchError( + 'invalid_query', + 'literal queries need at least 2 characters (after Unicode normalization)', + ); + } + if (literalLength > MAX_LITERAL_QUERY_CHARS) { + throw new GlobalSearchError( + 'invalid_query', + `literal queries are limited to ${MAX_LITERAL_QUERY_CHARS} characters`, + ); + } } + + // The request path serves the currently published generation and never + // waits for an open, sync, reopen or reindex: with no published base yet + // it answers with `building` semantics and lets the background + // coordinator catch up. const db = this.db; if (db === null) { - throw new GlobalSearchError('index_unavailable', 'search index is unavailable'); + if (this.disposed) { + throw new GlobalSearchError('index_unavailable', 'search service is disposed'); + } + if (this.openError !== null) { + // The last open failed (e.g. a read-only open racing a writer's + // compaction): surface the failure, but ALSO kick a background retry + // (runSync → ensureOpen), so search traffic self-heals the index once + // the transient cause goes away — a successful retry clears openError. + this.requestSync(); + throw new GlobalSearchError( + 'index_unavailable', + `search index failed to open: ${this.openError}`, + ); + } + if (pageToken !== undefined) { + // No generation to validate the token against — the client restarts + // the search once a base is published. + throw new GlobalSearchError( + 'invalid_page_token', + 'the search index is not ready yet; restart the search', + ); + } + this.requestSync(); // kicks the open + first sync if nothing is running + return { + items: [], + hasMore: false, + pageToken: undefined, + incomplete: undefined, + indexState: { + state: 'building', + indexedSessions: 0, + totalSessions: this.summaries.size, + documents: 0, + stale: true, + degraded: this.lastRefreshError?.message, + }, + source: 'index', + }; } - if (!db.readOnly) { - if (this.fullSyncDone) { - // Incremental catch-up before searching, debounced; the first full - // sync is never awaited (search serves whatever is indexed so far). - if (Date.now() - this.lastSyncStartedAt >= this.syncDebounceMs) { - await this.ensureSyncStarted().catch(() => {}); - } + let stale: boolean; + let serveDb = db; + if (serveDb.readOnly) { + // Cheap freshness probe (3 stats). A changed fingerprint refreshes in + // the BACKGROUND — this request deliberately serves the stale + // generation instead of waiting for a catch-up or a full reopen. + let fp: string | null = null; + try { + fp = await this.computeFingerprint(); + } catch (error) { + this.lastRefreshError = { at: Date.now(), message: errorMessage(error) }; + } + // A background refresh may have swapped (and closed) the captured + // handle during the await. Re-pin to the currently published handle: + // one re-check suffices because the rest of the query path is fully + // synchronous — after this point the handle cannot die underneath us. + if (this.db === null) { + throw new GlobalSearchError('index_unavailable', 'search service is disposed'); + } + serveDb = this.db; + if (serveDb.readOnly) { + stale = fp === null || fp !== this.fingerprint || this.refreshPromise !== null; + if (fp !== null && fp !== this.fingerprint) void this.refreshReadonly(); } else { - this.kickBackgroundSync(); + // The reopen promoted this process to writer (the old writer's lock + // was gone): serve from it and kick the coordinator like a writer. + this.requestSync(); + stale = this.syncPromise !== null || this.syncQueued || this.syncTimer !== null; } + } else { + // Writer: kick the coordinator (never awaited); the served generation + // is the one published by the last completed pass. + this.requestSync(); + stale = this.syncPromise !== null || this.syncQueued || this.syncTimer !== null; } + const generation = this.generation; + const page = decodePageToken(q, 'index', pageToken, generation); - // One text-index pass: db.search returns every candidate with its score; - // container/role/time filters and the requested sort are applied in - // memory. (A separate db.query({text}) for pagination would scan the same - // postings a second time.) + // One bounded text-index pass: db.searchBounded returns at most the + // budgeted candidates with their scores; container/role/time filters and + // the requested sort are applied in memory. (A separate db.query({text}) + // for pagination would scan the same postings a second time.) let candidates: { key: string; value: SearchDoc | undefined; score: number }[]; - let incomplete: 'candidate_cap' | undefined; + let incomplete: GlobalSearchIncomplete | undefined; try { if (q.mode === 'literal') { - // The n-gram index cannot confirm queries shorter than 2 normalized - // code points. Judged AFTER normalization on purpose: NFKC can change - // the length (the ligature 'ff' folds to 'ff' and becomes legal). The - // live route has no such constraint — it never reaches this branch. - if (Array.from(q.literalQuery ?? '').length < 2) { - throw new GlobalSearchError( - 'invalid_query', - 'literal queries need at least 2 characters (after Unicode normalization)', - ); - } - // Ask for one past the cap so an over-cap candidate set is detectable. - candidates = db.search(TRI_INDEX_NAME, q.query, { + // Ask for one past the cap so an over-cap candidate set is + // detectable; the postings budget bounds the index-side work before + // confirmation even starts. + const res = serveDb.searchBounded(TRI_INDEX_NAME, q.query, { op: 'AND', limit: this.literalCandidateCap + 1, + maxVisits: this.postingsVisitBudget, }); + candidates = res.hits; + if (res.truncated) incomplete = 'postings_budget'; if (candidates.length > this.literalCandidateCap) { candidates.length = this.literalCandidateCap; - incomplete = 'candidate_cap'; + incomplete ??= 'candidate_cap'; } } else { - candidates = db.search(TEXT_INDEX_NAME, q.query, { op: q.op, limit: MAX_TEXT_HITS }); + const res = serveDb.searchBounded(TEXT_INDEX_NAME, q.query, { + op: q.op, + limit: this.maxTextHits + 1, + maxVisits: this.postingsVisitBudget, + }); + candidates = res.hits; + if (res.truncated) incomplete = 'postings_budget'; + if (candidates.length > this.maxTextHits) { + candidates.length = this.maxTextHits; + incomplete ??= 'candidate_cap'; + } } } catch (error) { // A read-only instance can open before the writer has created the text @@ -1176,38 +1805,70 @@ export class GlobalSearchService implements IGlobalSearchService { hasMore: false, pageToken: undefined, incomplete: undefined, - indexState: this.readIndexState(db), + indexState: this.readIndexState(serveDb, stale), source: 'index', }; } throw error; } - const matched = this.matchDocs(q, candidates); - return this.toPage(q, 'index', skip, matched, incomplete, this.readIndexState(db)); + const budget: MatchBudget = { + deadlineAt: Date.now() + this.queryDeadlineMs, + textCharsLeft: this.queryTextBudgetChars, + }; + const boundary = page.kind === 'keyset' ? page.boundary : undefined; + const matched = this.matchDocs(q, candidates, boundary, budget); + incomplete ??= matched.incomplete; + return this.toPage( + q, + 'index', + page, + matched.rows, + incomplete, + this.readIndexState(serveDb, stale), + generation, + ); } // -- shared match & page assembly (both routes) -------------------------------- /** - * Container/role/time filtering plus literal confirmation — one - * implementation shared by the index route (confirming n-gram candidates) - * and the live route (scanning every in-memory document). + * Container/role/time filtering, keyset-boundary filtering and literal + * confirmation — one implementation shared by the index route (confirming + * n-gram candidates) and the live route (scanning every in-memory + * document). The query work budgets apply at this match stage: the + * wall-clock deadline is re-checked every DEADLINE_CHECK_STRIDE candidates + * and literal confirmation additionally charges each processed document's + * text against `budget.textCharsLeft`. A budget stop is reported as + * `incomplete: 'deadline'`, never a silent truncation. */ private matchDocs( q: NormalizedQuery, - docs: Iterable<{ value: SearchDoc | undefined; score: number }>, - ): { value: MessageDoc | TitleDoc; score: number; anchor?: number }[] { + docs: Iterable<{ key: string; value: SearchDoc | undefined; score: number }>, + boundary: SortBoundary | undefined, + budget: MatchBudget, + ): { rows: MatchedRow[]; incomplete?: GlobalSearchIncomplete } { const literalQuery = q.literalQuery; - const matched: { value: MessageDoc | TitleDoc; score: number; anchor?: number }[] = []; - for (const { value: doc, score } of docs) { + const rows: MatchedRow[] = []; + let i = 0; + for (const { key, value: doc, score } of docs) { + if ((i++ & (DEADLINE_CHECK_STRIDE - 1)) === 0 && Date.now() > budget.deadlineAt) { + return { rows, incomplete: 'deadline' }; + } if (doc === undefined || (doc.kind !== 'message' && doc.kind !== 'title')) continue; if (q.container?.sessionId !== undefined && doc.sessionId !== q.container.sessionId) continue; if (q.container?.agentId !== undefined && doc.agentId !== q.container.agentId) continue; if (q.role !== undefined && doc.role !== q.role) continue; if (q.startTime !== undefined && doc.time < q.startTime) continue; if (q.endTime !== undefined && doc.time > q.endTime) continue; + // The boundary check only needs the sort key (score/time/key), so it + // runs BEFORE the expensive literal confirmation. + if (boundary !== undefined && !rowAfterBoundary(q, { key, value: doc, score }, boundary)) { + continue; + } if (literalQuery !== undefined) { + budget.textCharsLeft -= doc.text.length; + if (budget.textCharsLeft < 0) return { rows, incomplete: 'deadline' }; // Two-phase execution (same model as Elasticsearch's wildcard field): // candidates (from the n-gram index, or every in-memory doc on the // live route) are confirmed against the document text — hash @@ -1218,39 +1879,47 @@ export class GlobalSearchService implements IGlobalSearchService { // lowercase), aligned with the terms tokenizer. const at = normalizeLiteral(doc.text).indexOf(literalQuery); if (at === -1) continue; - matched.push({ value: doc, score: 0, anchor: at }); + rows.push({ key, value: doc, score: 0, anchor: at }); } else { - matched.push({ value: doc, score }); + rows.push({ key, value: doc, score }); } } - return matched; + return { rows }; } - /** Sort, paginate and project the matched docs into a page (both routes). */ + /** + * Sort, paginate and project the matched docs into a page (both routes). + * Keyset pages collect the best `pageSize + 1` rows past the boundary in a + * bounded heap; legacy v1 offset tokens get one last offset slice and are + * answered with a v2 keyset token. + */ private toPage( q: NormalizedQuery, source: GlobalSearchSource, - skip: number, - matched: { value: MessageDoc | TitleDoc; score: number; anchor?: number }[], - incomplete: 'candidate_cap' | undefined, + page: DecodedPage, + rows: MatchedRow[], + incomplete: GlobalSearchIncomplete | undefined, indexState: GlobalSearchIndexState, + generation?: number, ): GlobalSearchPage { // Literal mode: the normalized query (computed in normalizeQuery), reused // by confirmation and the snippet anchor. const literalQuery = q.literalQuery; - // Literal hits carry no relevance score and always order by time desc - // (`sort` is a terms-mode concept). 'score' keeps the relevance order the - // route produced: the text index's on the index route, `matchLiveTerms'` - // on the live route. - if (q.mode === 'literal' || q.sort === 'time_desc') { - matched.sort((a, b) => b.value.time - a.value.time); - } else if (q.sort === 'time_asc') { - matched.sort((a, b) => a.value.time - b.value.time); + let pageRows: MatchedRow[]; + let hasMore: boolean; + if (page.kind === 'legacy') { + rows.sort((a, b) => compareRows(q, a, b)); + const slice = rows.slice(page.skip, page.skip + q.pageSize + 1); + hasMore = slice.length > q.pageSize; + pageRows = slice.slice(0, q.pageSize); + } else { + const top = new RowTopK(q, q.pageSize + 1); + for (const row of rows) top.offer(row); + const slice = top.sorted(); + hasMore = slice.length > q.pageSize; + pageRows = slice.slice(0, q.pageSize); } - - const pageRows = matched.slice(skip, skip + q.pageSize + 1); - const hasMore = pageRows.length > q.pageSize; - const items: GlobalSearchHit[] = pageRows.slice(0, q.pageSize).map((row) => { + const items: GlobalSearchHit[] = pageRows.map((row) => { const doc = row.value; return { sessionId: doc.sessionId, @@ -1274,7 +1943,9 @@ export class GlobalSearchService implements IGlobalSearchService { return { items, hasMore, - pageToken: hasMore ? encodePageToken(q, source, skip + q.pageSize) : undefined, + pageToken: hasMore + ? encodePageToken(q, source, boundaryOf(q, pageRows[pageRows.length - 1]!), generation) + : undefined, incomplete, indexState, source, @@ -1282,15 +1953,17 @@ export class GlobalSearchService implements IGlobalSearchService { } async reindex(): Promise<{ sessions: number; documents: number }> { - await this.ensureOpen(); - if (this.db?.readOnly === true) { - throw new GlobalSearchError( - 'readonly_index', - 'another process holds the search-index write lock; reindex from that process', - ); - } - this.reindexing = true; try { + // Block new background passes BEFORE the first await, so no sync can + // start writing into the db this rebuild is about to swap out. + this.reindexing = true; + await this.ensureOpen(); + if (this.db?.readOnly === true) { + throw new GlobalSearchError( + 'readonly_index', + 'another process holds the search-index write lock; reindex from that process', + ); + } // Let the in-flight sync settle before closing the db it writes into. // Syncs triggered while we wait see `reindexing` and return as no-ops, // so one await is sufficient — no new writer of the old db can appear. @@ -1304,10 +1977,16 @@ export class GlobalSearchService implements IGlobalSearchService { this.fullSyncDone = false; await rm(this.indexDir, { recursive: true, force: true }); await this.ensureOpen(); - } finally { + // The rebuild runs the authoritative sync itself — an explicit + // maintenance operation, never ordinary in-request work. this.reindexing = false; + await this.ensureSyncStarted(); + this.lastRefreshError = null; + } catch (error) { + this.reindexing = false; + this.lastRefreshError = { at: Date.now(), message: errorMessage(error) }; + throw error; } - await this.ensureSyncStarted(); const stats = this.db?.get(STATS_KEY); return { sessions: stats?.kind === 'stats' ? stats.sessions : 0, @@ -1315,22 +1994,31 @@ export class GlobalSearchService implements IGlobalSearchService { }; } - async status(): Promise<{ sessions: number; documents: number; lastIndexedAt: number | null }> { + async status(): Promise<{ + sessions: number; + documents: number; + lastIndexedAt: number | null; + generation: number; + degraded?: string; + }> { await this.ensureOpen(); if (this.db?.readOnly === true) { + // An explicit status call may wait for the refresh; searches may not. await this.refreshReadonly(); } else { - this.kickBackgroundSync(); + this.requestSync(); } const stats = this.db?.get(STATS_KEY); return { sessions: stats?.kind === 'stats' ? stats.sessions : 0, documents: stats?.kind === 'stats' ? stats.documents : 0, lastIndexedAt: stats?.kind === 'stats' ? stats.lastIndexedAt : null, + generation: this.generation, + degraded: this.lastRefreshError?.message, }; } - private readIndexState(db: MiniDb): GlobalSearchIndexState { + private readIndexState(db: MiniDb, stale: boolean): GlobalSearchIndexState { const stats = db.get(STATS_KEY); const indexed = stats?.kind === 'stats' ? stats.sessions : 0; const documents = stats?.kind === 'stats' ? stats.documents : 0; @@ -1339,6 +2027,8 @@ export class GlobalSearchService implements IGlobalSearchService { indexedSessions: indexed, totalSessions: db.readOnly ? indexed : Math.max(indexed, this.summaries.size), documents, + stale: stale || undefined, + degraded: this.lastRefreshError?.message, }; } } @@ -1354,18 +2044,17 @@ export class GlobalSearchService implements IGlobalSearchService { * uses — so a document matches when EVERY query term appears in its term set * (AND). The score is Σ log(1 + tf) per query term: it is only comparable * within the live route, since there is no corpus-wide IDF in memory (the - * `GlobalSearchSource` contract comment says the same). Hits are returned - * score-sorted, mirroring `TextIndex.search`, because the shared `toPage` - * keeps the candidate order for `sort: 'score'`. + * `GlobalSearchSource` contract comment says the same). The shared `toPage` + * applies the final (score, time, key) order over the returned rows. */ function matchLiveTerms( terms: readonly string[], - docs: readonly (MessageDoc | TitleDoc)[], -): { value: MessageDoc | TitleDoc; score: number }[] { + docs: readonly { key: string; value: MessageDoc | TitleDoc }[], +): { key: string; value: MessageDoc | TitleDoc; score: number }[] { // A query that tokenizes to nothing matches zero docs, same as the index. if (terms.length === 0) return []; - const matched: { value: MessageDoc | TitleDoc; score: number }[] = []; - for (const doc of docs) { + const matched: { key: string; value: MessageDoc | TitleDoc; score: number }[] = []; + for (const { key, value: doc } of docs) { const counts = new Map(); for (const token of tokenize(doc.text)) counts.set(token, (counts.get(token) ?? 0) + 1); let score = 0; @@ -1378,9 +2067,8 @@ function matchLiveTerms( } score += Math.log(1 + tf); } - if (hit) matched.push({ value: doc, score }); + if (hit) matched.push({ key, value: doc, score }); } - matched.sort((a, b) => b.score - a.score); return matched; } diff --git a/packages/kap-server/test/search/searchService.test.ts b/packages/kap-server/test/search/searchService.test.ts index 402f8341964..ca456d10444 100644 --- a/packages/kap-server/test/search/searchService.test.ts +++ b/packages/kap-server/test/search/searchService.test.ts @@ -1,6 +1,8 @@ +import { createHash } from 'node:crypto'; import { appendFile, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; +import { monitorEventLoopDelay, type IntervalHistogram } from 'node:perf_hooks'; import type { IBootstrapService, @@ -121,6 +123,56 @@ function makeService(home: string, index: ISessionIndex): GlobalSearchService { return service; } +// --------------------------------------------------------------------------- +// test-only drives for the background coordinator / private internals +// --------------------------------------------------------------------------- + +/** Join or start a sync pass (single-flight, so this is exactly "sync now"). */ +function syncNow(service: GlobalSearchService): Promise { + return (service as unknown as { ensureSyncStarted(): Promise }).ensureSyncStarted(); +} + +/** + * Deterministic sync after a fixture mutation: searches KICK fire-and-forget + * passes that may still be in flight (started before the mutation), so join + * any in-flight pass first, then run one more that is guaranteed to start + * after the mutation landed. + */ +async function settleSync(service: GlobalSearchService): Promise { + await syncNow(service); + await syncNow(service); +} + +/** Drive the read-only refresh (fingerprint check + WAL catch-up / reopen). */ +function refreshNow(service: GlobalSearchService): Promise { + return (service as unknown as { refreshReadonly(): Promise }).refreshReadonly(); +} + +interface ServiceInternals { + db: { + get(key: string): Record | undefined; + set(key: string, value: unknown): Promise; + del(key: string): Promise; + batch(ops: { op: 'set' | 'del'; key: string; value?: unknown }[]): Promise; + query(criteria: { key: { prefix: string }; project?: string[] }): { + key: string; + value: Record; + }[]; + compact(): Promise; + } | null; + generation: number; + syncPromise: Promise | null; + fileMetaMigrated: boolean; + doRefreshReadonly(): Promise; + syncSession(db: NonNullable, summary: SessionSummary): Promise; + openSearchDb(): Promise; + computeFingerprint(): Promise; +} + +function internals(service: GlobalSearchService): ServiceInternals { + return service as unknown as ServiceInternals; +} + // --------------------------------------------------------------------------- // suite // --------------------------------------------------------------------------- @@ -287,13 +339,15 @@ describe('GlobalSearchService', () => { ); }); - it('picks up appended wire lines on the next search', async () => { + it('picks up appended wire lines on the next sync pass', async () => { const s1 = summary('s1', 'incremental', T1); const file = await writeWire(home!, 's1', 'main', [userLine('苹果 initial', T1)]); const service = track(makeService(home!, staticIndex([s1]))); await service.reindex(); await appendFile(file, `${userLine('苹果 appended', T2)}\n`, 'utf8'); + // Searches no longer await a sync — drive the coordinator explicitly. + await settleSync(service); const page = await service.search({ query: '苹果' }); expect(page.items.length).toBe(2); expect(page.items.some((h) => h.snippet.includes('appended'))).toBe(true); @@ -342,6 +396,7 @@ describe('GlobalSearchService', () => { expect((await service.search({ query: '苹果' })).items.length).toBe(1); sessions.length = 0; // session directory vanished from the index + await settleSync(service); const page = await service.search({ query: '苹果' }); expect(page.items).toEqual([]); }); @@ -360,6 +415,7 @@ describe('GlobalSearchService', () => { // Rewrite with a shorter file: stale docs must be dropped and the new // content rescanned from offset 0. await writeFile(file, `${userLine('香蕉 fresh', T1)}\n`, 'utf8'); + await settleSync(service); const stale = await service.search({ query: '苹果' }); expect(stale.items).toEqual([]); const fresh = await service.search({ query: '香蕉' }); @@ -375,10 +431,12 @@ describe('GlobalSearchService', () => { // A partial line (no trailing newline) must not be indexed nor consumed. await appendFile(file, userLine('苹果 partial', T2), 'utf8'); + await settleSync(service); expect((await service.search({ query: 'partial' })).items).toEqual([]); // Once the line is completed, the next pass picks it up. await appendFile(file, '\n', 'utf8'); + await settleSync(service); const page = await service.search({ query: 'partial' }); expect(page.items.length).toBe(1); expect(page.items[0]?.role).toBe('user'); @@ -427,6 +485,7 @@ describe('GlobalSearchService', () => { expect((await service.search({ query: '苹果' })).items.length).toBe(2); await rm(subFile); + await settleSync(service); const page = await service.search({ query: '苹果' }); expect(page.items.length).toBe(1); expect(page.items[0]?.agentId).toBe('main'); @@ -450,18 +509,25 @@ describe('GlobalSearchService', () => { expect(first.indexState.state).toBe('readonly'); expect(first.items.length).toBe(1); - // The writer indexes a new line; the reader must see it via the - // fingerprint check + catchUpFromWal incremental replay (no full reopen). + // The writer indexes a new line. The reader's search must NOT wait for + // the refresh: it serves the stale view (flagged) and refreshes in the + // background; the catchUpFromWal replay lands for the next search. await appendFile(file, `${userLine('苹果 delta', T2)}\n`, 'utf8'); - await writer.search({ query: '苹果' }); // writer-side incremental sync + await settleSync(writer); + const stalePage = await reader.search({ query: '苹果' }); + expect(stalePage.items.length).toBe(1); + expect(stalePage.indexState.stale).toBe(true); + await refreshNow(reader); const caughtUp = await reader.search({ query: '苹果' }); expect(caughtUp.items.length).toBe(2); expect(caughtUp.items.some((h) => h.snippet.includes('delta'))).toBe(true); + expect(caughtUp.indexState.stale).toBeUndefined(); - // WAL rotation on the writer forces the reader's full-reopen fallback; - // results stay correct afterwards. - const writerDb = (writer as unknown as { db: { compact(): Promise } | null }).db; + // WAL rotation on the writer forces the reader's reopen path (open the + // replacement, then swap); results stay correct afterwards. + const writerDb = internals(writer).db; await writerDb?.compact(); + await refreshNow(reader); const afterRotation = await reader.search({ query: '苹果' }); expect(afterRotation.items.length).toBe(2); }); @@ -561,6 +627,7 @@ describe('GlobalSearchService', () => { `${userLine('苹果 second', T3)}\n${assistantLine('苹果 second reply', T3 + 1000)}\n`, 'utf8', ); + await settleSync(service); const page = await service.search({ query: '苹果', sort: 'time_asc' }); expect(page.items.map((h) => [h.role, h.turn])).toEqual([ ['user', 0], @@ -584,6 +651,7 @@ describe('GlobalSearchService', () => { ).toEqual([0, 1, 2]); await writeFile(file, `${userLine('苹果 only', T1)}\n`, 'utf8'); + await settleSync(service); const page = await service.search({ query: '苹果' }); expect(page.items.length).toBe(1); expect(page.items[0]?.turn).toBe(0); @@ -744,6 +812,7 @@ describe('GlobalSearchService', () => { await service.reindex(); await appendFile(file, `${assistantStepLine('苹果 reply', 'u1', T2)}\n`, 'utf8'); + await settleSync(service); const page = await service.search({ query: '苹果', role: 'assistant' }); expect(page.items.map((h) => [h.turn, h.stepId])).toEqual([[0, 't0.1']]); }); @@ -760,16 +829,7 @@ describe('GlobalSearchService', () => { // Simulate a file meta written before step tracking existed by stripping // stepState from the persisted meta. - const db = ( - service as unknown as { - db: { - query(criteria: { - key: { prefix: string }; - }): { key: string; value: Record }[]; - set(key: string, value: unknown): Promise; - } | null; - } - ).db; + const db = internals(service).db; expect(db).not.toBeNull(); const metaRows = db!.query({ key: { prefix: '\0meta\\file\\' } }); expect(metaRows.length).toBe(1); @@ -781,6 +841,7 @@ describe('GlobalSearchService', () => { // Appending triggers a sync; the legacy meta must force a full rescan of // the file, so every doc — old and new — ends up with a stepId. await appendFile(file, `${assistantStepLine('苹果 reply two', 'u1', T2)}\n`, 'utf8'); + await settleSync(service); const page = await service.search({ query: '苹果', role: 'assistant', sort: 'time_asc' }); expect(page.items.map((h) => h.stepId)).toEqual(['t0.1', 't0.1']); }); @@ -940,6 +1001,496 @@ describe('GlobalSearchService', () => { }); }); + // -- stage 4: bounded lifecycle --------------------------------------------- + + describe('stage-4 bounded lifecycle', () => { + it('serves the published generation without waiting for a blocked background sync', async () => { + const s1 = summary('s1', 'blocked', T1); + const file = await writeWire(home!, 's1', 'main', [userLine('苹果 base', T1)]); + let block = false; + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + const index = makeSessionIndex(async () => { + if (block) await gate; + return { items: [s1], nextCursor: undefined }; + }); + const service = track(makeService(home!, index)); + await service.reindex(); + expect((await service.search({ query: '苹果' })).items.length).toBe(1); + + // New bytes arrive, then the next background pass is blocked inside the + // session enumeration. The search must return promptly with the OLD + // generation instead of waiting for the pass. + await appendFile(file, `${userLine('苹果 delta', T2)}\n`, 'utf8'); + block = true; + const page = await Promise.race([ + service.search({ query: '苹果' }), + new Promise((_, reject) => + setTimeout(() => reject(new Error('search waited for the blocked sync')), 2_000), + ), + ]); + expect(page.items.length).toBe(1); // the published generation, not the delta + expect(page.indexState.stale).toBe(true); // a pass is in flight + + release(); + await settleSync(service); + const caughtUp = await service.search({ query: '苹果' }); + expect(caughtUp.items.length).toBe(2); + }); + + it('scopes one session sync to its own file-meta keys among 10k sessions', async () => { + const s1 = summary('s1', 'scoped', T1); + await writeWire(home!, 's1', 'main', [userLine('苹果 scoped', T1)]); + const service = track(makeService(home!, staticIndex([s1]))); + await service.reindex(); + + // Seed 10k foreign sessions × 3 file-meta rows directly (no wire files + // on disk), simulating a large pre-existing index. + const db = internals(service).db!; + for (let from = 0; from < 10_000; from += 2_500) { + const ops: { op: 'set'; key: string; value: unknown }[] = []; + for (let i = from; i < from + 2_500; i++) { + for (let j = 0; j < 3; j++) { + const hash = (i * 3 + j).toString(16).padStart(8, '0').repeat(4); + ops.push({ + op: 'set', + key: `\0meta\\file\\fake${i}\\${hash}`, + value: { + kind: 'fileMeta', + sessionId: `fake${i}`, + agentId: 'main', + source: 'agents', + path: `/nonexistent/${i}/${j}`, + offset: 0, + size: 0, + }, + }); + } + } + await db.batch(ops); + } + + // Count the file-meta rows one session sync scans: it must be bounded + // by THIS session's files, not by the global 30k metas. + let metaRowsScanned = 0; + const origQuery = db.query.bind(db); + db.query = (criteria) => { + const rows = origQuery(criteria); + if (criteria.key.prefix.startsWith('\0meta\\file\\')) metaRowsScanned += rows.length; + return rows; + }; + await internals(service).syncSession(db, s1); + expect(metaRowsScanned).toBeLessThanOrEqual(5); // s1 has exactly 1 meta + }); + + it('migrates legacy hash-only file-meta keys to the session-scoped format', async () => { + const s1 = summary('s1', 'migration', T1); + const main = await writeWire(home!, 's1', 'main', [userLine('苹果 main', T1)]); + await writeWire(home!, 's1', 'agent-1', [userLine('苹果 sub', T2)]); + const first = track(makeService(home!, staticIndex([s1]))); + await first.reindex(); + expect((await first.search({ query: '苹果' })).items.length).toBe(2); + + // Rewrite every file meta under its pre-v2 hash-only key (same value), + // simulating an index written before the key migration. + const db = internals(first).db!; + const metas = db.query({ key: { prefix: '\0meta\\file\\' } }); + expect(metas.length).toBe(2); + const legacyOffsets = new Map(); + for (const row of metas) { + const path = row.value['path'] as string; + const legacyKey = + '\0meta\\file\\' + createHash('sha256').update(path).digest('hex').slice(0, 32); + legacyOffsets.set(legacyKey, row.value['offset'] as number); + await db.del(row.key); + await db.set(legacyKey, row.value); + } + first.dispose(); // releases the write lock for the next instance + await drainGlobalSearchDisposals(); + + // A fresh instance migrates on its first background pass. + const second = track(makeService(home!, staticIndex([s1]))); + await settleSync(second); + const db2 = internals(second).db!; + const after = db2.query({ key: { prefix: '\0meta\\file\\' } }); + expect(after.length).toBe(2); + for (const row of after) { + // Every key is session-scoped now, and the watermark survived. + expect(row.key.slice('\0meta\\file\\'.length)).toContain('\\'); + const path = row.value['path'] as string; + const legacyKey = + '\0meta\\file\\' + createHash('sha256').update(path).digest('hex').slice(0, 32); + expect(row.value['offset']).toBe(legacyOffsets.get(legacyKey)); + } + + // The index keeps serving the migrated metas' docs, and an incremental + // append resumes from the migrated watermark. + expect((await second.search({ query: '苹果' })).items.length).toBe(2); + await appendFile(main, `${userLine('苹果 resumed', T3)}\n`, 'utf8'); + await settleSync(second); + const page = await second.search({ query: '苹果' }); + expect(page.items.length).toBe(3); + expect(page.items.some((h) => h.snippet.includes('resumed'))).toBe(true); + }); + + it('paginates by keyset without duplicates or gaps under concurrent additive writes', async () => { + const s1 = summary('s1', 'keyset', T1); + const lines: string[] = []; + for (let i = 0; i < 25; i++) lines.push(userLine(`苹果 doc ${i}`, T1 + i)); + const file = await writeWire(home!, 's1', 'main', lines); + const service = track(makeService(home!, staticIndex([s1]))); + await service.reindex(); + + const page1 = await service.search({ query: '苹果', sort: 'time_asc', pageSize: 10 }); + expect(page1.items.length).toBe(10); + expect(page1.hasMore).toBe(true); + // v2 keyset token: version + generation + boundary. + const decoded = JSON.parse( + Buffer.from(page1.pageToken!, 'base64url').toString('utf8'), + ) as Record; + expect(decoded['v']).toBe(2); + expect(typeof decoded['g']).toBe('number'); + expect(Array.isArray(decoded['b'])).toBe(true); + + // Additive writes land mid-pagination: they do NOT change the + // generation, and pages stay exact (new docs sort past the cursor). + const more: string[] = []; + for (let i = 25; i < 30; i++) more.push(`${userLine(`苹果 doc ${i}`, T1 + i)}\n`); + await appendFile(file, more.join(''), 'utf8'); + await settleSync(service); + + const page2 = await service.search({ + query: '苹果', + sort: 'time_asc', + pageSize: 10, + pageToken: page1.pageToken, + }); + const page3 = await service.search({ + query: '苹果', + sort: 'time_asc', + pageSize: 10, + pageToken: page2.pageToken, + }); + expect(page3.hasMore).toBe(false); + + const times = [...page1.items, ...page2.items, ...page3.items].map((h) => h.time); + expect(times).toEqual(Array.from({ length: 30 }, (_, i) => T1 + i)); + expect(new Set(times).size).toBe(30); + }); + + it('rejects page tokens from an older generation after a rescan or a reindex', async () => { + const s1 = summary('s1', 'generation', T1); + const lines: string[] = []; + for (let i = 0; i < 30; i++) lines.push(userLine(`苹果 doc ${i} padding`, T1 + i)); + const file = await writeWire(home!, 's1', 'main', lines); + const service = track(makeService(home!, staticIndex([s1]))); + await service.reindex(); + + // A shrink rescan REPLACES indexed documents → generation bump. + const page1 = await service.search({ query: '苹果', sort: 'time_asc', pageSize: 10 }); + await writeFile( + file, + `${Array.from({ length: 30 }, (_, i) => userLine('苹果 x', T1 + i)).join('\n')}\n`, + 'utf8', + ); + await settleSync(service); + await expect( + service.search({ query: '苹果', sort: 'time_asc', pageToken: page1.pageToken }), + ).rejects.toMatchObject({ reason: 'invalid_page_token' }); + await expect( + service.search({ query: '苹果', sort: 'time_asc', pageToken: page1.pageToken }), + ).rejects.toThrow(/older index generation/); + + // A reindex swaps the base → generation bump too. + const page2 = await service.search({ query: '苹果', sort: 'time_asc', pageSize: 10 }); + await service.reindex(); + await expect( + service.search({ query: '苹果', sort: 'time_asc', pageToken: page2.pageToken }), + ).rejects.toMatchObject({ reason: 'invalid_page_token' }); + }); + + it('terminates a hot 2-character literal query within the postings budget', async () => { + const s1 = summary('s1', 'hot bigram', T1); + const lines: string[] = []; + for (let i = 0; i < 400; i++) lines.push(userLine(`的汉 filler ${i} about stuff`, T1 + i)); + await writeWire(home!, 's1', 'main', lines); + const service = track(makeService(home!, staticIndex([s1]))); + await service.reindex(); + + // Full budget: the page is complete. + const full = await service.search({ query: '的汉', mode: 'literal' }); + expect(full.incomplete).toBeUndefined(); + expect(full.items.length).toBe(20); + + // A tiny postings budget stops the index-side candidate scan early and + // says so — every returned hit is still confirmed, never a false hit. + service.postingsVisitBudget = 50; + const page = await service.search({ query: '的汉', mode: 'literal' }); + expect(page.incomplete).toBe('postings_budget'); + expect(page.items.length).toBeGreaterThan(0); + expect(page.items.every((h) => h.snippet.includes('的汉'))).toBe(true); + }); + + it('exposes degraded state when a read-only refresh fails, and recovers', async () => { + const s1 = summary('s1', 'degraded', T1); + const file = await writeWire(home!, 's1', 'main', [userLine('苹果 base', T1)]); + const index = staticIndex([s1]); + const writer = track(makeService(home!, index)); + await writer.reindex(); + const reader = track(makeService(home!, index)); + await reader.status(); + expect((await reader.search({ query: '苹果' })).items.length).toBe(1); + + const original = internals(reader).doRefreshReadonly; + internals(reader).doRefreshReadonly = async () => { + throw new Error('refresh boom'); + }; + await appendFile(file, `${userLine('苹果 delta', T2)}\n`, 'utf8'); + await settleSync(writer); + + // The search still serves the stale view; the refresh failure is + // recorded instead of swallowed. + const stale = await reader.search({ query: '苹果' }); + expect(stale.items.length).toBe(1); + await refreshNow(reader); + const degraded = await reader.search({ query: '苹果' }); + expect(degraded.indexState.state).toBe('readonly'); + expect(degraded.indexState.degraded).toBe('refresh boom'); + expect(degraded.items.length).toBe(1); // still the stale generation + + // Restoring the refresh path self-heals the flag. + internals(reader).doRefreshReadonly = original; + await refreshNow(reader); + const healed = await reader.search({ query: '苹果' }); + expect(healed.indexState.degraded).toBeUndefined(); + expect(healed.items.length).toBe(2); + }); + + it('accepts legacy v1 offset tokens and upgrades them to v2 keyset tokens', async () => { + const s1 = summary('s1', 'legacy token', T1); + const lines: string[] = []; + for (let i = 0; i < 30; i++) lines.push(userLine(`苹果 legacy ${i}`, T1 + i)); + await writeWire(home!, 's1', 'main', lines); + const service = track(makeService(home!, staticIndex([s1]))); + await service.reindex(); + + const page1 = await service.search({ query: '苹果', sort: 'time_asc', pageSize: 10 }); + const v2 = JSON.parse( + Buffer.from(page1.pageToken!, 'base64url').toString('utf8'), + ) as { v: number; f: string }; + expect(v2.v).toBe(2); + + // Fabricate a pre-versioning offset token with the same fingerprint: + // it is answered with offset semantics and upgraded on the way out. + const legacyToken = Buffer.from(JSON.stringify({ f: v2.f, s: 10 })).toString('base64url'); + const page2 = await service.search({ + query: '苹果', + sort: 'time_asc', + pageSize: 10, + pageToken: legacyToken, + }); + expect(page2.items.map((h) => h.time)).toEqual( + Array.from({ length: 10 }, (_, i) => T1 + 10 + i), + ); + expect(page2.hasMore).toBe(true); + const upgraded = JSON.parse( + Buffer.from(page2.pageToken!, 'base64url').toString('utf8'), + ) as { v: number }; + expect(upgraded.v).toBe(2); + + const page3 = await service.search({ + query: '苹果', + sort: 'time_asc', + pageSize: 10, + pageToken: page2.pageToken, + }); + expect(page3.items.map((h) => h.time)).toEqual( + Array.from({ length: 10 }, (_, i) => T1 + 20 + i), + ); + expect(page3.hasMore).toBe(false); + }); + + it('paginates score sort by (score, time, key) without duplicates', async () => { + const s1 = summary('s1', 'score pages', T1); + const lines: string[] = []; + // Varying term frequency per doc produces several score bands. + for (let i = 0; i < 30; i++) { + lines.push(userLine(`${'苹果 '.repeat((i % 5) + 1)}doc ${i}`, T1 + i)); + } + await writeWire(home!, 's1', 'main', lines); + const service = track(makeService(home!, staticIndex([s1]))); + await service.reindex(); + + const seen = new Set(); + const boundaryScores: number[] = []; + let token: string | undefined; + for (let p = 0; p < 3; p++) { + const page: Awaited> = await service.search({ + query: '苹果', + sort: 'score', + pageSize: 10, + pageToken: token, + }); + expect(page.items.length).toBe(10); + for (const hit of page.items) { + expect(seen.has(hit.time)).toBe(false); + seen.add(hit.time); + } + // Scores are non-increasing within and across pages. + for (let i = 1; i < page.items.length; i++) { + expect(page.items[i]!.score).toBeLessThanOrEqual(page.items[i - 1]!.score); + } + boundaryScores.push(page.items[0]!.score); + token = page.pageToken; + } + for (let i = 1; i < boundaryScores.length; i++) { + expect(boundaryScores[i]!).toBeLessThanOrEqual(boundaryScores[i - 1]!); + } + expect(seen.size).toBe(30); + expect(token).toBeUndefined(); + }); + + it('self-heals a failed open through search traffic', async () => { + const s1 = summary('s1', 'heal', T1); + await writeWire(home!, 's1', 'main', [userLine('苹果 heal', T1)]); + const service = track(makeService(home!, staticIndex([s1]))); + + // The db open fails transiently (e.g. a read-only open racing a + // writer's compaction). + const si = internals(service); + const origOpen = si.openSearchDb; + let failOpen = true; + si.openSearchDb = async () => { + if (failOpen) throw new Error('open boom'); + return origOpen.call(service); + }; + + // First search: building semantics while the (doomed) pass runs. + const building = await service.search({ query: '苹果' }); + expect(building.indexState.state).toBe('building'); + await internals(service).syncPromise?.catch(() => {}); // the failing pass + + // While the failure persists, searches surface it — and each kicks a + // background retry instead of freezing the service. + await expect(service.search({ query: '苹果' })).rejects.toMatchObject({ + reason: 'index_unavailable', + }); + await expect(service.search({ query: '苹果' })).rejects.toThrow(/failed to open: open boom/); + await internals(service).syncPromise?.catch(() => {}); // the kicked retry also fails for now + + // The transient cause goes away. NO explicit sync is driven here: the + // next search itself must kick the pass that heals the open. + failOpen = false; + await expect(service.search({ query: '苹果' })).rejects.toMatchObject({ + reason: 'index_unavailable', + }); + await internals(service).syncPromise; // the search-kicked pass: succeeds + + const page = await service.search({ query: '苹果' }); + expect(page.items.length).toBe(1); + expect(page.indexState.state).toBe('ready'); + }); + + it('re-serves from the swapped handle when a background refresh lands mid-search', async () => { + const s1 = summary('s1', 'swap', T1); + await writeWire(home!, 's1', 'main', [userLine('苹果 base', T1)]); + const index = staticIndex([s1]); + const writer = track(makeService(home!, index)); + await writer.reindex(); + const reader = track(makeService(home!, index)); + await reader.status(); + expect((await reader.search({ query: '苹果' })).items.length).toBe(1); + + // Rotate the writer's WAL so the reader's refresh must take the reopen + // path (which swaps the handle and closes the previous one). + await internals(writer).db!.compact(); + + // Park the search's fingerprint probe at a gate; while it is parked, a + // background refresh completes the reopen and closes the handle the + // search captured. + const ri = internals(reader); + const origFp = ri.computeFingerprint.bind(ri); + let fpCalls = 0; + let releaseProbe!: () => void; + const probeGate = new Promise((resolve) => { + releaseProbe = resolve; + }); + ri.computeFingerprint = async () => { + fpCalls++; + if (fpCalls === 1) await probeGate; // the search's probe; the refresh's own probe passes + return origFp(); + }; + + const searchPromise = reader.search({ query: '苹果' }); + for (let i = 0; i < 1_000 && fpCalls === 0; i++) { + await new Promise((resolve) => setImmediate(resolve)); + } + expect(fpCalls).toBe(1); // the search is parked inside the probe + + await refreshNow(reader); // reopen + swap + close the captured handle + releaseProbe(); + + // Must re-pin to the swapped handle instead of dying on the closed one. + const page = await searchPromise; + expect(page.items.length).toBe(1); + expect(page.indexState.state).toBe('readonly'); + }); + + it('rejects over-budget queries: too many terms, oversized literal', async () => { + const s1 = summary('s1', 'budget', T1); + await writeWire(home!, 's1', 'main', [userLine('苹果 budget', T1)]); + const service = track(makeService(home!, staticIndex([s1]))); + await service.reindex(); + + service.maxQueryTerms = 3; + await expect(service.search({ query: 'aa bb cc dd' })).rejects.toMatchObject({ + reason: 'invalid_query', + }); + await expect(service.search({ query: 'aa bb cc dd' })).rejects.toThrow(/too many terms/); + // Duplicate terms collapse before the count. + expect((await service.search({ query: 'aa aa bb cc' })).items).toEqual([]); + + const oversized = 'x'.repeat(1_025); + await expect(service.search({ query: oversized, mode: 'literal' })).rejects.toMatchObject({ + reason: 'invalid_query', + }); + await expect(service.search({ query: oversized, mode: 'literal' })).rejects.toThrow( + /limited to 1024 characters/, + ); + }); + + it('flags deadline and text-budget stops as incomplete instead of truncating silently', async () => { + const s1 = summary('s1', 'deadline', T1); + const lines: string[] = []; + for (let i = 0; i < 200; i++) lines.push(userLine(`苹果 deadline ${i}`, T1 + i)); + await writeWire(home!, 's1', 'main', lines); + const service = track(makeService(home!, staticIndex([s1]))); + await service.reindex(); + + // An already-expired deadline stops the match loop immediately. + service.queryDeadlineMs = -1; + const stopped = await service.search({ query: '苹果' }); + expect(stopped.incomplete).toBe('deadline'); + expect(stopped.items).toEqual([]); + service.queryDeadlineMs = 500; + const complete = await service.search({ query: '苹果' }); + expect(complete.incomplete).toBeUndefined(); + expect(complete.items.length).toBe(20); + + // Literal confirmation charges each candidate's text against the + // volume budget: two 40-char docs fit a budget of 50, the third stops. + service.queryTextBudgetChars = 50; + const textStopped = await service.search({ query: '苹果', mode: 'literal' }); + expect(textStopped.incomplete).toBe('deadline'); + expect(textStopped.items.length).toBeLessThan(20); + service.queryTextBudgetChars = 16_000_000; + const textComplete = await service.search({ query: '苹果', mode: 'literal' }); + expect(textComplete.incomplete).toBeUndefined(); + }); + }); + // -- live route (in-memory transcript scan) ------------------------------------ describe('live route', () => { @@ -1623,4 +2174,63 @@ describe('baseline: synthetic corpus', () => { expect(terms400).toBeLessThan(terms100 * 10 + 100); expect(literal400).toBeLessThan(literal100 * 10 + 100); }, 120_000); + + it('stage-4: deep keyset pages cost like the first page, with a bounded event-loop pause', async () => { + const all: SessionSummary[] = []; + const service = makeService(home!, staticIndex(all)); + services.push(service); + all.push(...(await writeCorpus(0, 400))); + await service.reindex(); + + const eld: IntervalHistogram = monitorEventLoopDelay(); + eld.enable(); + try { + // 'message' hits every user doc (400 sessions × 8 = 3200 docs). Walk + // 10 pages of 20 via keyset tokens, then re-measure the first and the + // tenth page with the same tokens (static corpus → tokens stay valid). + const tokens: (string | undefined)[] = [undefined]; + let page = await service.search({ query: 'message', sort: 'time_desc', pageSize: 20 }); + for (let p = 1; p < 10; p++) { + tokens.push(page.pageToken); + page = await service.search({ + query: 'message', + sort: 'time_desc', + pageSize: 20, + pageToken: page.pageToken, + }); + } + expect(page.items.length).toBe(20); + + const page1Ms = await medianMs(() => + service.search({ query: 'message', sort: 'time_desc', pageSize: 20 }), + ); + const page10Ms = await medianMs(() => + service.search({ query: 'message', sort: 'time_desc', pageSize: 20, pageToken: tokens[9] }), + ); + const literalMs = await medianMs(() => + service.search({ query: 'message 3 about', mode: 'literal' }), + ); + + const eldMaxMs = eld.max / 1e6; + const eldP99Ms = eld.percentile(99) / 1e6; + console.log( + `[baseline] stage4 ${JSON.stringify({ + sessions: 400, + page1MedianMs: page1Ms, + page10MedianMs: page10Ms, + literalMedianMs: literalMs, + eventLoopDelayMs: { p99: eldP99Ms, max: eldMaxMs }, + })}`, + ); + // Page 10 re-runs the same bounded candidate scan but skips the full + // re-sort + offset slice of the old implementation: its cost tracks + // the first page's, not the match count × page depth. + expect(page10Ms).toBeLessThan(page1Ms * 5 + 50); + // The whole measurement never hard-blocks the loop for long (the + // query path is synchronous but bounded; syncs run in the background). + expect(eldMaxMs).toBeLessThan(500); + } finally { + eld.disable(); + } + }, 120_000); }); diff --git a/packages/minidb/src/index.ts b/packages/minidb/src/index.ts index bf146ccc209..8a4a316c9f1 100644 --- a/packages/minidb/src/index.ts +++ b/packages/minidb/src/index.ts @@ -1420,14 +1420,29 @@ export class MiniDb { return ok; } - search(name: string, q: string, opts: { op?: 'AND' | 'OR'; limit?: number } = {}): { key: string; value: V | undefined; score: number }[] { + search(name: string, q: string, opts: { op?: 'AND' | 'OR'; limit?: number; maxVisits?: number } = {}): { key: string; value: V | undefined; score: number }[] { + return this.searchBounded(name, q, opts).hits; + } + + /** + * `search` with work accounting: `opts.maxVisits` bounds how many posting + * entries the index visits (see TextIndex.searchBounded); the result + * reports the visits and whether the budget truncated the candidate set + * (hits are then a subset of the full matches, never false hits). + */ + searchBounded( + name: string, + q: string, + opts: { op?: 'AND' | 'OR'; limit?: number; maxVisits?: number } = {}, + ): { hits: { key: string; value: V; score: number }[]; visits: number; truncated: boolean } { this.ensureOpen(); const ti = this.text.get(name); if (!ti) throw new Error(`no such text index: ${name}`); - return ti - .search(q, opts) + const res = ti.searchBounded(q, opts); + const hits = res.hits .map(({ key, score }) => ({ key: fromKStr(key), value: this.decode(this.store.get(key)), score })) .filter((r): r is { key: string; value: V; score: number } => r.value !== undefined); + return { hits, visits: res.visits, truncated: res.truncated }; } private indexPredicates(filter?: Record): { field: string; cond: unknown }[] { diff --git a/packages/minidb/src/text-index.ts b/packages/minidb/src/text-index.ts index ac2425c17a4..e9ed4b35749 100644 --- a/packages/minidb/src/text-index.ts +++ b/packages/minidb/src/text-index.ts @@ -100,6 +100,24 @@ export interface SearchHit { export interface SearchOptions { op?: 'AND' | 'OR'; limit?: number; + /** + * Max posting entries visited (decoded + merged) across all query terms. + * Terms are decoded most-selective-first and a term whose list would + * overflow the remaining budget contributes only its leading prefix, so an + * exhausted budget yields a SUBSET of the full matches — never false hits. + * Detect the shortfall via `searchBounded`'s `truncated` flag; plain + * `search` keeps returning just the (possibly partial) hits. + */ + maxVisits?: number; +} + +/** `search` outcome with its work accounting (see SearchOptions.maxVisits). */ +export interface BoundedSearchResult { + readonly hits: SearchHit[]; + /** Posting entries actually visited across all query terms. */ + readonly visits: number; + /** True when `maxVisits` cut one or more postings lists short. */ + readonly truncated: boolean; } /** Staged text-index rebuild (see TextIndex.beginBuild): feed docs with @@ -499,10 +517,40 @@ export class TextIndex { this.N--; } - /** Decoded base postings for a term (disk, cached; or memory). May still - * contain tombstoned docIDs — callers filter via `removed`. */ - private readBase(term: string): ReadonlyMap { - if (this.memBase) return this.memBase.get(term) ?? EMPTY_MAP; + /** + * Decoded base postings for a term (disk, cached; or memory), budgeted: + * with `maxEntries`, a list longer than the budget is capped to its leading + * (lowest-docID) prefix and flagged `capped`. A capped read never populates + * the LRU cache, so a later uncapped query still decodes the full list. + * May still contain tombstoned docIDs — callers filter via `removed`. + */ + private readBaseBounded( + term: string, + maxEntries: number | undefined, + ): { map: ReadonlyMap; capped: boolean } { + if (this.memBase) { + const m = this.memBase.get(term); + if (m === undefined || maxEntries === undefined || m.size <= maxEntries) { + return { map: m ?? EMPTY_MAP, capped: false }; + } + const out = new Map(); + let i = 0; + for (const [id, f] of m) { + if (i++ >= maxEntries) break; + out.set(id, f); + } + return { map: out, capped: true }; + } + + const entry = this.postings.get(term); + if (entry !== undefined && maxEntries !== undefined && entry.df > maxEntries) { + // Over budget before decoding even starts: cap the decode itself and + // skip the cache (a partial list must never be cached as complete). + const arr = this.pf ? this.pf.read(entry, maxEntries) : []; + const m = new Map(); + for (const [id, f] of arr) m.set(id, f); + return { map: m, capped: true }; + } let arr = this.cache.get(term); if (arr) { @@ -510,7 +558,6 @@ export class TextIndex { this.cache.delete(term); this.cache.set(term, arr); } else { - const entry = this.postings.get(term); arr = entry && this.pf ? this.pf.read(entry) : []; if (this.cacheTerms > 0) { this.cache.set(term, arr); @@ -522,16 +569,45 @@ export class TextIndex { } const m = new Map(); for (const [id, f] of arr) m.set(id, f); - return m; + return { map: m, capped: false }; } - /** Live postings for a term = (base ∪ delta) minus tombstones. */ - private livePostings(term: string): Map { + /** + * Live postings for a term = (base ∪ delta) minus tombstones, budgeted: at + * most `maxEntries` entries are visited (base first, then delta); `capped` + * flags a shortfall and `visited` reports the decoded/merged entry count + * feeding the query-level budget accounting. + */ + private livePostingsBounded( + term: string, + maxEntries: number | undefined, + ): { map: Map; capped: boolean; visited: number } { const out = new Map(); - for (const [id, f] of this.readBase(term)) if (!this.removed.has(id)) out.set(id, f); + let capped = false; + const base = this.readBaseBounded(term, maxEntries); + if (base.capped) capped = true; + for (const [id, f] of base.map) if (!this.removed.has(id)) out.set(id, f); + let visited = base.map.size; const d = this.delta.get(term); - if (d) for (const [id, f] of d) if (!this.removed.has(id)) out.set(id, f); - return out; + if (d) { + for (const [id, f] of d) { + if (maxEntries !== undefined && visited >= maxEntries) { + capped = true; + break; + } + visited++; + if (!this.removed.has(id)) out.set(id, f); + } + } + return { map: out, capped, visited }; + } + + /** Estimated document frequency without decoding the list: the on-disk + * dictionary carries df, the memory base and delta know their size. */ + private estimatedDf(term: string): number { + let n = this.memBase ? (this.memBase.get(term)?.size ?? 0) : (this.postings.get(term)?.df ?? 0); + n += this.delta.get(term)?.size ?? 0; + return n; } private idf(df: number): number { @@ -539,13 +615,35 @@ export class TextIndex { } search(query: string, opts: SearchOptions = {}): SearchHit[] { + return this.searchBounded(query, opts).hits; + } + + searchBounded(query: string, opts: SearchOptions = {}): BoundedSearchResult { const qtokens = [...new Set(this.queryTokenizer(query))]; - if (!qtokens.length) return []; + if (!qtokens.length) return { hits: [], visits: 0, truncated: false }; const op = opts.op ?? 'AND'; const limit = opts.limit ?? 50; + // Decode the most selective terms first: under a visit budget the hot + // terms are the ones that get prefix-capped, and AND-intersection starts + // from the smallest candidate set either way. Order never changes the + // unbudgeted result. + const terms = qtokens + .map((t) => ({ t, df: this.estimatedDf(t) })) + .sort((a, b) => a.df - b.df); + + let remaining = opts.maxVisits ?? Number.POSITIVE_INFINITY; + let visits = 0; + let truncated = false; const termMaps = new Map>(); - for (const t of qtokens) termMaps.set(t, this.livePostings(t)); + for (const { t } of terms) { + const cap = remaining === Number.POSITIVE_INFINITY ? undefined : Math.max(0, remaining); + const live = this.livePostingsBounded(t, cap); + termMaps.set(t, live.map); + visits += live.visited; + remaining -= live.visited; + if (live.capped) truncated = true; + } let candidates: Set; if (op === 'OR') { @@ -553,7 +651,7 @@ export class TextIndex { for (const m of termMaps.values()) for (const id of m.keys()) candidates.add(id); } else { const lists = [...termMaps.values()]; - if (lists.some((m) => m.size === 0)) return []; + if (lists.some((m) => m.size === 0)) return { hits: [], visits, truncated }; lists.sort((a, b) => a.size - b.size); candidates = new Set(lists[0]!.keys()); for (let i = 1; i < lists.length && candidates.size; i++) { @@ -574,7 +672,7 @@ export class TextIndex { if (key !== undefined) top.offer({ key, score }); } } - return top.sorted(); + return { hits: top.sorted(), visits, truncated }; } /** Close the underlying postings file. */ diff --git a/packages/minidb/src/text-postings.ts b/packages/minidb/src/text-postings.ts index 116e52120ea..91a21a889ed 100644 --- a/packages/minidb/src/text-postings.ts +++ b/packages/minidb/src/text-postings.ts @@ -71,13 +71,19 @@ export function encodePostingList(entries: readonly (readonly [number, number])[ return Buffer.from(bytes); } -/** Decode a payload back into [docID, freq] pairs (ascending docID). */ -export function decodePostingList(buf: Buffer): [number, number][] { +/** + * Decode a payload back into [docID, freq] pairs (ascending docID). With + * `maxEntries`, only that many leading pairs are decoded (docIDs ascend, so + * this is the lowest-docID prefix): a query-time work budget can stop the + * decode of a hot term's list early instead of always paying its full length. + */ +export function decodePostingList(buf: Buffer, maxEntries?: number): [number, number][] { const cur = { i: 0 }; const count = decodeVarint(buf, cur); - const out = Array.from<[number, number]>({ length: count }); + const n = maxEntries === undefined ? count : Math.min(count, maxEntries); + const out = Array.from<[number, number]>({ length: n }); let prev = 0; - for (let k = 0; k < count; k++) { + for (let k = 0; k < n; k++) { const d = decodeVarint(buf, cur); const freq = decodeVarint(buf, cur); prev += d; @@ -175,8 +181,10 @@ export class PostingsFile { return this.fd !== null; } - /** Read + decode one term's postings record by dictionary pointer. */ - read(entry: PostingEntry): [number, number][] { + /** Read + decode one term's postings record by dictionary pointer. With + * `maxEntries`, only the leading (lowest-docID) part of the list is + * decoded — see decodePostingList. */ + read(entry: PostingEntry, maxEntries?: number): [number, number][] { if (this.fd === null) throw new Error('postings file is closed'); const buf = Buffer.alloc(entry.len); let got = 0; @@ -186,7 +194,7 @@ export class PostingsFile { got += r; } const rec = decodeRecord(buf); - return decodePostingList(rec.payload); + return decodePostingList(rec.payload, maxEntries); } close(): void { diff --git a/packages/minidb/test/text-index.test.ts b/packages/minidb/test/text-index.test.ts index d01cdb47a7f..4d804bae7e5 100644 --- a/packages/minidb/test/text-index.test.ts +++ b/packages/minidb/test/text-index.test.ts @@ -670,3 +670,117 @@ test('TextIndex: overwrite/remove prune the delta via the doc reverse map', () = assert.equal(ti.termCount(), 1, 'only mango remains'); ti.close(); }); + + +// ---- query-time postings budget (maxVisits / searchBounded) --------------- + +test('TextIndex: maxVisits truncates a hot term and reports visits (disk-backed)', async () => { + const dir = await tmpDir(); + try { + const ti = new TextIndex({ postingsPath: path.join(dir, 't.postings') }); + const entries: { key: string; value: { bio: string } }[] = []; + for (let i = 0; i < 500; i++) entries.push({ key: `k${String(i).padStart(4, '0')}`, value: { bio: 'x pad' } }); + await ti.build(entries); + + const full = ti.searchBounded('x', { limit: 1_000 }); + assert.equal(full.hits.length, 500); + assert.equal(full.truncated, false); + assert.ok(full.visits >= 500); + + const bounded = ti.searchBounded('x', { limit: 1_000, maxVisits: 100 }); + assert.equal(bounded.truncated, true, 'the budget cut the hot list short'); + assert.ok(bounded.visits <= 100, `visits stay within the budget, got ${bounded.visits}`); + assert.ok(bounded.hits.length > 0 && bounded.hits.length <= 100); + // A truncated result is a SUBSET of the full matches — never false hits. + const fullKeys = new Set(full.hits.map((h) => h.key)); + for (const h of bounded.hits) assert.ok(fullKeys.has(h.key), `${h.key} is a real match`); + + // The same options through plain search() keep returning just the hits. + assert.deepEqual( + ti.search('x', { limit: 1_000, maxVisits: 100 }).map((h) => h.key), + bounded.hits.map((h) => h.key), + ); + // Unbudgeted callers are unaffected, and a capped read never poisoned the + // postings cache with a partial list. + assert.equal(ti.search('x', { limit: 1_000 }).length, 500); + ti.close(); + } finally { + await fs.rm(dir, { recursive: true, force: true }); + } +}); + +test('TextIndex: maxVisits caps the memory base the same way', async () => { + const ti = new TextIndex(); // memory base + for (let i = 0; i < 200; i++) ti.add(`k${String(i).padStart(4, '0')}`, { bio: 'x pad' }); + + const bounded = ti.searchBounded('x', { limit: 1_000, maxVisits: 50 }); + assert.equal(bounded.truncated, true); + assert.ok(bounded.visits <= 50); + assert.ok(bounded.hits.length > 0 && bounded.hits.length <= 50); + assert.equal(ti.search('x', { limit: 1_000 }).length, 200, 'unbudgeted search unaffected'); + ti.close(); +}); + +test('TextIndex: AND under a budget yields a subset with complete per-doc scores', async () => { + const dir = await tmpDir(); + try { + const ti = new TextIndex({ postingsPath: path.join(dir, 't.postings') }); + const entries: { key: string; value: { bio: string } }[] = []; + // 'hot' appears in 500 docs, 'rare' in 3 of them. + for (let i = 0; i < 500; i++) { + const rare = i < 3 ? ' rare' : ''; + entries.push({ key: `k${String(i).padStart(4, '0')}`, value: { bio: `hot pad${rare}` } }); + } + await ti.build(entries); + + const full = ti.searchBounded('hot rare', { limit: 1_000 }); + assert.equal(full.hits.length, 3); + + // The budget is exhausted by the hot term, but the selective term decodes + // first; the intersection can only shrink — a subset, never false hits. + // (Scores under a truncated budget are approximate: idf is computed from + // the decoded list, whose df the cap shrinks. The `truncated` flag is + // what tells the caller not to trust completeness.) + const bounded = ti.searchBounded('hot rare', { limit: 1_000, maxVisits: 60 }); + assert.equal(bounded.truncated, true); + const fullKeys = new Set(full.hits.map((h) => h.key)); + assert.ok(bounded.hits.length <= full.hits.length); + for (const h of bounded.hits) { + assert.ok(fullKeys.has(h.key), `${h.key} is a true AND match`); + assert.ok(h.score > 0); + } + + // A zero budget cannot assemble any AND candidate set: empty + flagged. + const zero = ti.searchBounded('hot rare', { limit: 1_000, maxVisits: 0 }); + assert.equal(zero.truncated, true); + assert.equal(zero.hits.length, 0); + ti.close(); + } finally { + await fs.rm(dir, { recursive: true, force: true }); + } +}); + +test('MiniDb: searchBounded surfaces values, visits and the truncated flag', async () => { + const dir = await tmpDir(); + try { + const db = await MiniDb.open({ dir, valueCodec: 'json', fsyncPolicy: 'no' }); + await db.createTextIndex('body', { fields: ['bio'] }); + for (let i = 0; i < 300; i++) { + await db.set(`k${String(i).padStart(4, '0')}`, { bio: 'x pad', n: i }); + } + + const bounded = db.searchBounded('body', 'x', { limit: 1_000, maxVisits: 80 }); + assert.equal(bounded.truncated, true); + assert.ok(bounded.visits <= 80); + assert.ok(bounded.hits.length > 0 && bounded.hits.length <= 80); + for (const h of bounded.hits) assert.equal(typeof h.value.n, 'number', 'hits carry decoded values'); + + const full = db.search('body', 'x', { limit: 1_000 }); + assert.equal(full.length, 300); + const fullKeys = new Set(full.map((h) => h.key)); + for (const h of bounded.hits) assert.ok(fullKeys.has(h.key)); + await db.close(); + } finally { + await fs.rm(dir, { recursive: true, force: true }); + } +}); From 75df66e8ecd57cfbae1d5915d95ca09a212ab504 Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Sun, 2 Aug 2026 23:06:23 +0800 Subject: [PATCH 05/15] fix(minidb): poison, roll back, and recover the WAL on write failures - give WAL writes a commit point: a failed flushBatch poisons the WAL (WAL_POISONED, tracked separately as walWriteErrors vs walFsyncErrors), rejects queued frames in reverse enqueue order, and stops scheduling further batches; everysec background sync failures stay non-rejecting per stage-1 semantics - recover in place to a known-safe point: a serialized recovery chain truncates the WAL back to the first un-acked frame, rebuilds size/nextOffset, and clears the poison; writes queue behind the recovery gate (zero-cost when idle), a failed truncate flips the instance into an explicit writeDisabled state, and a stale truncate offset (WAL file replaced by a rotation) skips the truncate - roll failed flush groups back as a unit: frames are stamped with their batchId, MiniDb keeps per-group earliest pre-state, and the first rejection restores every key of the group (rejected writes no longer reappear after reopen, and in-memory state matches reopen for any failure interleaving); the per-op seq guard remains for cross-group and rotation-retry races - wrap applyOp and the following in-memory mutations so a contract violation poisons the WAL and rolls the group back instead of escaping as a half-commit; frames never enqueued (seal race) roll back per-op without poisoning - tag errors past the commit point with ambiguous: true so callers can distinguish "definitely not applied" from "maybe applied but revoked" - close() waits for the recovery chain to go idle and backup() fences behind in-flight recovery before copying files Controlled A/B bench (22 alternating iterations, 100k concurrent sets): write-path throughput regression is within the 2% budget. --- packages/minidb/src/index.ts | 477 ++++++++++++++++-- packages/minidb/src/wal.ts | 203 +++++++- packages/minidb/test/compaction-fault.test.ts | 82 +++ packages/minidb/test/review-round2.test.ts | 462 +++++++++++++++++ packages/minidb/test/wal.test.ts | 135 +++++ 5 files changed, 1298 insertions(+), 61 deletions(-) diff --git a/packages/minidb/src/index.ts b/packages/minidb/src/index.ts index 8a4a316c9f1..e5a9668798f 100644 --- a/packages/minidb/src/index.ts +++ b/packages/minidb/src/index.ts @@ -12,6 +12,7 @@ import path from 'node:path'; import { Store } from './store.js'; import type { StoreRecord, ValueLoc } from './store.js'; import { WAL } from './wal.js'; +import type { WalPoison } from './wal.js'; import { ValueReader } from './value-reader.js'; import { recover, catchUpWal, frameToOps } from './recovery.js'; import { compact, shouldCompact } from './compaction.js'; @@ -212,6 +213,20 @@ interface PreparedOp { valueDecoded: V | undefined; } +/** Per-flush-group rollback state: the pre-group logical record of every key + * the group's ops touched, plus the count of the group's ops still awaiting + * their frame's `done`. Created when the first op of a group applies. */ +interface WalGroup { + /** pk → pre-group record. The earliest capture per key wins: several ops of + * one group on the same key roll back to the state before the FIRST of + * them, so the result matches what a reopen replays (the whole group's + * frames are truncated away together). */ + pre: Map; + pending: number; + /** Set once the group failed and was rolled back; later rejects are no-ops. */ + rolledBack: boolean; +} + /** Persisted shape of one entry in `db.textindexes.json`. `tokenizer` is * absent in definitions written before n-gram support existed, which means * 'default'; it is also omitted for new default indexes so their definitions @@ -282,13 +297,52 @@ export class MiniDb { maxMemoryPolicy: 'reject' | 'evict-lru' = 'reject'; private access = new Set(); // pk, insertion-ordered by last touch (Map/Set iteration order): front = LRU private uniqueWriteLock: Promise = Promise.resolve(); + /** Serializes in-place WAL recoveries (poison → truncate → resume), the same + * promise-chain style as uniqueWriteLock. Never rejects (a failed recovery + * lands in writeDisabled instead). */ + private walRecoveryChain: Promise = Promise.resolve(); + /** False while a kicked recovery may still be running. Write-op commit + * bodies check it BEFORE awaiting walRecoveryChain: with no recovery in + * flight the commit path takes zero extra awaits (hot path), while a write + * issued after a failure queues behind the recovery instead of hitting the + * still-poisoned WAL. */ + private walRecoveryIdle = true; + /** The poison object the current recovery chain covers (dedupe key for + * kickWalRecovery; each poison event is a fresh object identity). */ + private walRecoveryCovers: WalPoison | null = null; + /** Set when in-place WAL recovery's truncate fails (persistent I/O error): + * from then on every write op throws a WAL_WRITE_DISABLED error + * immediately; reads and close() keep working. The value is the truncate + * error (the cause). DESIGNED CONSEQUENCE: the WAL stays poisoned, so + * close() skips its final flush and the un-acked tail is LEFT in db.wal — + * a later reopen replays it and the rejected writes resurface. That is + * exactly why every commit-point failure is marked `ambiguous: true`: in + * this state the caller cannot assume a rejected write had no effect. */ + writeDisabled: unknown = null; + /** Scratch out-param for applyOp's pre-state capture. Live only within the + * synchronous apply section of a commit body (shared safely because + * nothing awaits while it is read); callers lift the reference into a + * local before any await. Avoids one small allocation per write op. */ + private readonly applyBox: { prev: StoreRecord | undefined } = { prev: undefined }; + /** Pre-group rollback state of every in-flight flush group, keyed per WAL: + * a compaction rotation replaces the WAL and each side's batchIds are + * independent. Entries are dropped when their group fully settles. */ + private pendingGroups = new Map>(); + /** The group groupFor returned most recently (see groupFor); invalidated + * when that group settles or rolls back. batchIds are monotonic per WAL, + * so a stale (wal, batchId) pair can never collide with a later group. */ + private lastGroup: { wal: WAL; batchId: number; group: WalGroup } | null = null; readonly stats = { compactions: 0, compactErrors: 0, walBytesWritten: 0, walFsyncs: 0, + /** Failed writev-class attempts on the WAL write path. Each one poisons + * the WAL and triggers an in-place recovery (truncate + resume). */ + walWriteErrors: 0, /** Failed fsync attempts; a background everysec failure never rejects a - * write — it surfaces only here and in lastWalFsyncError. */ + * write — it surfaces only here and in lastWalFsyncError. A write-path + * ('always') fsync failure rejects its batch and poisons the WAL. */ walFsyncErrors: 0, /** Sticky copy of the most recent fsync failure (never cleared). */ lastWalFsyncError: null as unknown, @@ -742,6 +796,182 @@ export class MiniDb { } } + // ---- WAL poison: in-place recovery + flush-group rollback ---------------- + // + // Commit point semantics: an op is committed when its frame's `done` + // resolves. A WAL write/fsync failure poisons the WAL (see wal.ts) and + // rejects every un-acked frame; each rejected op rolls its flush group back + // to the pre-group records, then the instance recovers the WAL in place — + // truncate db.wal to the failed batch's first predicted offset (removing + // exactly the un-acked bytes), refreshSize, clearPoison — so the on-disk + // tail a reopen would replay and the in-memory state agree again. + + /** Register one op of a flush group (one call per op awaiting a frame) and + * return the group; null when the frame never entered a group (batchId < 0: + * a sealed/closed/poisoned appendLoc — those use the per-op rollback). + * lastGroup caches the previous lookup: ops of one flush burst share the + * same (wal, batchId), so they hit two reference compares instead of two + * map lookups. */ + private groupFor(wal: WAL, batchId: number): WalGroup | null { + if (batchId < 0) return null; + const last = this.lastGroup; + if (last && last.wal === wal && last.batchId === batchId) { + last.group.pending++; + return last.group; + } + let byId = this.pendingGroups.get(wal); + if (!byId) { + byId = new Map(); + this.pendingGroups.set(wal, byId); + } + let g = byId.get(batchId); + if (!g) { + g = { pre: new Map(), pending: 0, rolledBack: false }; + byId.set(batchId, g); + } + g.pending++; + this.lastGroup = { wal, batchId, group: g }; + return g; + } + + /** Record a key's pre-group record; the earliest capture per group wins. */ + private groupNoteKey(group: WalGroup | null, pk: string, prev: StoreRecord | undefined): void { + if (group && !group.pre.has(pk)) group.pre.set(pk, prev); + } + + /** The op's frame landed: drop the group's pre-state once every op settled. */ + private settleGroup(group: WalGroup | null, wal: WAL, batchId: number): void { + if (!group) return; + if (--group.pending === 0 && !group.rolledBack) { + const byId = this.pendingGroups.get(wal); + byId?.delete(batchId); + if (byId && byId.size === 0) this.pendingGroups.delete(wal); + if (this.lastGroup?.group === group) this.lastGroup = null; + } + } + + /** Roll a failed group back as a whole: every touched key returns to its + * pre-group record. Uses the unguarded restoreGroupKey — flush-group + * ordering itself guarantees no legally-committed later op exists (a + * poison rejects every queued frame, and the rollbacks unwind newest + * group first because the WAL rejects queued frames in reverse enqueue + * order), so the per-op seq guard would only misfire here: an earlier + * group's pre-state must win even after a later group's rollback re-seqd + * the record. Idempotent per group. */ + private rollbackGroup(group: WalGroup | null, wal: WAL, batchId: number): void { + if (!group || group.rolledBack) return; + group.rolledBack = true; + for (const [pk, prev] of group.pre) this.restoreGroupKey(pk, prev); + const byId = this.pendingGroups.get(wal); + byId?.delete(batchId); + if (byId && byId.size === 0) this.pendingGroups.delete(wal); + if (this.lastGroup?.group === group) this.lastGroup = null; + } + + /** Tag a failure past the commit point as ambiguous: the op's frame may + * have reached the OS — and its value was visible to in-process readers + * between applyOp and the group rollback — before the failure revoked it, + * so the caller must not assume the write had no effect. Errors thrown + * before the commit point (validation, unique violation, maxMemory, + * write-disabled) carry no flag: those definitely had no effect. + * WAL_SEALED is excluded too: retryOnWalSeal transparently retries it. */ + private markAmbiguous(err: unknown): unknown { + if (err && typeof err === 'object' && (err as { code?: string }).code !== 'WAL_SEALED') { + (err as { ambiguous?: boolean }).ambiguous = true; + } + return err; + } + + /** Kick the in-place recovery for a poisoned WAL (single-flight; recoveries + * serialize on walRecoveryChain). Called from op catches after the group + * rollback — many ops can share one poison event, so a recovery already + * chained for THIS poison object is not chained again (a write storm's + * worth of catches costs one recovery, not one per op). No-op for + * anything that did not poison the WAL (e.g. a seal rejection during a + * compaction rotation). */ + private kickWalRecovery(wal: WAL): void { + const poison = wal.poison; + if (!poison || poison === this.walRecoveryCovers) return; + this.walRecoveryCovers = poison; + this.walRecoveryIdle = false; + const run = this.walRecoveryChain.then(() => this.recoverWalInPlace(wal)); + const chain = run.catch(() => {}); + this.walRecoveryChain = chain; + void chain.finally(() => { + // Idle again only once the LATEST kicked recovery settled (an earlier + // chain's settle must not mark idle while a later one still runs). + if (this.walRecoveryChain === chain) { + this.walRecoveryIdle = true; + this.walRecoveryCovers = null; + } + }); + } + + /** Write-op gate at the start of every commit body: throws synchronously + * while writes are disabled; returns the recovery chain to await while a + * recovery is in flight, null otherwise — so the hot path pays zero extra + * microtasks (`const g = this.walRecoveryGate(); if (g) await g;`). + * Correctness never depends on the gate alone: an op that races a poison + * past the check is still rejected by the WAL itself and rolls its group + * back. */ + private walRecoveryGate(): Promise | null { + if (this.writeDisabled) throw this.writeDisabledError(); + return this.walRecoveryIdle ? null : this.walRecoveryChain; + } + + private writeDisabledError(): Error { + const cause = this.writeDisabled; + return Object.assign( + new Error( + `MiniDb writes are disabled: in-place WAL recovery failed: ${cause instanceof Error ? cause.message : String(cause)}`, + ), + { code: 'WAL_WRITE_DISABLED', cause }, + ); + } + + /** Recover a poisoned WAL back to a known-safe point: truncate db.wal to + * the failed batch's first predicted offset — exactly the un-acked bytes; + * every acknowledged write sits in earlier, successful batches — then + * re-sync the live WAL's size bookkeeping and clear the poison. + * + * Mutual exclusion with a compaction rotation (which has its own recovery: + * swapping in a fresh WAL at the real EOF): the truncate targets the PATH, + * so it is correct whether or not the rotation's recovery swapped the WAL + * meanwhile, and the bookkeeping refresh hits the CURRENT WAL. Both sides + * only ever truncate to the same poison offset, so the composition never + * double-executes. + * + * A truncate failure (the I/O error persists) parks the instance in + * writeDisabled: the poison is kept, so appends keep rejecting, reads keep + * working and close() skips its final flush. */ + private async recoverWalInPlace(wal: WAL): Promise { + let poison = wal.poison; + if (!poison) return; + // Settle any in-flight flush first: a poisonPending truncation point is + // predicted against the in-flight batch fully landing, so truncating past + // the real EOF would zero-extend the file (a corrupt gap on reopen). A + // failed in-flight batch widens the point via poisonWith meanwhile. + await wal.whenIdle(); + poison = wal.poison; + if (!poison) return; + try { + // Stale-coordinate guard: if db.wal was REPLACED since the poison was + // recorded (a compaction rotation committed a new, shorter file at the + // path — the commit-body guards make this unreachable for poisons + // recorded during/after the seal, so this is defense-in-depth), the + // offset belongs to the old file's coordinate system and truncating to + // it would zero-extend the new file. The new file never carried the + // un-acked tail, so skipping the truncate is the correct recovery. + const st = await fs.stat(this.walPath); + if (poison.failedAtOffset <= st.size) await fs.truncate(this.walPath, poison.failedAtOffset); + } catch (err) { + this.writeDisabled = err; + return; + } + await this.wal.refreshSize(); + wal.clearPoison(); + } + private touchAccess(pk: string): void { // 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 @@ -793,16 +1023,42 @@ export class MiniDb { // 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; + const recoveryGate = this.walRecoveryGate(); + if (recoveryGate) await recoveryGate; + const wal = this.wal; + const appended = wal.appendLoc(encodeFrame({ type: TYPE_DEL, key: op.key })); + const group = this.groupFor(wal, appended.batchId); + const applied = this.applyBox; + let prev: StoreRecord | undefined; + let seq: number | undefined; try { - await appended; + this.applyOp(op, applied); + prev = applied.prev; + seq = this.store.map.get(op.pk)?.seq; + } catch (err) { + // See set() for this defensive path (applyOp's must-not-throw contract). + void appended.done.catch(() => {}); // this op throws here; swallow the frame's rejection + if (group) { + wal.poisonPending(err); + this.groupNoteKey(group, op.pk, applied.prev); + this.rollbackGroup(group, wal, appended.batchId); + this.kickWalRecovery(wal); + } else { + this.restoreGroupKey(op.pk, applied.prev); + } + throw this.markAmbiguous(err); + } + this.groupNoteKey(group, op.pk, prev); + try { + await appended.done; this.stats.evictions++; } catch (e) { - this.restoreKey(op.pk, prev, seq); - throw e; + if (group) this.rollbackGroup(group, wal, appended.batchId); + else this.restoreKey(op.pk, prev, seq); + this.kickWalRecovery(wal); + throw this.markAmbiguous(e); } + this.settleGroup(group, wal, appended.batchId); }; await this.retryOnWalSeal(commit); } @@ -891,6 +1147,11 @@ export class MiniDb { await this.ensureMemoryFor([op]); const commit = async (): Promise => { + // Queue behind any in-place WAL recovery: a write issued after a + // failure waits for the truncate + poison-clear instead of hitting the + // still-poisoned WAL. Null (and zero-cost) when no recovery is running. + const recoveryGate = this.walRecoveryGate(); + if (recoveryGate) await recoveryGate; if (this.indexes.indexes.size && this.indexable(value)) this.indexes.checkUnique(op.pk, value); const frame = encodeFrame({ type: TYPE_SET, key: op.key, value: op.value, meta: op.meta, expireAt: op.expireAt }); const wal = this.wal; @@ -901,17 +1162,48 @@ export class MiniDb { // db.wal yet (appendLoc's offset is only a prediction), so a disk // pointer published now could point past the end of the file. The // pointer is published once `done` resolves (see publishWalRef). If the - // WAL write ultimately fails, roll the store + derived indexes back to - // the pre-op record so in-memory state never diverges from what is - // durable. - const prev = this.applyOp(op); - const seq = this.store.map.get(op.pk)?.seq; + // WAL write ultimately fails, the whole flush group rolls back to the + // pre-group records so in-memory state never diverges from what is + // durable (and from what a reopen replays after the in-place recovery + // truncated the failed tail). + const group = this.groupFor(wal, appended.batchId); + const applied = this.applyBox; + let prev: StoreRecord | undefined; + let seq: number | undefined; + try { + this.applyOp(op, applied); + // Lift the pre-state reference out of the shared scratch before any + // await lets a later op overwrite it. + prev = applied.prev; + seq = this.store.map.get(op.pk)?.seq; + } catch (err) { + // applyOp violated its must-not-throw contract (see its doc — stage 11 + // makes it structural; this try is the defensive layer). An enqueued + // frame (batchId >= 0) is un-acked and must never reach disk: poison + // the WAL exactly like a write failure and roll the group back. A + // never-enqueued frame (batchId < 0, e.g. a seal race) poisons + // nothing — only the partial in-memory mutation needs undoing. + void appended.done.catch(() => {}); // this op throws here; swallow the frame's rejection + if (group) { + wal.poisonPending(err); + this.groupNoteKey(group, op.pk, applied.prev); + this.rollbackGroup(group, wal, appended.batchId); + this.kickWalRecovery(wal); + } else { + this.restoreGroupKey(op.pk, applied.prev); + } + throw this.markAmbiguous(err); + } + this.groupNoteKey(group, op.pk, prev); try { await appended.done; } catch (e) { - this.restoreKey(op.pk, prev, seq); - throw e; + if (group) this.rollbackGroup(group, wal, appended.batchId); + else this.restoreKey(op.pk, prev, seq); + this.kickWalRecovery(wal); + throw this.markAmbiguous(e); } + this.settleGroup(group, wal, appended.batchId); if (this.valueMode === 'disk') { this.publishWalRef( op.pk, @@ -938,15 +1230,41 @@ export class MiniDb { const op = this.prepareDel(key); await this.ensureMemoryFor([op]); 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; + const recoveryGate = this.walRecoveryGate(); + if (recoveryGate) await recoveryGate; + const wal = this.wal; + const appended = wal.appendLoc(encodeFrame({ type: TYPE_DEL, key: op.key })); + const group = this.groupFor(wal, appended.batchId); + const applied = this.applyBox; + let prev: StoreRecord | undefined; + let seq: number | undefined; try { - await appended; + this.applyOp(op, applied); + prev = applied.prev; + seq = this.store.map.get(op.pk)?.seq; + } catch (err) { + // See set() for this defensive path (applyOp's must-not-throw contract). + void appended.done.catch(() => {}); // this op throws here; swallow the frame's rejection + if (group) { + wal.poisonPending(err); + this.groupNoteKey(group, op.pk, applied.prev); + this.rollbackGroup(group, wal, appended.batchId); + this.kickWalRecovery(wal); + } else { + this.restoreGroupKey(op.pk, applied.prev); + } + throw this.markAmbiguous(err); + } + this.groupNoteKey(group, op.pk, prev); + try { + await appended.done; } catch (e) { - this.restoreKey(op.pk, prev, seq); - throw e; + if (group) this.rollbackGroup(group, wal, appended.batchId); + else this.restoreKey(op.pk, prev, seq); + this.kickWalRecovery(wal); + throw this.markAmbiguous(e); } + this.settleGroup(group, wal, appended.batchId); this.maybeAutoCompact(); }; await this.retryOnWalSeal(commit); @@ -963,6 +1281,8 @@ export class MiniDb { await this.ensureMemoryFor(prepared); const commit = async (): Promise => { + const recoveryGate = this.walRecoveryGate(); + if (recoveryGate) await recoveryGate; if (this.indexes.indexes.size) { this.indexes.checkUniqueBatch( prepared.map((o) => ({ @@ -978,15 +1298,37 @@ export class MiniDb { const frame = encodeFrame({ type: TYPE_BATCH, key: Buffer.alloc(0), value: body }); const wal = this.wal; const appended = wal.appendLoc(frame); + const group = this.groupFor(wal, appended.batchId); // Capture each key's pre-batch record (first applyOp per key) so the whole // batch can be rolled back if the WAL write fails, preserving atomicity. const prevs = new Map(); - for (const op of prepared) { - const prev = this.applyOp(op); - if (!prevs.has(op.pk)) prevs.set(op.pk, prev); + const applied = this.applyBox; + let cur: PreparedOp | null = null; + try { + for (const op of prepared) { + cur = op; + this.applyOp(op, applied); + if (!prevs.has(op.pk)) prevs.set(op.pk, applied.prev); + } + } catch (err) { + // See set() for this defensive path (applyOp's must-not-throw + // contract); the op that threw mid-apply has its pre-state in `applied`. + if (cur && !prevs.has(cur.pk)) prevs.set(cur.pk, applied.prev); + void appended.done.catch(() => {}); // this batch throws here; swallow the frame's rejection + if (group) { + wal.poisonPending(err); + for (const [pk, p] of prevs) this.groupNoteKey(group, pk, p); + this.rollbackGroup(group, wal, appended.batchId); + this.kickWalRecovery(wal); + } else { + for (const [pk, p] of prevs) this.restoreGroupKey(pk, p); + } + throw this.markAmbiguous(err); } + for (const [pk, p] of prevs) this.groupNoteKey(group, pk, p); // 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 + // batch's last op deleted the key): guards both the per-op rollback + // (frames that never entered a group, e.g. a seal race) 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); @@ -1009,9 +1351,12 @@ export class MiniDb { try { await appended.done; } catch (e) { - for (const [pk, prev] of prevs) this.restoreKey(pk, prev, seqs.get(pk)); - throw e; + if (group) this.rollbackGroup(group, wal, appended.batchId); + else for (const [pk, prev] of prevs) this.restoreKey(pk, prev, seqs.get(pk)); + this.kickWalRecovery(wal); + throw this.markAmbiguous(e); } + this.settleGroup(group, wal, appended.batchId); for (const [pk, { op, loc, seq }] of lastSet) { this.publishWalRef(pk, wal, seq, loc, op.expireAt, op.dtNorm); } @@ -1049,11 +1394,20 @@ export class MiniDb { return { type: TYPE_DEL, key: toBuf(key), value: null, meta: null, expireAt: 0, dtNorm: null, pk: this.pk(key), valueDecoded: undefined }; } - /** Apply a prepared op to the store + derived indexes. Returns the key's - * pre-op logical record so the caller can roll back on WAL failure. */ - private applyOp(op: PreparedOp): StoreRecord | undefined { + /** Apply a prepared op to the store + derived indexes, writing the key's + * pre-op logical record into `out.prev` so the caller can roll back (or + * poison + group-rollback) on failure. `out.prev` is assigned before any + * mutation, so it is valid even when the apply throws. + * + * CONTRACT: applyOp must not throw — every fallible input validation + * belongs to the prepare phase (stage 11 moves unique checks, the + * tokenizer and canonical extraction there, making this structural). + * Until then the commit bodies wrap the call in a defensive try that + * converts a throw into a WAL poison + group rollback + in-place recovery; + * that path is not the normal one. */ + private applyOp(op: PreparedOp, out: { prev: StoreRecord | undefined }): void { const oldBuf = this.store.get(op.pk); - const prev = oldBuf !== undefined ? this.store.map.get(op.pk) : undefined; + out.prev = oldBuf !== undefined ? this.store.map.get(op.pk) : undefined; const oldDoc = oldBuf !== undefined ? this.decode(oldBuf) : undefined; if (op.type === TYPE_SET) { // Always applied as an in-memory ref; in valueMode 'disk' the caller @@ -1081,7 +1435,6 @@ export class MiniDb { } } if (op.type === TYPE_SET) this.touchAccess(op.pk); - return prev; } /** Roll a key back to its pre-op record across the store and every derived @@ -1091,10 +1444,20 @@ export class MiniDb { * 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. */ + * over it would wipe state that is already durable. This per-op path covers + * frames that never entered a flush group (batchId < 0: a seal/rotation + * race) and cross-group interleaves with retryOnWalSeal retries; grouped + * failures roll back via rollbackGroup instead. */ 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; + this.restoreGroupKey(pk, prev); + } + + /** The unguarded restore core behind restoreKey and the flush-group + * rollback: put the key back to `prev` across the store and every derived + * index (TTL/access/dt/secondary/compound/text). */ + private restoreGroupKey(pk: string, prev: StoreRecord | undefined): void { if (this.indexes.indexes.size) this.indexes.remove(pk, undefined); for (const ti of this.text.values()) ti.remove(pk); this.dt.del(pk); @@ -1197,21 +1560,45 @@ export class MiniDb { const keyBuf = toBuf(key); const frame = encodeFrame({ type: TYPE_SET, key: keyBuf, value: curValue, meta, expireAt }); const commit = async (): Promise => { + const recoveryGate = this.walRecoveryGate(); + if (recoveryGate) await recoveryGate; const wal = this.wal; const appended = wal.appendLoc(frame); + const group = this.groupFor(wal, appended.batchId); // 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; + let seq: number | undefined; + try { + this.store.set(k, curValue, expireAt, cur.dt); + seq = this.store.map.get(k)?.seq; + } catch (err) { + // The in-memory mutation failed: an enqueued frame poisons the WAL + // exactly like a write failure and rolls the group back; a + // never-enqueued one only needs the per-op undo (see set()). + void appended.done.catch(() => {}); // this op throws here; swallow the frame's rejection + if (group) { + wal.poisonPending(err); + this.groupNoteKey(group, k, prev); + this.rollbackGroup(group, wal, appended.batchId); + this.kickWalRecovery(wal); + } else { + this.restoreGroupKey(k, prev); + } + throw this.markAmbiguous(err); + } + this.groupNoteKey(group, k, prev); try { await appended.done; } catch (e) { - this.restoreKey(k, prev, seq); - throw e; + if (group) this.rollbackGroup(group, wal, appended.batchId); + else this.restoreKey(k, prev, seq); + this.kickWalRecovery(wal); + throw this.markAmbiguous(e); } + this.settleGroup(group, wal, appended.batchId); if (this.valueMode === 'disk') { this.publishWalRef( k, @@ -1755,6 +2142,14 @@ export class MiniDb { releaseRotation = resolve; }); try { + // Wait out any in-flight WAL recovery before fencing: a WAL failure + // racing the backup leaves un-acked bytes in db.wal that the recovery + // is about to truncate away, and the fence must land on the recovered + // (possibly truncated) file rather than copying bytes that are about + // to disappear. A persistent failure keeps the WAL poisoned and the + // flush below then rejects the backup. (Stage 12 rewrites backup with + // OpTracker; this is the minimal guard.) + await this.walRecoveryChain; await this.wal.flush(); await fs.mkdir(destDir, { recursive: true }); const files = await this.persistentFiles(); @@ -1848,10 +2243,19 @@ export class MiniDb { if (this.closed) return; if (this.compacting) await this._compactDone; this.closed = true; + // Let in-flight WAL failures and their kicked recoveries settle before + // and after closing the WAL: a poisoned/failing close would otherwise + // leave an un-acked tail in db.wal that a reopen replays as ghost writes. + // Kicks arrive in op rejection microtasks that can be scheduled behind + // this close (and the WAL's own final flush can drive a queued failing + // batch, kicking one more recovery), so wait for the chain to be IDLE in + // a loop instead of awaiting one snapshot of it. + while (!this.walRecoveryIdle) await this.walRecoveryChain; for (const ti of this.text.values()) ti.close(); this.store.close(); this.valueReader?.close(); await this.wal.close(); + while (!this.walRecoveryIdle) await this.walRecoveryChain; if (this.lock) { await this.lock.release(); this.lock = null; @@ -1863,5 +2267,6 @@ export class MiniDb { } private ensureWritable(): void { if (this.readOnly) throw new Error('MiniDb is open in read-only mode'); + if (this.writeDisabled) throw this.writeDisabledError(); } } diff --git a/packages/minidb/src/wal.ts b/packages/minidb/src/wal.ts index 2d5aae08e7d..31c9a3560a8 100644 --- a/packages/minidb/src/wal.ts +++ b/packages/minidb/src/wal.ts @@ -12,6 +12,16 @@ // Group commit: all append() calls within a tick are coalesced into a single // writev(2) syscall on the next macrotask. Only one flush is ever in flight, so // frames reach disk strictly in append order (single-writer, like SQLite WAL). +// +// Write-failure semantics (the commit point): a writev or write-path fsync +// failure in a flush POISONS the WAL. The poison records the failed batch's +// first predicted offset — every byte at/after it may have reached the file +// without being acknowledged. While poisoned, appends reject with +// 'WAL_POISONED', flush() throws, sync() is a no-op, and close() skips its +// final flush. The owner (MiniDb) recovers in place: truncate the file to the +// poison offset (removing exactly the un-acked bytes), refreshSize(), then +// clearPoison(). A background everysec sync failure never poisons: it rejects +// no write and is observable only in stats (stage-1 semantics). import fs from 'node:fs/promises'; import type { FileHandle } from 'node:fs/promises'; @@ -26,14 +36,27 @@ interface PendingWrite { reject: (err: unknown) => void; } +/** The failure that poisoned the WAL plus the offset the owner must truncate + * the file to for in-place recovery: every acknowledged frame sits in + * earlier, successful batches, so truncating here removes exactly the + * un-acked bytes. */ +export interface WalPoison { + failedAtOffset: number; + error: unknown; +} + /** Cumulative WAL counters, owned by MiniDb so they survive WAL rotation * during compaction (which replaces the WAL). */ export interface WalStats { walBytesWritten: number; /** Successful fsyncs (write-path 'always', background 'everysec', close). */ walFsyncs: number; + /** Failed writev-class attempts on the write path. Each one poisons the WAL + * (see the header) and triggers the owner's in-place recovery. */ + walWriteErrors: number; /** Failed fsync attempts. A background everysec failure does not reject any - * write — it is observable only here and via lastWalFsyncError. */ + * write — it is observable only here and via lastWalFsyncError. A + * write-path ('always') failure rejects its batch and poisons the WAL. */ walFsyncErrors: number; /** Sticky copy of the most recent fsync failure (never cleared on success). */ lastWalFsyncError: unknown; @@ -73,6 +96,15 @@ export class WAL { * old WAL so no append can slip between the final flush and close(): any * frame that will ever land in the old file is durable after one flush. */ private sealed = false; + /** Set by the first failed flush (or poisonPending): the WAL stops accepting + * appends until the owner recovers it in place (see the header). Distinct + * from `sealed`: seal is a normal compaction rotation (rejections are + * retried against the new WAL), poison is a fault state (hard failures). */ + private poisoned: WalPoison | null = null; + /** Id of the batch the next drain will carry. appendLoc stamps each frame + * with it so the owner can track per-flush-group pre-state; flushBatch + * increments it at drain time, so same-tick appends always share an id. */ + private nextBatchId = 1; private timer: ReturnType | null = null; private closed = false; private readonly stats: WalStats | null; @@ -128,22 +160,29 @@ export class WAL { this.sealed = true; } - /** Append one frame and return its predicted absolute file offset. The offset - * is known synchronously because frames are flushed strictly in append order. + /** Append one frame and return its predicted absolute file offset plus the + * id of the flush batch that will carry it. The offset is known + * synchronously because frames are flushed strictly in append order. * NOTE: the frame's bytes are NOT in the file yet — they sit in the in-memory * queue until a later writev lands — so the offset must not be published as a * disk value pointer before `done` resolves: a synchronous positioned read in - * that window would hit a short read past the current end of the file. */ - appendLoc(frame: Buffer): { offset: number; done: Promise } { - if (this.closed) return { offset: -1, done: Promise.reject(new Error('WAL is closed')) }; + * that window would hit a short read past the current end of the file. + * batchId is -1 for frames that never entered a group (immediate + * rejections: closed/poisoned/sealed/invalid). */ + appendLoc(frame: Buffer): { offset: number; batchId: number; done: Promise } { + if (this.closed) return { offset: -1, batchId: -1, done: Promise.reject(new Error('WAL is closed')) }; + if (this.poisoned) return { offset: -1, batchId: -1, done: Promise.reject(this.poisonError()) }; if (this.sealed) { const err = new Error('WAL is sealed by a compaction rotation; retry against the new WAL'); (err as { code?: string }).code = 'WAL_SEALED'; - return { offset: -1, done: Promise.reject(err) }; + return { offset: -1, batchId: -1, done: Promise.reject(err) }; + } + if (!Buffer.isBuffer(frame)) { + return { offset: -1, batchId: -1, done: Promise.reject(new TypeError('frame must be a Buffer')) }; } - if (!Buffer.isBuffer(frame)) return { offset: -1, done: Promise.reject(new TypeError('frame must be a Buffer')) }; const offset = this.nextOffset; this.nextOffset += frame.length; + const batchId = this.nextBatchId; const done = new Promise((resolve, reject) => { this.queue.push({ buf: frame, resolve, reject }); this.queuedBytes += frame.length; @@ -158,7 +197,7 @@ export class WAL { setImmediate(() => { void this.flushBatch(); }); } }); - return { offset, done }; + return { offset, batchId, done }; } /** Append one frame. Resolves once written to the OS page cache; for @@ -171,6 +210,7 @@ export class WAL { this.scheduled = false; if (this.flushing) return this.inflight; if (this.queue.length === 0) return null; + if (this.poisoned) return null; // defensive: the queue stays empty while poisoned this.flushing = true; const run = async () => { @@ -178,17 +218,23 @@ export class WAL { this.queue = []; const batchBytes = this.queuedBytes; this.queuedBytes = 0; + this.nextBatchId++; if (this.stats) { this.stats.walQueuedBytes -= batchBytes; this.stats.walGroupCommits++; this.stats.walGroupCommitFrames += batch.length; } + // The predicted offset of the batch's first frame: the owner's recovery + // truncation point if this flush fails. Captured now — later appends + // move nextOffset forward. + const batchStartOffset = this.nextOffset - batchBytes; // writev(2) may short-write (signal interruption, RLIMIT_FSIZE, …). Retry // until the whole batch lands so a partial write never rejects frames // whose in-memory side effects were already applied. Only a real I/O // error (or zero progress) rejects the batch. let bufs = batch.map((b) => b.buf); let off = 0; // byte offset within bufs[0] + let failure: unknown = null; try { while (bufs.length > 0) { const toWrite = off > 0 ? [bufs[0]!.subarray(off), ...bufs.slice(1)] : bufs; @@ -212,23 +258,111 @@ export class WAL { } } } - if (this.policy === 'always') await this.sync(); - for (const b of batch) b.resolve(); } catch (err) { - for (const b of batch) b.reject(err); - } finally { - this.flushing = false; - this.inflight = null; - if (this.queue.length > 0 && !this.closed) { - this.scheduled = true; - setImmediate(() => { void this.flushBatch(); }); + failure = err; + if (this.stats) this.stats.walWriteErrors++; + } + if (!failure && this.policy === 'always') { + // sync() records walFsyncErrors itself. The batch's bytes are in the + // page cache but unacknowledged, so an fsync failure poisons exactly + // like a write failure. + try { + await this.sync(); + } catch (err) { + failure = err; } } + if (failure) { + this.poisonWith(batchStartOffset, failure); + // Reject the still-queued frames first (reverse enqueue order: newest + // flush group first, so the owner's group rollback unwinds + // newest-first), then this batch's frames with the original error — + // the oldest group's rollback must run last. + const perr = this.poisonError(); + this.rejectQueued(perr); + for (const b of batch) b.reject(failure); + } else { + for (const b of batch) b.resolve(); + } + this.flushing = false; + this.inflight = null; + if (this.queue.length > 0 && !this.closed && !this.poisoned) { + this.scheduled = true; + setImmediate(() => { void this.flushBatch(); }); + } }; this.inflight = run(); return this.inflight; } + /** Non-null while the WAL is poisoned by a failed flush (or poisonPending): + * the failure and the owner's in-place recovery truncation point. */ + get poison(): WalPoison | null { + return this.poisoned; + } + + /** Clear the poison after the owner truncated the file to failedAtOffset + * and re-synced size bookkeeping via refreshSize(): the write path resumes. */ + clearPoison(): void { + this.poisoned = null; + } + + /** Poison the WAL from outside the flush path — used when a post-append + * in-memory apply fails (MiniDb's applyOp contract violation): every queued + * frame is un-acked and must never reach disk, exactly as if the flush + * carrying it had failed. The truncation point is the start of the queued + * region; an in-flight batch keeps its own fate. */ + poisonPending(error: unknown): void { + if (this.closed) return; + this.poisonWith(this.nextOffset - this.queuedBytes, error); + this.rejectQueued(this.poisonError()); + } + + private poisonWith(failedAtOffset: number, error: unknown): void { + if (this.poisoned) { + // Already poisoned (poisonPending raced a flush failure, or a second + // batch failed): widen the truncation point to also cover the older + // un-acked bytes, never narrowing it. + this.poisoned.failedAtOffset = Math.min(this.poisoned.failedAtOffset, failedAtOffset); + return; + } + this.poisoned = { failedAtOffset, error }; + } + + /** Reject every queued frame with `perr` in reverse enqueue order (see the + * flushBatch failure path for why newest-first matters). */ + private rejectQueued(perr: Error): void { + if (this.queue.length === 0) return; + for (let i = this.queue.length - 1; i >= 0; i--) this.queue[i]!.reject(perr); + if (this.stats) this.stats.walQueuedBytes -= this.queuedBytes; + this.queue = []; + this.queuedBytes = 0; + } + + /** The rejection appends and flush() see while poisoned. 'WAL_POISONED' is + * deliberately distinct from 'WAL_SEALED': seal is a normal rotation + * (callers retry against the new WAL), poison is a hard failure the caller + * must treat as ambiguous. */ + private poisonError(): Error { + const cause = this.poisoned?.error; + const err = new Error( + `WAL is poisoned by a previous write failure: ${cause instanceof Error ? cause.message : String(cause)}`, + ); + (err as { code?: string }).code = 'WAL_POISONED'; + (err as { cause?: unknown }).cause = cause; + return err; + } + + /** Await the currently in-flight flush (if any) without scheduling new + * ones. Used by the owner's in-place recovery: a poisonPending truncation + * point is predicted against the in-flight batch fully landing, so the + * truncate must wait for that batch to settle — on success the point lies + * beyond its bytes, on failure poisonWith already widened the point to + * cover them. Never rejects (flushBatch settles its frames itself). */ + async whenIdle(): Promise { + await this.inflight; + } + /** Re-sync size/nextOffset with the file on disk. Required after recovery * truncates a torn WAL tail: the truncate happens on the path behind this * WAL's back, and stale bookkeeping would otherwise make later appends @@ -244,9 +378,11 @@ export class WAL { /** Force an fsync of the underlying file. On success the durability * watermark advances to the write generation sampled when the fsync was * issued; a failure is recorded (walFsyncErrors + sticky lastWalFsyncError) - * and rethrown, and the WAL stays dirty. */ + * and rethrown, and the WAL stays dirty. A no-op while poisoned: the tail + * is about to be truncated by the owner's recovery, so syncing it reports + * nothing actionable. */ async sync(): Promise { - if (!this.fh) return; + if (!this.fh || this.poisoned) return; const gen = this.writeGen; try { await this.fh.sync(); @@ -267,9 +403,14 @@ export class WAL { /** Flush buffered frames to the OS (without necessarily fsync'ing). * Loops until everything queued up to now has been flushed: an earlier * version only awaited the in-flight batch and could return while newer - * frames were still queued, which let compaction truncate un-flushed data. */ + * frames were still queued, which let compaction truncate un-flushed data. + * Throws the poison error when the WAL is (or becomes) poisoned: a caller + * that needs a durable fence (compaction, backup) must fail there instead + * of building on a tail the owner's recovery is about to truncate. */ async flush(): Promise { - while (this.queue.length > 0 || this.inflight) { + for (;;) { + if (this.poisoned) throw this.poisonError(); + if (this.queue.length === 0 && !this.inflight) return; if (this.inflight) await this.inflight; if (this.queue.length > 0) await this.flushBatch(); } @@ -287,10 +428,22 @@ export class WAL { // fd (a compaction rotation recovering from a failed close swaps in a // fresh WAL on the same path and abandons this handle). An fh.close() // error itself is swallowed: with the fsync above already durable there - // is nothing actionable left to report. + // is nothing actionable left to report. While poisoned the final flush is + // skipped entirely — the queue was drained with rejections and the tail + // belongs to the owner's recovery (or the write-disabled state). A flush + // that fails HERE (the final flush itself drives a queued failing batch) + // poisons the WAL the same way and is swallowed identically: the frames + // were rejected and the owner's recovery — which MiniDb.close() awaits + // right after this — owns the tail. try { - await this.flush(); - if (this.fh) await this.sync(); + if (!this.poisoned) { + try { + await this.flush(); + } catch (err) { + if (!this.poisoned) throw err; + } + if (this.fh) await this.sync(); + } } finally { const fh = this.fh; this.fh = null; diff --git a/packages/minidb/test/compaction-fault.test.ts b/packages/minidb/test/compaction-fault.test.ts index bb544f84561..65b1f3ef7bb 100644 --- a/packages/minidb/test/compaction-fault.test.ts +++ b/packages/minidb/test/compaction-fault.test.ts @@ -369,3 +369,85 @@ test('a compaction whose onCompacted hook throws counts as a compactError, not a await fs.rm(dir, { recursive: true, force: true }); } }); + +test('a WAL poison during the snapshot phase aborts this compaction; the next compaction succeeds and data stays consistent', async () => { + // Barrier-driven: the snapshot phase parks on `releaseSnapshot`, and the + // in-place recovery's fs.truncate parks on `releaseTruncate`, so the + // compaction deterministically hits the poisoned WAL (its pre-copy flush + // throws) before the recovery can clear the poison. + let snapshotEntered!: () => void; + let releaseSnapshot!: () => void; + const entered = new Promise((r) => (snapshotEntered = r)); + const releaseS = new Promise((r) => (releaseSnapshot = r)); + vi.doMock('../src/snapshot.js', async () => { + const real = await vi.importActual('../src/snapshot.js'); + return { + ...real, + writeSnapshot: async (...args: Parameters) => { + snapshotEntered(); + await releaseS; + return real.writeSnapshot(...args); + }, + }; + }); + let releaseTruncate!: () => void; + const truncateGate = new Promise((r) => (releaseTruncate = r)); + const truncate = async (p: PathLike, len?: number): Promise => { + await truncateGate; + return fs.truncate(p, len); + }; + const mocked = { ...fs, truncate }; + vi.doMock('node:fs/promises', () => ({ ...mocked, default: mocked })); + + const { MiniDb } = await import('../src/index.js'); + const dir = await tmpDir(); + try { + let db = await MiniDb.open({ dir, valueCodec: 'string', fsyncPolicy: 'no', compactThresholdBytes: 1 << 30 }); + const N = 50; + for (let i = 0; i < N; i++) await db.set(`k${i}`, `v${i}`); + + const compactPromise = db.compact(); + await entered; // the compaction is parked inside the snapshot phase now + + // Poison the WAL with a one-shot writev failure. + const fh = (db as unknown as { wal: { fh: { writev: (...a: unknown[]) => Promise } } }).wal.fh; + const orig = fh.writev.bind(fh); + let boom = true; + fh.writev = async (...a: unknown[]) => { + if (boom) { + boom = false; + throw new Error('injected WAL failure'); + } + return orig(...a); + }; + await assert.rejects(db.set('bad', 'x'), /injected WAL failure/); + + // The compaction's next flush hits the poison and aborts the whole round + // through its existing catch (the recovery's truncate is still gated). + releaseSnapshot(); + await assert.rejects(compactPromise, /poisoned/); + assert.equal(db.stats.compactions, 0); + assert.equal(db.stats.compactErrors, 1); + + // Let the in-place recovery run; later writes queue behind it. Then the + // next compaction round succeeds on the truncated WAL. + releaseTruncate(); + await db.set('post', 'ok'); + await db.compact(); + assert.equal(db.stats.compactions, 1); + assert.equal(db.stats.compactErrors, 1); + assert.equal(db.get('bad'), undefined, 'the rejected write never reached the snapshot or the WAL'); + assert.equal(db.get('k0'), 'v0'); + assert.equal(db.get('post'), 'ok'); + await db.close(); + + db = await MiniDb.open({ dir, valueCodec: 'string' }); + assert.equal(db.size, N + 1); + assert.equal(db.get('bad'), undefined); + assert.equal(db.get(`k${N - 1}`), `v${N - 1}`); + assert.equal(db.get('post'), 'ok'); + await db.close(); + } finally { + await fs.rm(dir, { recursive: true, force: true }); + } +}); diff --git a/packages/minidb/test/review-round2.test.ts b/packages/minidb/test/review-round2.test.ts index fead79b80a8..e3d5c4a9763 100644 --- a/packages/minidb/test/review-round2.test.ts +++ b/packages/minidb/test/review-round2.test.ts @@ -377,3 +377,465 @@ test('open failure on corrupt index JSON releases the lock', async () => { await db2.close(); await fs.rm(dir, { recursive: true, force: true }); }); + +// --- WAL poison / flush-group rollback (write-failure semantics) ------------ +// +// Fault-injection assertions for the commit point: a failed WAL write/fsync +// poisons the WAL, the failed frames are physically truncated away (in-place +// recovery), every touched flush group rolls back to its pre-group records, +// and later writes queue behind the recovery instead of hitting the poisoned +// WAL. All synchronization is barrier-driven (in-flight writev gates, +// condition polling, the recovery gate inside every commit) — no fixed sleeps +// on the assertion path. + +type WalFh = { writev: (...a: unknown[]) => Promise; sync: () => Promise }; + +function walFh(db: MiniDb): WalFh { + return (db as unknown as { wal: { fh: WalFh } }).wal.fh; +} + +/** One-shot writev failure on the live WAL's append handle. */ +function failNextWritev(db: MiniDb): void { + const fh = walFh(db); + const orig = fh.writev.bind(fh); + let fail = true; + fh.writev = async (...a: unknown[]) => { + if (fail) { + fail = false; + throw new Error('injected WAL failure'); + } + return orig(...a); + }; +} + +/** Condition-driven barrier (not a fixed sleep): poll until `cond` holds. */ +async function waitFor(cond: () => boolean, what: string, timeoutMs = 5000): Promise { + const t0 = Date.now(); + while (!cond()) { + if (Date.now() - t0 > timeoutMs) throw new Error(`timeout waiting for ${what}`); + await new Promise((r) => setTimeout(r, 2)); + } +} + +const MEM_OPTS = { valueCodec: 'string' as const, fsyncPolicy: 'no' as const, activeExpireIntervalMs: 0 }; + +test('WAL poison: a failed writev is truncated away — the rejected key never reappears and later writes stay consistent (disk mode)', async () => { + const dir = await tmpDir(); + let db = await MiniDb.open({ dir, ...MEM_OPTS, valueMode: 'disk' }); + failNextWritev(db as MiniDb); + + const err: Error = await db.set('failed', 'not-written').then( + () => { + throw new Error('expected the set to reject'); + }, + (e) => e as Error, + ); + assert.match(String(err), /injected WAL failure/); + assert.equal((err as { ambiguous?: boolean }).ambiguous, true, 'a failure past the commit point is marked ambiguous'); + assert.equal(db.get('failed'), undefined, 'the group rollback hides the rejected write in-memory'); + assert.equal(db.stats.walWriteErrors, 1, 'writev-class failure counted'); + assert.equal(db.stats.walFsyncErrors, 0); + + // The next write queues behind the in-place recovery, then lands at the + // real EOF: disk-mode get() must not short-read a stale predicted offset. + await db.set('ok', 'persisted'); + assert.equal(db.get('ok'), 'persisted'); + await db.close(); + + db = await MiniDb.open({ dir, ...MEM_OPTS, valueMode: 'disk' }); + assert.equal(db.get('failed'), undefined, 'rejected write must not reappear after reopen'); + assert.equal(db.get('ok'), 'persisted'); + await db.close(); + await fs.rm(dir, { recursive: true, force: true }); +}); + +test('WAL poison: a half-written group is fully revoked — in-memory and reopen agree that both keys are absent', async () => { + const dir = await tmpDir(); + let db = await MiniDb.open({ dir, ...MEM_OPTS }); + const fh = walFh(db as MiniDb); + const orig = fh.writev.bind(fh); + let calls = 0; + fh.writev = async (bufs: unknown[]) => { + calls++; + // Both sets coalesce into one group commit: land only the first frame, + // then fail the remainder of the batch. + if (calls === 1) return orig([bufs[0]]); + throw new Error('injected after first frame'); + }; + + const results = await Promise.allSettled([db.set('first', 'should-fail'), db.set('second', 'should-fail')]); + assert.deepEqual( + results.map((r) => r.status), + ['rejected', 'rejected'], + ); + assert.equal(db.get('first'), undefined, 'in-memory state must match what reopen replays'); + assert.equal(db.get('second'), undefined); + await db.close(); + + db = await MiniDb.open({ dir, ...MEM_OPTS }); + assert.equal(db.get('first'), undefined, 'the half-written frame was truncated by the in-place recovery'); + assert.equal(db.get('second'), undefined); + await db.close(); + await fs.rm(dir, { recursive: true, force: true }); +}); + +test("WAL poison: an fsync failure (fsyncPolicy 'always') revokes the rejected write — no reappears after reopen", async () => { + const dir = await tmpDir(); + let db = await MiniDb.open({ dir, valueCodec: 'string', fsyncPolicy: 'always', activeExpireIntervalMs: 0 }); + const fh = walFh(db as MiniDb); + const origSync = fh.sync.bind(fh); + let fail = true; + fh.sync = async () => { + if (fail) { + fail = false; + throw new Error('injected fsync failure'); + } + return origSync(); + }; + + const err: Error = await db.set('rejected', 'reappears').then( + () => { + throw new Error('expected the set to reject'); + }, + (e) => e as Error, + ); + assert.match(String(err), /injected fsync failure/); + assert.equal((err as { ambiguous?: boolean }).ambiguous, true); + assert.equal(db.get('rejected'), undefined); + assert.equal(db.stats.walFsyncErrors, 1, 'fsync-class failure counted separately'); + assert.equal(db.stats.walWriteErrors, 0, 'not a writev-class failure'); + await db.close(); + + db = await MiniDb.open({ dir, valueCodec: 'string', fsyncPolicy: 'always', activeExpireIntervalMs: 0 }); + assert.equal(db.get('rejected'), undefined, 'rejected write must not reappear after reopen'); + await db.close(); + await fs.rm(dir, { recursive: true, force: true }); +}); + +test('WAL poison: same-key ops failing in one group restore the pre-group value; a cross-group failure keeps the committed value', async () => { + // Same group: A and B share the failed batch — the key rolls back to 'old' + // (never the never-committed intermediate A). + const dir1 = await tmpDir(); + let db = await MiniDb.open({ dir: dir1, ...MEM_OPTS }); + await db.set('k', 'old'); + failNextWritev(db as MiniDb); + const results = await Promise.allSettled([db.set('k', 'A'), db.set('k', 'B')]); + assert.deepEqual( + results.map((r) => r.status), + ['rejected', 'rejected'], + ); + assert.equal(db.get('k'), 'old', 'group rollback restores the pre-group value, not an intermediate one'); + await db.close(); + db = await MiniDb.open({ dir: dir1, ...MEM_OPTS }); + assert.equal(db.get('k'), 'old', 'reopen agrees with the in-memory state'); + await db.close(); + await fs.rm(dir1, { recursive: true, force: true }); + + // Different groups: A commits in its own batch, B's later batch fails — the + // key stays 'A' in-memory and after reopen. + const dir2 = await tmpDir(); + db = await MiniDb.open({ dir: dir2, ...MEM_OPTS }); + await db.set('k', 'A'); + failNextWritev(db as MiniDb); + await assert.rejects(db.set('k', 'B'), /injected WAL failure/); + assert.equal(db.get('k'), 'A', 'a failed later group must not roll back a committed value'); + await db.close(); + db = await MiniDb.open({ dir: dir2, ...MEM_OPTS }); + assert.equal(db.get('k'), 'A'); + await db.close(); + await fs.rm(dir2, { recursive: true, force: true }); +}); + +test('WAL poison: an applyOp contract violation poisons the WAL and rolls the group back — no half-commit anywhere', async () => { + const dir = await tmpDir(); + let db = await MiniDb.open<{ t: string }>({ dir, valueCodec: 'json', fsyncPolicy: 'no', activeExpireIntervalMs: 0 }); + await db.createTextIndex('ft', { fields: ['t'] }); + const ti = (db as unknown as { text: Map void }> }).text.get('ft')!; + const origAdd = ti.add.bind(ti); + let boom = true; + ti.add = (k: string, v: unknown) => { + if (boom) { + boom = false; + throw new Error('injected apply failure'); + } + origAdd(k, v); + }; + + const err: Error = await db.set('doc', { t: 'hello world' }).then( + () => { + throw new Error('expected the set to reject'); + }, + (e) => e as Error, + ); + assert.match(String(err), /injected apply failure/); + assert.equal((err as { ambiguous?: boolean }).ambiguous, true); + assert.equal(db.get('doc'), undefined, 'no half-commit in the store'); + assert.deepEqual( + db.search('ft', 'hello'), + [], + 'no half-commit in the text index', + ); + + // The defensive poison triggers the same in-place recovery as a write + // failure; later writes land normally. + await db.set('after', { t: 'fine' }); + assert.equal(db.get('after')?.t, 'fine'); + await db.close(); + + db = await MiniDb.open<{ t: string }>({ dir, valueCodec: 'json', fsyncPolicy: 'no', activeExpireIntervalMs: 0 }); + assert.equal(db.get('doc'), undefined, 'the half-applied write never reached disk'); + assert.equal(db.get('after')?.t, 'fine'); + await db.close(); + await fs.rm(dir, { recursive: true, force: true }); +}); + +test('WAL poison: when the recovery truncate fails the instance is write-disabled but stays readable and closable', async () => { + const dir = await tmpDir(); + const db = await MiniDb.open({ dir, ...MEM_OPTS }); + await db.set('k', 'v'); + failNextWritev(db as MiniDb); + + const origTruncate = fs.truncate; + (fs as unknown as { truncate: unknown }).truncate = async () => { + throw new Error('injected truncate failure'); + }; + try { + await assert.rejects(db.set('bad', 'x'), /injected WAL failure/); + await waitFor(() => db.writeDisabled !== null, 'writeDisabled to be set'); + } finally { + (fs as unknown as { truncate: unknown }).truncate = origTruncate; + } + assert.match(String(db.writeDisabled), /injected truncate failure/); + + await assert.rejects(db.set('more', 'x'), /writes are disabled/); + await assert.rejects(db.batch([{ op: 'set', key: 'b2', value: 'x' }]), /writes are disabled/); + assert.equal(db.get('k'), 'v', 'reads keep working'); + await db.close(); // must not throw + await fs.rm(dir, { recursive: true, force: true }); +}); + +test('everysec background sync failure neither poisons the WAL nor rejects writes (stage-1 semantics regression)', async () => { + const dir = await tmpDir(); + const db = await MiniDb.open({ dir, valueCodec: 'string', fsyncPolicy: 'everysec', syncIntervalMs: 10, activeExpireIntervalMs: 0 }); + const fh = walFh(db as MiniDb); + const origSync = fh.sync.bind(fh); + const boom = new Error('injected background fsync failure'); + fh.sync = () => Promise.reject(boom); + try { + // Writes are acknowledged from the page cache: the failing background + // fsync never rejects them. + await db.set('k', 'v'); + await waitFor(() => db.stats.walFsyncErrors >= 1, 'walFsyncErrors to be counted'); + assert.equal(db.stats.lastWalFsyncError, boom, 'sticky error is observable'); + assert.equal(db.wal.poison, null, 'a background sync failure must not poison the WAL'); + await db.set('k2', 'v2'); + assert.equal(db.get('k'), 'v'); + assert.equal(db.get('k2'), 'v2'); + } finally { + fh.sync = origSync; // let close()'s final sync succeed + } + await db.close(); + await fs.rm(dir, { recursive: true, force: true }); +}); + +test('WAL poison: an applyOp violation queued behind an in-flight batch still lets that batch commit', async () => { + // The in-flight batch (op A) must keep its own fate when a later op's + // applyOp violation poisons the pending queue: A commits, the poisoned op + // is revoked, and the recovery truncates nothing of A's bytes (the + // truncation point is the queued region's start, applied only after the + // in-flight batch settled — no zero-extended gap on reopen). + const dir = await tmpDir(); + let db = await MiniDb.open<{ t: string }>({ dir, valueCodec: 'json', fsyncPolicy: 'no', activeExpireIntervalMs: 0 }); + await db.createTextIndex('ft', { fields: ['t'] }); + + const fh = walFh(db as MiniDb); + const orig = fh.writev.bind(fh); + let releaseWritev!: () => void; + const writevGate = new Promise((r) => (releaseWritev = r)); + let writevCalls = 0; + fh.writev = async (...a: unknown[]) => { + writevCalls++; + if (writevCalls === 1) await writevGate; // park A's batch mid-flight + return orig(...a); + }; + + const opA = db.set('a', { t: 'first' }); + await waitFor(() => writevCalls === 1, "op A's writev to be in flight"); + + const ti = (db as unknown as { text: Map void }> }).text.get('ft')!; + const origAdd = ti.add.bind(ti); + let boom = true; + ti.add = (k: string, v: unknown) => { + if (boom) { + boom = false; + throw new Error('injected apply failure'); + } + origAdd(k, v); + }; + await assert.rejects(db.set('b', { t: 'second' }), /injected apply failure/); + assert.ok(db.wal.poison, 'the apply violation poisoned the pending queue'); + + releaseWritev(); + await opA; // A's batch was in flight before the poison: it commits + await waitFor(() => db.wal.poison === null, 'the in-place recovery to finish'); + + assert.equal(db.get('a')?.t, 'first'); + assert.equal(db.get('b'), undefined); + await db.set('c', { t: 'third' }); + await db.close(); + + db = await MiniDb.open<{ t: string }>({ dir, valueCodec: 'json', fsyncPolicy: 'no', activeExpireIntervalMs: 0 }); + assert.equal(db.get('a')?.t, 'first', 'the committed in-flight batch survives reopen'); + assert.equal(db.get('b'), undefined); + assert.equal(db.get('c')?.t, 'third'); + await db.close(); + await fs.rm(dir, { recursive: true, force: true }); +}); + +test('WAL poison: an applyOp violation on a never-enqueued frame (sealed WAL) does not poison and rolls back per-op', async () => { + // During a compaction rotation the old WAL is sealed: an op's appendLoc is + // rejected with WAL_SEALED and the frame is NEVER enqueued. An applyOp + // violation on such an op must not poison the WAL (the rotation can still + // commit a new, shorter file at db.wal — a poison recorded with the old + // file's coordinates would corrupt it during the in-place recovery); only + // the partial in-memory mutation needs undoing. + const dir = await tmpDir(); + let db = await MiniDb.open<{ t: string }>({ dir, valueCodec: 'json', fsyncPolicy: 'no', activeExpireIntervalMs: 0 }); + await db.createTextIndex('ft', { fields: ['t'] }); + await db.set('old', { t: 'keep' }); + + const ti = (db as unknown as { text: Map void }> }).text.get('ft')!; + ti.add = () => { + throw new Error('injected apply failure'); + }; + // Simulate the rotation seal (what db.wal.seal() does inside compaction). + db.wal.seal(); + + const err: Error = await db.set('bad', { t: 'x' }).then( + () => { + throw new Error('expected the set to reject'); + }, + (e) => e as Error, + ); + assert.match(String(err), /injected apply failure/); + assert.equal(db.wal.poison, null, 'a never-enqueued frame poisons nothing'); + assert.equal(db.get('bad'), undefined, 'the partial apply was undone'); + assert.equal(db.get('old')?.t, 'keep'); + assert.deepEqual(db.search('ft', 'keep').map((h) => h.key), ['old'], 'derived indexes stayed consistent'); + await db.close(); // the sealed WAL still flushes its (empty) queue and closes + + db = await MiniDb.open<{ t: string }>({ dir, valueCodec: 'json', fsyncPolicy: 'no', activeExpireIntervalMs: 0 }); + assert.equal(db.get('bad'), undefined); + assert.equal(db.get('old')?.t, 'keep'); + await db.close(); + await fs.rm(dir, { recursive: true, force: true }); +}); + +test('WAL poison: in-place recovery skips the truncate when the WAL file was replaced (stale coordinates)', async () => { + // Defense-in-depth for the rotation window: a poison whose failedAtOffset + // was recorded against the OLD file must never zero-extend the NEW, shorter + // file that a committed rotation left at db.wal. + const dir = await tmpDir(); + let db = await MiniDb.open({ dir, ...MEM_OPTS }); + for (let i = 0; i < 10; i++) await db.set(`k${i}`, `v${i}`); + await db.wal.flush(); + const staleOffset = (await fs.stat(path.join(dir, 'db.wal'))).size; + assert.ok(staleOffset > 0); + + // Poison the WAL directly (no op rejection, so no recovery is kicked yet). + db.wal.poisonPending(new Error('injected poison')); + const poison = db.wal.poison!; + assert.equal(poison.failedAtOffset, staleOffset); + + // Simulate a successful rotation committing a NEW, shorter WAL at the same + // path: the recorded offset now belongs to the old file's coordinates. + await fs.writeFile(path.join(dir, 'db.wal'), Buffer.alloc(0)); + + // Drive the recovery: the stale-coordinate guard must skip the truncate — + // zero-extending to the stale offset would corrupt the new file. + await (db as unknown as { recoverWalInPlace(w: unknown): Promise }).recoverWalInPlace(db.wal); + assert.equal((await fs.stat(path.join(dir, 'db.wal'))).size, 0, 'the new file was not zero-extended'); + assert.equal(db.wal.poison, null, 'the poison is cleared'); + + await db.set('post', 'ok'); + assert.equal(db.get('post'), 'ok'); + await db.close(); + db = await MiniDb.open({ dir, ...MEM_OPTS }); + assert.equal(db.size, 1, 'only the post-recovery write is in the fresh WAL'); + assert.equal(db.get('post'), 'ok'); + await db.close(); + await fs.rm(dir, { recursive: true, force: true }); +}); + +test('WAL poison: close() in the same tick as a failing write resolves cleanly and leaves a consistent file', async () => { + // The WAL's final flush itself drives the queued failing batch: close() + // must swallow the poison, wait for the recovery the op's rejection kicks, + // and still release everything. The first writev parks so close() provably + // begins while the failing batch is in flight. + const dir = await tmpDir(); + let db = await MiniDb.open({ dir, ...MEM_OPTS }); + const fh = walFh(db as MiniDb); + const orig = fh.writev.bind(fh); + let releaseWritev!: () => void; + const writevGate = new Promise((r) => (releaseWritev = r)); + let calls = 0; + fh.writev = async (...a: unknown[]) => { + calls++; + if (calls === 1) { + await writevGate; + throw new Error('injected WAL failure'); + } + return orig(...a); + }; + + const op = db.set('bad', 'x'); + await waitFor(() => calls === 1, 'the failing writev to be in flight'); + const closing = db.close(); + releaseWritev(); + await assert.rejects(op, /injected WAL failure/); + await closing; // must not throw even though the failure landed mid-close + + db = await MiniDb.open({ dir, ...MEM_OPTS }); + assert.equal(db.get('bad'), undefined, 'the revoked write did not survive close+reopen'); + await db.close(); + await fs.rm(dir, { recursive: true, force: true }); +}); + +test('backup waits out an in-flight WAL recovery instead of copying the un-acked tail', async () => { + const dir = await tmpDir(); + const backupDir = await tmpDir(); + const restoreDir = await tmpDir(); + const db = await MiniDb.open({ dir, ...MEM_OPTS }); + await db.set('k', 'v'); + failNextWritev(db as MiniDb); + + // Park the recovery's truncate so the backup provably overlaps the + // in-flight recovery; the backup must wait it out and re-fence. + const origTruncate = fs.truncate; + let releaseTruncate!: () => void; + const truncateGate = new Promise((r) => (releaseTruncate = r)); + (fs as unknown as { truncate: unknown }).truncate = async (p: unknown, len?: number) => { + await truncateGate; + return origTruncate(p as Parameters[0], len); + }; + try { + await assert.rejects(db.set('bad', 'x'), /injected WAL failure/); + const backingUp = db.backup(backupDir, { compact: false }); + releaseTruncate(); + await backingUp; + } finally { + (fs as unknown as { truncate: unknown }).truncate = origTruncate; + } + assert.equal(db.get('bad'), undefined); + await db.close(); + + // The backup carries the recovered state: the acknowledged write is in, + // the revoked one is not. + const restored = await MiniDb.restore(backupDir, restoreDir, { ...MEM_OPTS, force: true }); + assert.equal(restored.get('k'), 'v'); + assert.equal(restored.get('bad'), undefined, 'the un-acked tail never reached the backup'); + await restored.close(); + await fs.rm(dir, { recursive: true, force: true }); + await fs.rm(backupDir, { recursive: true, force: true }); + await fs.rm(restoreDir, { recursive: true, force: true }); +}); diff --git a/packages/minidb/test/wal.test.ts b/packages/minidb/test/wal.test.ts index 4c59de15f17..d0ae2d0c37f 100644 --- a/packages/minidb/test/wal.test.ts +++ b/packages/minidb/test/wal.test.ts @@ -14,6 +14,7 @@ function freshStats() { return { walBytesWritten: 0, walFsyncs: 0, + walWriteErrors: 0, walFsyncErrors: 0, lastWalFsyncError: null, walQueuedBytes: 0, @@ -23,6 +24,15 @@ function freshStats() { }; } +/** Condition-driven barrier (not a fixed sleep): poll until `cond` holds. */ +async function waitFor(cond, what, timeoutMs = 5000) { + const t0 = Date.now(); + while (!cond()) { + if (Date.now() - t0 > timeoutMs) throw new Error(`timeout waiting for ${what}`); + await new Promise((r) => setTimeout(r, 2)); + } +} + async function tmpWalPath() { const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'minidb-wal-')); return { dir, file: path.join(dir, 'db.wal') }; @@ -222,6 +232,7 @@ test('background sync failure is recorded but neither rejects writes nor clears await sleep(120); assert.ok(stats.walFsyncs >= 1, 'sync retried after the failure'); assert.equal(stats.lastWalFsyncError, boom, 'sticky error is not cleared by a later success'); + assert.equal(wal.poison, null, 'a background sync failure must never poison the WAL'); // Clean again: no more background fsyncs. const n = stats.walFsyncs; @@ -264,3 +275,127 @@ test('queue depth and group-commit counters track the append buffer', async () = await fs.rm(dir, { recursive: true, force: true }); } }); + +test('appendLoc stamps frames with the id of the flush batch carrying them', async () => { + const { dir, file } = await tmpWalPath(); + try { + const wal = new WAL(file, { fsyncPolicy: 'no' }); + await wal.open(); + const a = wal.appendLoc(encodeFrame({ type: TYPE_SET, key: B('a'), value: B('1') })); + const b = wal.appendLoc(encodeFrame({ type: TYPE_SET, key: B('b'), value: B('2') })); + assert.equal(a.batchId, b.batchId, 'same-tick appends share the next batch id'); + assert.ok(a.batchId > 0); + await Promise.all([a.done, b.done]); + const c = wal.appendLoc(encodeFrame({ type: TYPE_SET, key: B('c'), value: B('3') })); + assert.ok(c.batchId > a.batchId, 'the id advances once a batch drained'); + await c.done; + await wal.close(); + } finally { + await fs.rm(dir, { recursive: true, force: true }); + } +}); + +test('a failed flush poisons the WAL: WAL_POISONED rejections until truncate + refreshSize + clearPoison', async () => { + const { dir, file } = await tmpWalPath(); + try { + const stats = freshStats(); + const wal = new WAL(file, { fsyncPolicy: 'no', stats }); + await wal.open(); + await wal.append(encodeFrame({ type: TYPE_SET, key: B('ok'), value: B('1') })); + + const fh = (wal as unknown as { fh: { writev: (...a: unknown[]) => Promise } }).fh; + const orig = fh.writev.bind(fh); + // The first writev of the failing batch parks until a second frame is + // queued behind it, then fails: the batch frame and the queued frame must + // see different rejections. + let releaseWritev!: () => void; + const writevGate = new Promise((r) => (releaseWritev = r)); + let calls = 0; + fh.writev = async (...a: unknown[]) => { + calls++; + if (calls === 1) { + await writevGate; + throw new Error('injected writev failure'); + } + return orig(...a); + }; + + const batchFrame = wal.append(encodeFrame({ type: TYPE_SET, key: B('bad'), value: B('x') })); + await waitFor(() => calls === 1, 'the failing writev to be in flight'); + const queuedFrame = wal.append(encodeFrame({ type: TYPE_SET, key: B('queued'), value: B('y') })); + releaseWritev(); + + const order: string[] = []; + await Promise.all([ + batchFrame.catch((e) => { order.push('batch'); throw e; }), + queuedFrame.catch((e) => { order.push('queued'); throw e; }), + ]).then( + () => { throw new Error('expected both frames to reject'); }, + () => {}, + ); + assert.deepEqual(order, ['queued', 'batch'], 'queued frames reject first so group rollbacks unwind newest-first'); + assert.equal(stats.walWriteErrors, 1, 'writev-class failure counted'); + assert.equal(stats.walFsyncErrors, 0, 'not an fsync-class failure'); + + const poison = wal.poison; + assert.ok(poison, 'WAL is poisoned'); + assert.match(String(poison.error), /injected writev failure/); + + // While poisoned: appends reject WAL_POISONED (distinct from WAL_SEALED), + // flush() throws, sync() is a silent no-op. + await assert.rejects( + wal.append(encodeFrame({ type: TYPE_SET, key: B('later'), value: B('z') })), + (err) => { + assert.equal((err as { code?: string }).code, 'WAL_POISONED'); + return true; + }, + ); + await assert.rejects(wal.flush(), (err) => { + assert.equal((err as { code?: string }).code, 'WAL_POISONED'); + return true; + }); + await wal.sync(); + + // In-place recovery (what MiniDb does): truncate the un-acked tail, + // re-sync bookkeeping, clear the poison — then the write path resumes. + const sizeBefore = (await fs.stat(file)).size; + assert.equal(poison.failedAtOffset, sizeBefore, 'the injected writev threw before writing a byte'); + await fs.truncate(file, poison.failedAtOffset); + await wal.refreshSize(); + wal.clearPoison(); + assert.equal(wal.poison, null); + await wal.append(encodeFrame({ type: TYPE_SET, key: B('c'), value: B('3') })); + await wal.close(); + + const frames = parseAll(await fs.readFile(file)); + assert.deepEqual( + frames.map((f) => f.key.toString()), + ['ok', 'c'], + 'rejected frames never reach the file', + ); + } finally { + await fs.rm(dir, { recursive: true, force: true }); + } +}); + +test("an fsync failure with fsyncPolicy 'always' poisons the WAL and counts as walFsyncErrors", async () => { + const { dir, file } = await tmpWalPath(); + try { + const stats = freshStats(); + const wal = new WAL(file, { fsyncPolicy: 'always', stats }); + await wal.open(); + const fh = (wal as unknown as { fh: { sync: () => Promise } }).fh; + fh.sync = () => Promise.reject(new Error('injected fsync failure')); + + await assert.rejects(wal.append(encodeFrame({ type: TYPE_SET, key: B('k'), value: B('v') })), /injected fsync failure/); + assert.equal(stats.walFsyncErrors, 1, 'fsync-class failure counted by sync() itself'); + assert.equal(stats.walWriteErrors, 0, 'not a writev-class failure'); + assert.ok(wal.poison, 'an unacknowledged batch poisons even though its bytes landed'); + + // A poisoned close() does not throw and skips the final flush/fsync. + await wal.close(); + assert.equal((wal as unknown as { fh: unknown }).fh, null, 'the handle is still released'); + } finally { + await fs.rm(dir, { recursive: true, force: true }); + } +}); From fc64a1a79082117ba81a30ad47c2c13af783f5c0 Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Sun, 2 Aug 2026 23:22:12 +0800 Subject: [PATCH 06/15] fix(minidb): turn the file lock into an instance-owned serialized lease - distinguish lock ownership by instance instead of pid: every acquire mints a pid:uuid token carried by lock/bid/watch files, inspect().mine compares tokens, liveness still follows pid, tokenless legacy files keep the old stale-takeover path, and hasLiveForeignWatch excludes self by token so same-process contenders see each other (closing the double-win takeover and the cross-instance release); a live same-pid lock is still respected, and re-acquiring a held lock is idempotent - serialize acquire/renew/release through a per-instance promise-chain mutex: renew re-checks held inside the chain and release waits for an in-flight renew, eliminating the renew/rename-after-unlink ghost lock - make MiniDb.close() a state machine (open/closing/closed) with a shared closePromise: cleanup runs per-resource try/catch in dependency order (text indexes, store, valueReader, WAL, lock), aggregates every cleanup error into an AggregateError, stays in 'closing' on failure so a retry finishes the cleanup, and no longer leaks the lock when the WAL close fails; a rejected in-flight compaction no longer escapes the cleanup pass --- packages/minidb/src/index.ts | 83 ++++++++-- packages/minidb/src/lockfile.ts | 131 ++++++++++++---- packages/minidb/test/lock.test.ts | 241 +++++++++++++++++++++++++++++- 3 files changed, 413 insertions(+), 42 deletions(-) diff --git a/packages/minidb/src/index.ts b/packages/minidb/src/index.ts index e5a9668798f..798f19c31af 100644 --- a/packages/minidb/src/index.ts +++ b/packages/minidb/src/index.ts @@ -275,7 +275,13 @@ export class MiniDb { private codecName: ValueCodecName = 'buffer'; fsyncPolicy: FsyncPolicy = 'everysec'; syncIntervalMs = 1000; - private closed = false; + /** Lifecycle state machine. 'closing' is a real state (not just a flag on + * the way down): a cleanup failure leaves the instance there so a later + * close() call can retry the remaining cleanup, and ensureOpen rejects + * 'closing' and 'closed' alike. */ + private state: 'open' | 'closing' | 'closed' = 'open'; + /** The in-flight close() cleanup pass, shared by concurrent close() calls. */ + private closePromise: Promise | null = null; recoveryInfo: RecoveryInfo | null = null; /** Continuation watermark for catchUpFromWal: the WAL inode + applied * offset as advanced by the last successful catch-up (recoveryInfo's scan @@ -2240,9 +2246,35 @@ export class MiniDb { } async close(): Promise { - if (this.closed) return; - if (this.compacting) await this._compactDone; - this.closed = true; + if (this.state === 'closed') return; + // Concurrent close() calls share the one in-flight cleanup pass; after a + // failed pass a later call retries the remaining cleanup (the state stays + // 'closing' until a pass completes without errors). + if (this.closePromise) return this.closePromise; + this.state = 'closing'; + const run = this.closeResources(); + this.closePromise = run; + try { + await run; + this.state = 'closed'; + } finally { + if (this.closePromise === run) this.closePromise = null; + } + } + + /** One cleanup pass over every held resource in dependency order (text + * indexes → store → valueReader → WAL → lock). Each resource's close is + * independently fallible and idempotent: an error is collected and the + * rest still run — a failed WAL close must not skip the lock release — + * then every collected error is rethrown as one AggregateError. The WAL + * failure semantics themselves are unchanged (the error propagates); only + * the lock release is no longer skipped because of it. */ + private async closeResources(): Promise { + // Wait out an in-flight compaction, but never propagate its failure: it is + // already accounted in lastCompactError/stats.compactErrors, and letting + // it escape here would skip the whole cleanup pass (the caller would have + // to close() twice to actually release the lock). + if (this.compacting) await this._compactDone?.catch(() => {}); // Let in-flight WAL failures and their kicked recoveries settle before // and after closing the WAL: a poisoned/failing close would otherwise // leave an un-acked tail in db.wal that a reopen replays as ghost writes. @@ -2251,19 +2283,46 @@ export class MiniDb { // batch, kicking one more recovery), so wait for the chain to be IDLE in // a loop instead of awaiting one snapshot of it. while (!this.walRecoveryIdle) await this.walRecoveryChain; - for (const ti of this.text.values()) ti.close(); - this.store.close(); - this.valueReader?.close(); - await this.wal.close(); + const errors: unknown[] = []; + try { + for (const ti of this.text.values()) ti.close(); + } catch (e) { + errors.push(e); + } + try { + this.store.close(); + } catch (e) { + errors.push(e); + } + try { + this.valueReader?.close(); + } catch (e) { + errors.push(e); + } + try { + await this.wal.close(); + } catch (e) { + errors.push(e); + } while (!this.walRecoveryIdle) await this.walRecoveryChain; - if (this.lock) { - await this.lock.release(); - this.lock = null; + try { + if (this.lock) { + await this.lock.release(); + this.lock = null; + } + } catch (e) { + errors.push(e); + } + if (errors.length > 0) { + throw new AggregateError( + errors, + `MiniDb close: ${errors.map((e) => (e instanceof Error ? e.message : String(e))).join('; ')}`, + ); } } private ensureOpen(): void { - if (this.closed) throw new Error('MiniDb is closed'); + if (this.state !== 'open') throw new Error('MiniDb is closed'); } private ensureWritable(): void { if (this.readOnly) throw new Error('MiniDb is open in read-only mode'); diff --git a/packages/minidb/src/lockfile.ts b/packages/minidb/src/lockfile.ts index 43727f1a41c..bba37dd5a19 100644 --- a/packages/minidb/src/lockfile.ts +++ b/packages/minidb/src/lockfile.ts @@ -4,10 +4,20 @@ // processes from opening the same database directory for writing (which would // corrupt it). A lock is considered stale and is taken over only when the // recorded owner PID is no longer alive — never merely because it is old. +// +// Ownership is per INSTANCE, not per process: every acquire() mints a token +// (`${pid}:${uuid}`) carried by the lock/bid/watch files, and `mine` compares +// tokens, so two LockFile objects in one process are visible to each other +// instead of passing every pid-based check. A legacy lock line without a +// token is never "mine" and follows the pid-liveness stale rules unchanged — +// a live same-pid lock is still respected, exactly as before. Lifecycle ops +// (acquire/renew/release) are serialized through a per-instance promise +// chain, so no interleaving can re-publish the lock after it was released. import fs from 'node:fs/promises'; import fsSync from 'node:fs'; import path from 'node:path'; +import { randomUUID } from 'node:crypto'; import { renameReplace } from './rename-replace.js'; export class LockError extends Error { @@ -46,9 +56,10 @@ let exitHooked = false; // 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. +// double-win — the liveness-watch check at verification shrinks this window +// to "competitor had not even registered its watch yet" (the watch precedes +// the whole attempt), 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 { @@ -62,11 +73,38 @@ function hookExit(): void { export class LockFile { readonly path: string; held = false; + /** Identity of the current acquire() attempt: minted fresh per attempt, + * carried by every file this instance publishes (lock/bid/watch), and the + * sole ownership criterion (`mine`). Null before the first acquire(). */ + private token: string | null = null; + /** Serializes acquire/renew/release (the withUniqueWriteLock pattern): each + * op's whole read-check-write completes before the next one starts, so a + * renew already in flight finishes before a release unlinks. */ + private opChain: Promise = Promise.resolve(); constructor(path: string) { this.path = path; } + private async serialized(fn: () => Promise): Promise { + const prev = this.opChain; + let done!: () => void; + this.opChain = new Promise((resolve) => { + done = resolve; + }); + await prev; + try { + return await fn(); + } finally { + done(); + } + } + + /** File body for every file this instance publishes (lock, bid, watch). */ + private payload(): string { + return JSON.stringify({ pid: process.pid, ts: Date.now(), token: this.token }); + } + /** 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 @@ -74,6 +112,15 @@ export class LockFile { * re-races: callers that want to wait retry acquire() at a higher level * (see the cluster lock pool). */ async acquire(): Promise { + return this.serialized(() => this.acquireOnce()); + } + + private async acquireOnce(): Promise { + // Re-entrant acquire on an already-held lock is an idempotent success: + // the lock is ours, and re-minting the token here would make the later + // release fail to recognize (and unlink) our own lock line. + if (this.held) return true; + this.token = `${process.pid}:${randomUUID()}`; // 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 @@ -81,7 +128,7 @@ export class LockFile { // 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() })); + await fs.writeFile(watch, this.payload()); try { await this.reapDeadWatches(); @@ -108,7 +155,7 @@ export class LockFile { 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() })); + await fs.writeFile(bid, this.payload()); 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 @@ -179,14 +226,26 @@ export class LockFile { } } - /** True when any OTHER process's liveness watch exists (reaping dead ones on sight). */ + /** True when any OTHER owner's liveness watch exists (reaping dead ones on + * sight). "Foreign" is by token, not pid: a same-process competitor's + * registration counts, so the settle loop waits for the competitor's whole + * attempt to finish instead of claiming on stale evidence. A legacy + * tokenless watch line cannot be told apart from our own when its pid is + * ours, so it keeps the old pid-based exclusion. */ 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 (!Number.isInteger(pid)) continue; + let token: string | undefined; + try { + token = (JSON.parse(await fs.readFile(path.join(dir, f), 'utf8')) as { token?: string }).token; + } catch { + token = undefined; // unreadable/partial line: fall back to the pid in the name + } + if (token !== undefined ? token === this.token : pid === process.pid) continue; if (pidAlive(pid)) return true; await fs.unlink(path.join(dir, f)).catch(() => {}); } @@ -197,7 +256,7 @@ export class LockFile { 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.writeFile(tmp, this.payload()); await fs.link(tmp, this.path); this.markHeld(); return true; @@ -209,7 +268,9 @@ export class LockFile { } } - /** Read the lock file and decide its state. null = the file vanished. */ + /** Read the lock file and decide its state. null = the file vanished. + * `mine` is decided by the owner token, `alive` still by pid liveness: a + * legacy tokenless line is never mine and follows the stale rules. */ private async inspect(): Promise<{ ino: number | bigint; alive: boolean; mine: boolean } | null> { let raw: string; let st: { ino: number | bigint }; @@ -220,12 +281,15 @@ export class LockFile { throw e; } let pid: number | undefined; + let token: string | undefined; try { - pid = (JSON.parse(raw) as { pid?: number }).pid; + const parsed = JSON.parse(raw) as { pid?: number; token?: string }; + pid = parsed.pid; + token = parsed.token; } catch { pid = undefined; // unparsable content looks abandoned, same as a dead PID } - return { ino: st.ino, alive: pidAlive(pid), mine: pid === process.pid }; + return { ino: st.ino, alive: pidAlive(pid), mine: this.token !== null && token === this.token }; } private inspectSync(): { ino: number | bigint; alive: boolean; mine: boolean } | null { @@ -239,25 +303,32 @@ export class LockFile { throw e; } let pid: number | undefined; + let token: string | undefined; try { - pid = (JSON.parse(raw) as { pid?: number }).pid; + const parsed = JSON.parse(raw) as { pid?: number; token?: string }; + pid = parsed.pid; + token = parsed.token; } catch { pid = undefined; } - return { ino: st.ino, alive: pidAlive(pid), mine: pid === process.pid }; + return { ino: st.ino, alive: pidAlive(pid), mine: this.token !== null && token === this.token }; } /** 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. */ + * behind for a lock that is actually still owned. Serialized with + * acquire/release: `held` is re-checked inside the chain, and a release + * queued behind this renew unlinks only after the rename landed. */ 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 }); + return this.serialized(async () => { + if (!this.held) return; + const tmp = `${this.path}.tmp-${process.pid}-${nextSidecarSeq()}`; + await fs.writeFile(tmp, this.payload()); + // 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 { @@ -267,15 +338,17 @@ export class LockFile { } async release(): Promise { - if (!this.held) return; - // 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); + return this.serialized(async () => { + if (!this.held) return; + // 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); + }); } /** Best-effort sync release for the exit hook. */ diff --git a/packages/minidb/test/lock.test.ts b/packages/minidb/test/lock.test.ts index 3e5c22b83c6..79d62f6e238 100644 --- a/packages/minidb/test/lock.test.ts +++ b/packages/minidb/test/lock.test.ts @@ -5,7 +5,7 @@ import fs from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; import { MiniDb } from '../src/index.js'; -import { LockError } from '../src/lockfile.js'; +import { LockError, LockFile } from '../src/lockfile.js'; async function tmpDir() { return fs.mkdtemp(path.join(os.tmpdir(), 'minidb-lock-')); @@ -73,3 +73,242 @@ test('a stale lock (dead PID) is taken over', async () => { await db.close(); await fs.rm(dir, { recursive: true, force: true }); }); + +// ---- owner token: per-instance ownership (review #10/#11 regression) ------ + +test('two same-process contenders over a stale corpse: exactly one wins, zero double-wins', { timeout: 120_000 }, async () => { + const dir = await tmpDir(); + try { + const lockPath = path.join(dir, 'db.lock'); + for (let i = 0; i < 100; i++) { + await fs.writeFile(lockPath, JSON.stringify({ pid: 999999, ts: Date.now() })); + const a = new LockFile(lockPath); + const b = new LockFile(lockPath); + const wins = await Promise.all([a.acquire(), b.acquire()]); + assert.equal(wins.filter(Boolean).length, 1, `iteration ${i}: exactly one winner`); + await a.release(); + await b.release(); + // The winner's release unlinked its own lock; nothing may be left. + assert.equal( + await fs.stat(lockPath).then(() => true, () => false), + false, + `iteration ${i}: lock released`, + ); + } + } finally { + await fs.rm(dir, { recursive: true, force: true }); + } +}); + +test('a racing renew() never re-publishes a released lock (100 iterations)', { timeout: 60_000 }, async () => { + const dir = await tmpDir(); + try { + for (let i = 0; i < 100; i++) { + const lockPath = path.join(dir, `db-${i}.lock`); + const lock = new LockFile(lockPath); + assert.equal(await lock.acquire(), true); + await Promise.all([lock.renew(), lock.release()]); + assert.equal(lock.held, false); + assert.equal( + await fs.stat(lockPath).then(() => true, () => false), + false, + `iteration ${i}: no ghost lock`, + ); + } + } finally { + await fs.rm(dir, { recursive: true, force: true }); + } +}); + +test('renew() keeps the owner token, so release() still recognizes the lock', async () => { + const dir = await tmpDir(); + try { + const lockPath = path.join(dir, 'db.lock'); + const lock = new LockFile(lockPath); + assert.equal(await lock.acquire(), true); + const before = JSON.parse(await fs.readFile(lockPath, 'utf8')) as { token?: string }; + await lock.renew(); + const after = JSON.parse(await fs.readFile(lockPath, 'utf8')) as { token?: string }; + assert.equal(after.token, before.token, 'renew preserves the owner token'); + await lock.release(); + assert.equal(await fs.stat(lockPath).then(() => true, () => false), false); + } finally { + await fs.rm(dir, { recursive: true, force: true }); + } +}); + +test('acquire() on an already-held lock is an idempotent true and does not re-mint the token', async () => { + const dir = await tmpDir(); + try { + const lockPath = path.join(dir, 'db.lock'); + const lock = new LockFile(lockPath); + assert.equal(await lock.acquire(), true); + const before = JSON.parse(await fs.readFile(lockPath, 'utf8')) as { token?: string }; + assert.equal(await lock.acquire(), true, 're-entrant acquire reports the held lock'); + const after = JSON.parse(await fs.readFile(lockPath, 'utf8')) as { token?: string }; + assert.equal(after.token, before.token, 'token is not re-minted'); + await lock.release(); + assert.equal( + await fs.stat(lockPath).then(() => true, () => false), + false, + 'release still recognizes and unlinks its own lock', + ); + } finally { + await fs.rm(dir, { recursive: true, force: true }); + } +}); + +test('a legacy tokenless lock file is respected while alive and taken over when dead', async () => { + const dir = await tmpDir(); + try { + // Live same-pid owner without a token: respected exactly as before — the + // protocol does not open a "I know the old instance is gone" backdoor. + const livePath = path.join(dir, 'live.lock'); + const legacyLive = JSON.stringify({ pid: process.pid, ts: Date.now() }); + await fs.writeFile(livePath, legacyLive); + const contender = new LockFile(livePath); + assert.equal(await contender.acquire(), false); + assert.equal(await fs.readFile(livePath, 'utf8'), legacyLive, 'live legacy lock untouched'); + + // Dead-pid owner without a token: taken over via the stale rules, and the + // new lock line carries the instance token. + const deadPath = path.join(dir, 'dead.lock'); + await fs.writeFile(deadPath, JSON.stringify({ pid: 999999, ts: Date.now() })); + const taker = new LockFile(deadPath); + assert.equal(await taker.acquire(), true); + const taken = JSON.parse(await fs.readFile(deadPath, 'utf8')) as { pid: number; token?: string }; + assert.equal(taken.pid, process.pid); + assert.ok( + typeof taken.token === 'string' && taken.token.startsWith(`${process.pid}:`), + 'taken-over lock line carries the owner token', + ); + await taker.release(); + } finally { + await fs.rm(dir, { recursive: true, force: true }); + } +}); + +// ---- close() state machine: exception-safe cleanup (review #12 regression) - + +test('close() still releases the lock when the WAL close fails; a retry finishes the cleanup', async () => { + const dir = await tmpDir(); + try { + const db = await MiniDb.open({ dir, valueCodec: 'string', fsyncPolicy: 'no' }); + await db.set('a', '1'); + const internals = db as unknown as { wal: { close(): Promise } }; + const originalClose = internals.wal.close.bind(internals.wal); + let failClose = true; + internals.wal.close = async () => { + await originalClose(); + if (failClose) { + failClose = false; + throw new Error('injected close failure'); + } + }; + const err = await db.close().catch((e: unknown) => e); + assert.ok(err instanceof AggregateError, 'close failure is aggregated'); + assert.equal(err.errors.length, 1); + assert.match(String(err.errors[0]), /injected close failure/); + // The lock release was NOT skipped by the WAL failure. + assert.equal( + await fs.stat(path.join(dir, 'db.lock')).then(() => true, () => false), + false, + 'lock released despite the WAL close failure', + ); + // Idempotent continuation: the second pass completes the cleanup. + await db.close(); + await db.close(); // fully closed now: a no-op + // The directory is free: a fresh writer opens with no LockError. + const reopened = await MiniDb.open({ dir, valueCodec: 'string', fsyncPolicy: 'no' }); + assert.equal(reopened.get('a'), '1'); + await reopened.close(); + } finally { + await fs.rm(dir, { recursive: true, force: true }); + } +}); + +test('close() aggregates every cleanup error instead of stopping at the first', async () => { + const dir = await tmpDir(); + try { + const db = await MiniDb.open({ dir, valueCodec: 'string', fsyncPolicy: 'no' }); + const internals = db as unknown as { + wal: { close(): Promise }; + lock: { release(): Promise } | null; + }; + const originalWalClose = internals.wal.close.bind(internals.wal); + let failWal = true; + internals.wal.close = async () => { + await originalWalClose(); + if (failWal) { + failWal = false; + throw new Error('injected wal close failure'); + } + }; + const lock = internals.lock!; + const originalRelease = lock.release.bind(lock); + let failRelease = true; + lock.release = async () => { + if (failRelease) { + failRelease = false; + throw new Error('injected lock release failure'); + } + return originalRelease(); + }; + const err = await db.close().catch((e: unknown) => e); + assert.ok(err instanceof AggregateError, 'close failure is aggregated'); + assert.deepEqual( + err.errors.map((e) => (e instanceof Error ? e.message : String(e))), + ['injected wal close failure', 'injected lock release failure'], + 'AggregateError carries every cleanup error', + ); + // Retry: the WAL close is now a no-op, the lock release runs for real. + await db.close(); + assert.equal( + await fs.stat(path.join(dir, 'db.lock')).then(() => true, () => false), + false, + 'lock released on the retry', + ); + // The instance refused use from the first 'closing' transition on. + await assert.rejects(() => db.set('b', '2'), /MiniDb is closed/); + await assert.rejects(() => db.compact(), /MiniDb is closed/); + } finally { + await fs.rm(dir, { recursive: true, force: true }); + } +}); + +test('close() waits out a failing in-flight compaction and still cleans up every resource', async () => { + const dir = await tmpDir(); + try { + const db = await MiniDb.open({ dir, valueCodec: 'string', fsyncPolicy: 'no' }); + await db.set('a', '1'); + // Keep the compaction deterministically in flight when close() starts, and + // fail it (via the onCompacted hook, a real compactError path) while + // close() is parked on _compactDone. + let failHook: ((e: Error) => void) | undefined; + db.onCompacted = () => + new Promise((_, reject) => { + failHook = reject; + }); + const compacted = db.compact().catch(() => {}); + // Wait until the compaction actually reaches the injected hook (the + // compacting flag flips long before the hook is invoked). + while (!failHook) await new Promise((r) => setTimeout(r, 1)); + const closing = db.close(); + failHook(new Error('injected compaction failure')); + await compacted; + // The compaction failure is accounted on the instance, but close() must + // not reject with it and must not skip the cleanup. + await closing; + assert.match(String(db.lastCompactError), /injected compaction failure/); + assert.equal( + await fs.stat(path.join(dir, 'db.lock')).then(() => true, () => false), + false, + 'lock released despite the failed compaction', + ); + await db.close(); // fully closed now: a no-op + const reopened = await MiniDb.open({ dir, valueCodec: 'string', fsyncPolicy: 'no' }); + await reopened.close(); + } finally { + await fs.rm(dir, { recursive: true, force: true }); + } +}); From cd9d1d5934ff40d22a2439e9c26a94ddad39f9cc Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Mon, 3 Aug 2026 00:09:13 +0800 Subject: [PATCH 07/15] fix(minidb): keep readers on one consistent file generation - add an internal persistent-files module as the single source of truth for the persisted file set (snapshot, WAL, sidecars, postings pattern, fingerprint subset); lock-pool fingerprints, persistentFiles, open stale-tmp cleanup, and backup/restore filtering all derive from it, and fingerprints upgrade to dev:ino:size:mtimeMs so compound sidecar changes can no longer hide from cluster readers - pair snapshot and WAL generations during recovery (transitional stat-pairing until stage-5 manifests): each pass anchors the fds it scans, re-stats afterwards, tolerates append-only WAL growth, retries bounded times on generation churn with a clean store reset, and throws RECOVERY_GENERATION_CHURN when churn exceeds the budget; the disk-mode ValueReader attach re-validates inodes so stale offsets never read a replaced file - make the rotation directory fsyncs strict: failures abort the rotation through the existing rollback path instead of being swallowed, while platforms without directory fsync degrade once with a warn and stats.dirFsyncUnsupported --- packages/minidb/src/cluster/lock-pool.ts | 36 +- packages/minidb/src/compaction.ts | 43 ++- packages/minidb/src/index.ts | 97 ++++-- packages/minidb/src/persistent-files.ts | 65 ++++ packages/minidb/src/recovery.ts | 203 +++++++++-- packages/minidb/src/value-reader.ts | 14 +- .../minidb/test/cluster/concurrent.test.ts | 65 ++++ packages/minidb/test/cluster/mp-worker.ts | 14 + packages/minidb/test/compaction-fault.test.ts | 168 +++++++++- packages/minidb/test/recovery.test.ts | 316 +++++++++++++++++- 10 files changed, 940 insertions(+), 81 deletions(-) create mode 100644 packages/minidb/src/persistent-files.ts diff --git a/packages/minidb/src/cluster/lock-pool.ts b/packages/minidb/src/cluster/lock-pool.ts index 2a6aa3ccce5..5948878ec8b 100644 --- a/packages/minidb/src/cluster/lock-pool.ts +++ b/packages/minidb/src/cluster/lock-pool.ts @@ -10,8 +10,10 @@ // 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: +// cheap file fingerprint (dev:ino:size:mtimeMs of the shard's WAL, snapshot +// and every index-definition sidecar — FINGERPRINT_FILES, derived from the +// authoritative persistent-files module so a newly added file can never be +// missed). 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); @@ -25,6 +27,7 @@ import fs from 'node:fs/promises'; import path from 'node:path'; import type { MiniDb } from '../index.js'; import { LockError } from '../lockfile.js'; +import { FINGERPRINT_FILES } from '../persistent-files.js'; import { ShardHandle } from './shard.js'; import type { ShardOpenOptions } from './shard.js'; import { sleep } from './utils.js'; @@ -69,17 +72,15 @@ interface ReaderEntry { async function statFingerprint(file: string): Promise { try { const s = await fs.stat(file); - return `${s.mtimeMs}:${s.size}`; + // dev:ino:size:mtimeMs — sidecars are replaced by rename (tmp + rename), + // so the inode eliminates the "same size, same mtime alias" window a + // size+mtime fingerprint would leave open. + return `${s.dev}:${s.ino}:${s.size}:${s.mtimeMs}`; } 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)))); } @@ -296,10 +297,21 @@ export class ShardLockPool { 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]) { + // Something in the shard changed. When the change is confined to the + // WAL (parts[0]) — i.e. the snapshot and EVERY sidecar are unchanged — + // it can only be WAL appends on the same inode, so apply just those + // frames instead of paying for a full replay (fallback: a clean full + // reopen below). A compound/secondary/text definition change lands on + // this reopen path too: it rewrites its sidecar, which the fingerprint + // tracks. + let walOnly = true; + for (let i = 1; i < parts.length; i++) { + if (parts[i] !== cached.fpParts[i]) { + walOnly = false; + break; + } + } + if (walOnly) { if (await this.tryCatchUpReader(cached, dir, parts)) return cached; } // A change the watermark cannot advance over (rotation, truncation, diff --git a/packages/minidb/src/compaction.ts b/packages/minidb/src/compaction.ts index 73ff64ead61..85de3402899 100644 --- a/packages/minidb/src/compaction.ts +++ b/packages/minidb/src/compaction.ts @@ -42,7 +42,12 @@ // whole old WAL on top of the new snapshot is idempotent for pre-fence frames // and correct for post-fence frames, so the state is still consistent. The // reverse order (WAL first) would pair an old snapshot with a truncated new WAL -// and lose pre-fence data. +// and lose pre-fence data. The argument only holds when each rename is durable +// before the next one lands, so the rotation's directory fsyncs are STRICT: a +// failed dir fsync aborts the rotation (rolling back through the catch in +// runCompaction) rather than silently weakening the invariant. Platforms that +// cannot fsync a directory degrade explicitly instead — a one-time warning and +// stats.dirFsyncUnsupported = true. import fs from 'node:fs/promises'; import type { FileHandle } from 'node:fs/promises'; @@ -78,6 +83,10 @@ export interface CompactionTarget { compactionDurationMs?: number; compactionSnapshotDurationMs?: number; compactionRotationDurationMs?: number; + /** Set (once) when a directory fsync reported EINVAL/ENOTSUP: this + * platform cannot make renames durable via the directory, so rotation + * durability is knowingly degraded (warned once) rather than aborted. */ + dirFsyncUnsupported?: boolean; }; /** Reader for disk-backed values; reopened after snapshot/WAL rotation so * remapped value pointers read from the new files. On Windows it is also @@ -109,13 +118,31 @@ const rotateReplace = (src: string, dst: string): Promise => renameReplace const MAX_PRECOPY_PASSES = 5; const CONVERGE_RATIO = 0.7; -export async function fsyncDir(dir: string): Promise { +export async function fsyncDir( + dir: string, + opts: { strict?: boolean; stats?: { dirFsyncUnsupported?: boolean } } = {}, +): Promise { let fh: FileHandle | null = null; try { fh = await fs.open(dir, 'r'); await fh.sync(); - } catch { - /* best-effort */ + } catch (e) { + const code = (e as NodeJS.ErrnoException).code; + // Some platforms cannot fsync a directory at all. That is a permanent + // environment property, not a rotation fault: degrade explicitly — warn + // once and mark stats.dirFsyncUnsupported — and continue without it, in + // BOTH modes. + if (code === 'EINVAL' || code === 'ENOTSUP') { + if (opts.stats && !opts.stats.dirFsyncUnsupported) { + opts.stats.dirFsyncUnsupported = true; + console.warn(`minidb: directory fsync unsupported on this platform (${code}); rotation durability is degraded`); + } + return; + } + // Strict mode (the rotation path): a failed directory fsync breaks the + // rename-durability invariant, so the caller must abort — never swallow. + if (opts.strict) throw e; + /* best-effort otherwise */ } finally { if (fh) await fh.close().catch(() => {}); } @@ -300,11 +327,15 @@ async function runCompaction(db: CompactionTarget): Promise { if (process.platform === 'win32') db.valueReader?.close?.(); // Snapshot first, then WAL — see the crash-safety note in the file header. + // That argument assumes each rename is durable before the next one lands, + // so the directory fsyncs here are STRICT: a failure aborts the rotation + // (the catch below rolls back) instead of silently weakening the + // invariant. Platforms without directory fsync degrade via fsyncDir itself. await rotateReplace(tmp, snap); - await fsyncDir(db.dir); + await fsyncDir(db.dir, { strict: true, stats: db.stats }); await rotateReplace(walTmp, db.walPath); rotated = true; - await fsyncDir(db.dir); + await fsyncDir(db.dir, { strict: true, stats: db.stats }); const fresh = new WAL(db.walPath, { fsyncPolicy: db.fsyncPolicy, syncIntervalMs: db.syncIntervalMs, stats: db.stats }); db.wal = fresh; diff --git a/packages/minidb/src/index.ts b/packages/minidb/src/index.ts index 798f19c31af..19e5e9cbded 100644 --- a/packages/minidb/src/index.ts +++ b/packages/minidb/src/index.ts @@ -16,6 +16,17 @@ import type { WalPoison } from './wal.js'; import { ValueReader } from './value-reader.js'; import { recover, catchUpWal, frameToOps } from './recovery.js'; import { compact, shouldCompact } from './compaction.js'; +import { + SNAPSHOT_FILE, + WAL_FILE, + SECONDARY_INDEXES_FILE, + COMPOUND_INDEXES_FILE, + TEXT_INDEXES_FILE, + SIDECAR_FILES, + STALE_TMP_FILES, + STALE_POSTINGS_TMP_PATTERN, + isPersistentFile, +} from './persistent-files.js'; import { IndexManager, UniqueViolationError } from './index-manager.js'; import { DtIndex } from './dt-index.js'; import { TextIndex, type TextIndexOptions, type TextIndexBuild } from './text-index.js'; @@ -144,7 +155,7 @@ async function writeFileAtomic(file: string, data: string): Promise { async function resolveValueMode(mode: ValueModeSetting, dir: string, maxMemoryBytes: number | null): Promise { if (mode !== 'auto') return mode; if (maxMemoryBytes === null) return 'memory'; - const total = (await fileSize(path.join(dir, 'db.snapshot'))) + (await fileSize(path.join(dir, 'db.wal'))); + const total = (await fileSize(path.join(dir, SNAPSHOT_FILE))) + (await fileSize(path.join(dir, WAL_FILE))); return total > maxMemoryBytes ? 'disk' : 'memory'; } @@ -385,6 +396,10 @@ export class MiniDb { compactionPostingsDurationMs: 0, /** Cumulative time write ops spent parked on a compaction rotation. */ compactionRotationPauseMs: 0, + /** Set once a rotation's directory fsync reported EINVAL/ENOTSUP: this + * platform cannot make renames durable via the directory, so rotation + * durability is knowingly degraded (warned once), never silently. */ + dirFsyncUnsupported: false, /** Candidate keys iterated / values decoded / rows fed to a sort in query(). */ queryCandidates: 0, queryDecoded: 0, @@ -407,10 +422,10 @@ export class MiniDb { if (!opts || !opts.dir) throw new TypeError('MiniDb.open: opts.dir is required'); const db = new MiniDb(); db.dir = opts.dir; - db.walPath = path.join(db.dir, 'db.wal'); - db.indexPath = path.join(db.dir, 'db.indexes.json'); - db.textIndexPath = path.join(db.dir, 'db.textindexes.json'); - db.compoundIndexPath = path.join(db.dir, 'db.compound-indexes.json'); + db.walPath = path.join(db.dir, WAL_FILE); + db.indexPath = path.join(db.dir, SECONDARY_INDEXES_FILE); + db.textIndexPath = path.join(db.dir, TEXT_INDEXES_FILE); + db.compoundIndexPath = path.join(db.dir, COMPOUND_INDEXES_FILE); db.fsyncPolicy = opts.fsyncPolicy ?? 'everysec'; db.syncIntervalMs = opts.syncIntervalMs ?? 1000; db.codecName = opts.valueCodec ?? 'buffer'; @@ -445,17 +460,12 @@ 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. + // compaction's snapshot/WAL temps, sidecar-definition temps — the atomic + // write siblings of every persistent file, derived from the authoritative + // module). 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', - ]) { + for (const tmp of STALE_TMP_FILES) { await fs.rm(path.join(db.dir, tmp), { force: true }); } // A failed postings rebuild orphans `db.text-*.postings.tmp` (its atomic @@ -463,7 +473,7 @@ export class MiniDb { // 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 }); + if (STALE_POSTINGS_TMP_PATTERN.test(f)) await fs.rm(path.join(db.dir, f), { force: true }); } } @@ -490,6 +500,39 @@ export class MiniDb { mode: opts.recovery ?? 'resync', truncate: !db.readOnly, valueMode: db.valueMode, + // Disk-backed values need the positioned reader attached to the SAME + // inodes recovery scanned; recovery's generation pairing re-verifies + // the attach and retries the whole pass when a rotation landed in + // between (see the pairing note in recovery.ts). 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). + attachValueReader: + db.valueMode === 'disk' + ? (anchors) => { + const reader = new ValueReader(db.dir); + // open() can throw after attaching only one side (e.g. EMFILE + // on the WAL with the snapshot already open). This reader is + // never published to db.valueReader, so the open() failure + // cleanup cannot reach it — close it here or leak the fd. + let ids: ReturnType; + try { + ids = reader.open(); + } catch (e) { + reader.close(); + throw e; + } + const sameInode = (a: { dev: number; ino: number } | null, i: { dev: number; ino: number } | null): boolean => + a === null ? i === null : i !== null && i.dev === a.dev && i.ino === a.ino; + if (sameInode(anchors.snapshot, ids.snapshot) && sameInode(anchors.wal, ids.wal)) { + db.valueReader = reader; + return true; + } + reader.close(); + return false; + } + : undefined, }); db.stats.recoveryDurationMs += performance.now() - recT0; db.stats.recoveryBytes += db.recoveryInfo.snapshotBytes + db.recoveryInfo.walBytes; @@ -498,15 +541,6 @@ export class MiniDb { // 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(); @@ -569,7 +603,7 @@ export class MiniDb { // (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']) { + for (const f of SIDECAR_FILES) { await fs.rm(path.join(opts.dir, f), { force: true }); await fs.rm(path.join(opts.dir, `${f}.tmp`), { force: true }); } @@ -2119,10 +2153,7 @@ export class MiniDb { private async persistentFiles(): Promise { const names = await fs.readdir(this.dir); - return names.filter((n) => - /^db\.(snapshot|wal|indexes\.json|compound-indexes\.json|textindexes\.json)$/.test(n) || - /^db\.text-.*\.postings$/.test(n), - ); + return names.filter(isPersistentFile); } private async copyIfExists(name: string, destDir: string): Promise { @@ -2192,11 +2223,7 @@ export class MiniDb { const names = await fs.readdir(srcDir); for (const name of names) { - if ( - /^db\.(snapshot|wal|indexes\.json|compound-indexes\.json|textindexes\.json)$/.test(name) || - /^db\.text-.*\.postings$/.test(name) || - name === 'backup.manifest.json' - ) { + if (isPersistentFile(name) || name === 'backup.manifest.json') { await fs.copyFile(path.join(srcDir, name), path.join(destDir, name)); } } diff --git a/packages/minidb/src/persistent-files.ts b/packages/minidb/src/persistent-files.ts new file mode 100644 index 00000000000..009f739dbc8 --- /dev/null +++ b/packages/minidb/src/persistent-files.ts @@ -0,0 +1,65 @@ +// src/persistent-files.ts +// +// The authoritative inventory of MiniDb's on-disk persistent files — the +// single source of truth that every file-set enumerator derives from: the +// cluster reader fingerprint (cluster/lock-pool.ts), backup/restore +// (index.ts), and the open-time stale-temp cleanup (index.ts). Before this +// module existed the set was hand-enumerated in at least four places and the +// lists had already drifted apart (db.compound-indexes.json was invisible to +// the fingerprint — review #17). Adding a persisted file now means adding it +// HERE, and no consumer can silently miss it. +// +// MiniDb's disk state is a compound document: the primary data pair +// (db.snapshot + db.wal), the index-definition sidecars, and the per-text- +// index postings files. This module holds name/pattern knowledge only; it +// performs no I/O. +// +// Internal to the package — NOT re-exported from the root entry point. +// +// TRANSITIONAL: stage 5's generations/ manifest absorbs this module (the +// manifest codec becomes the authority on the file set). Until then, never +// re-enumerate these names elsewhere. + +/** The primary data pair recovery pairs up: the snapshot, then the WAL. */ +export const SNAPSHOT_FILE = 'db.snapshot'; +export const WAL_FILE = 'db.wal'; + +/** Index-definition sidecars, rewritten atomically (tmp + rename) on every + * definition change. */ +export const SECONDARY_INDEXES_FILE = 'db.indexes.json'; +export const COMPOUND_INDEXES_FILE = 'db.compound-indexes.json'; +export const TEXT_INDEXES_FILE = 'db.textindexes.json'; +export const SIDECAR_FILES = [SECONDARY_INDEXES_FILE, COMPOUND_INDEXES_FILE, TEXT_INDEXES_FILE] as const; + +/** Per-text-index postings files (derived state, rebuilt on open and after + * each compaction) share one naming pattern with the index name embedded. */ +export const POSTINGS_PATTERN = /^db\.text-.*\.postings$/; + +/** The files the cluster reader fingerprint MUST track: a change to any of + * them means a cached read-only instance can no longer serve without a + * refresh. The WAL comes first — the lock pool's "WAL-only append" fast path + * compares every OTHER entry by position (see shardFingerprint). */ +export const FINGERPRINT_FILES = [WAL_FILE, SNAPSHOT_FILE, ...SIDECAR_FILES] as const; + +/** Is `name` one of MiniDb's persistent files (a primary data file, an + * index-definition sidecar, or a postings file)? backup/restore filter on + * this. */ +export function isPersistentFile(name: string): boolean { + return ( + name === SNAPSHOT_FILE || + name === WAL_FILE || + (SIDECAR_FILES as readonly string[]).includes(name) || + POSTINGS_PATTERN.test(name) + ); +} + +/** Atomic-write temp siblings a crashed previous run may have left behind + * (a compaction's snapshot/WAL temps, sidecar-definition temps). Only the + * sole writer may delete them at open — a read-only opener must never touch + * a live writer's in-flight temps. */ +export const STALE_TMP_FILES: readonly string[] = [SNAPSHOT_FILE, WAL_FILE, ...SIDECAR_FILES].map((f) => `${f}.tmp`); + +/** A failed postings rebuild orphans `db.text-*.postings.tmp` (its atomic + * rename never ran). Postings are pure derived state, so such temps are + * always safe for the writer to delete, for any index name. */ +export const STALE_POSTINGS_TMP_PATTERN = /^db\.text-.*\.postings\.tmp$/; diff --git a/packages/minidb/src/recovery.ts b/packages/minidb/src/recovery.ts index 90df57d158f..bec3d667d1a 100644 --- a/packages/minidb/src/recovery.ts +++ b/packages/minidb/src/recovery.ts @@ -7,12 +7,34 @@ // 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. +// +// GENERATION PAIRING (stat-pairing — a TRANSITIONAL implementation; stage +// 5's generations/ manifest replaces it with a generation-id comparison, +// with the replacement confined to recoverPass/sameGeneration and the +// attachValueReader hook below). MiniDb's disk state is a compound document: +// a compaction rotation swaps db.snapshot and db.wal in two renames, and a +// read-only opener that scans the two files unpaired can combine the OLD +// snapshot with the NEW truncated WAL — silently losing the data the +// snapshot had absorbed, and (in disk mode) reading values back through +// offsets that point into the wrong inode (review #15). recover() therefore +// runs bounded passes: each pass fingerprints both files (dev/ino/size of +// the opened fd BEFORE scanning it, a path re-stat AFTER the last read), and +// any generation switch — an inode change, a size shrink, a file appearing +// or disappearing mid-pass — discards the pass's whole result and retries +// with exponential backoff. A WAL that merely GREW on the same inode is safe +// (append-only; the extra frames are a natural staleness window that +// catch-up covers). Exhausting the retries throws +// RecoveryGenerationChurnError. The writer's own open walks the same code +// path but is naturally stable (it holds the write lock, and compaction only +// starts after recovery completes), so it costs two extra stat calls and +// changes zero behavior. 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, MAGIC } from './codec.js'; import type { FrameRef } from './codec.js'; +import { SNAPSHOT_FILE, WAL_FILE } from './persistent-files.js'; import type { Store, ValueLoc, ValueRef } from './store.js'; export type RecoveryMode = 'resync' | 'strict'; @@ -35,6 +57,13 @@ export interface RecoveryInfo { /** dev/ino of the WAL inode recovery scanned (both 0 when there was none). */ walDev: number; walIno: number; + /** dev/ino of the snapshot inode recovery scanned (both 0 when there was + * none). */ + snapshotDev: number; + snapshotIno: number; + /** Generation-churn retries recovery needed before it paired a consistent + * snapshot/WAL set (0 on a stable directory — see the file header). */ + generationRetries: number; } function readAtSync(fd: number, off: number, len: number): Buffer { @@ -130,29 +159,143 @@ function applyFrames(frames: FrameRef[], file: ValueLoc['file'], fd: number, sto } } +/** dev/ino/size identity of one persistent file at one moment. */ +interface FileIdentity { + dev: number; + ino: number; + size: number; +} + +/** The inode pair one consistent recovery pass actually scanned, handed to + * the disk-mode ValueReader attach check. null = the file did not exist. */ +export interface GenerationAnchors { + snapshot: { dev: number; ino: number } | null; + wal: { dev: number; ino: number } | null; +} + +/** Thrown when recovery kept detecting snapshot/WAL generation switches + * across every bounded retry — the writer is rotating files faster than a + * consistent pair can be scanned. Callers with a refresh loop (kap-server's + * readonly degrade path, the cluster shard reader) treat it as transient. */ +export class RecoveryGenerationChurnError extends Error { + readonly code = 'RECOVERY_GENERATION_CHURN'; + constructor(readonly attempts: number) { + super(`recovery: snapshot/WAL generation kept changing across ${attempts} attempt(s)`); + this.name = 'RecoveryGenerationChurnError'; + } +} + +const GENERATION_RETRY_BASE_MS = 5; + +const sleep = (ms: number): Promise => new Promise((r) => setTimeout(r, ms)); + +function statIdentity(p: string): FileIdentity | null { + try { + const st = fsSync.statSync(p); + return { dev: st.dev, ino: st.ino, size: st.size }; + } catch (e) { + if ((e as NodeJS.ErrnoException).code === 'ENOENT') return null; + throw e; + } +} + +/** The pairing rule (see the file header): the file a pass scanned must still + * be the file at its path after the pass's last read — same dev+ino, and a + * size that never dropped below `sizeFloor` (append-only growth on the same + * inode is safe: the extra bytes are a staleness window catch-up covers). + * A null↔non-null transition (the file appeared or vanished mid-pass) cannot + * be verified and is treated as a generation switch. */ +function sameGeneration(scanned: FileIdentity | null, after: FileIdentity | null, sizeFloor: number): boolean { + if (scanned === null || after === null) return scanned === null && after === null; + if (scanned.dev !== after.dev || scanned.ino !== after.ino) return false; + return after.size >= sizeFloor; +} + +/** Discard every record a churned pass applied, restoring the (always + * initially empty) Store for the next pass. del() keeps the bytes/expiry + * accounting consistent; stale TTL-heap entries are reaped lazily by their + * seq guard. (Map iteration survives deletion mid-iteration.) */ +function resetStore(store: Store): void { + for (const k of store.map.keys()) store.del(k); +} + export async function recover({ dir, store, mode = 'resync', truncate = true, valueMode = 'memory', + maxGenerationRetries = 4, + attachValueReader, }: { dir: string; store: Store; mode?: RecoveryMode; truncate?: boolean; valueMode?: ValueMode; + /** Bounded retries when a pass detects a generation switch (default 4, + * exponential backoff from 5 ms). Exhaustion throws + * RecoveryGenerationChurnError. */ + maxGenerationRetries?: number; + /** Disk-mode ValueReader attach point: called with the verified inode pair + * of an otherwise-consistent pass; must attach the reader to the current + * paths and report whether ITS handles are those same inodes. A false + * return discards the pass and retries the whole recovery — closing the + * "old offsets read a new file" window between recovery's final forensics + * and the reader attach. */ + attachValueReader?: (anchors: GenerationAnchors) => boolean; }): Promise { - const snapPath = path.join(dir, 'db.snapshot'); - const walPath = path.join(dir, 'db.wal'); + const snapPath = path.join(dir, SNAPSHOT_FILE); + const walPath = path.join(dir, WAL_FILE); + let delay = GENERATION_RETRY_BASE_MS; + for (let attempt = 0; ; attempt++) { + const pass = await recoverPass({ snapPath, walPath, store, mode, truncate, valueMode }); + if (pass.consistent && (!attachValueReader || attachValueReader(pass.anchors))) { + pass.info.generationRetries = attempt; + return pass.info; + } + // Generation churn: discard EVERY record this pass applied before + // retrying, so no partial application state leaks into a later pass (or + // survives inside the thrown error). + resetStore(store); + if (attempt >= maxGenerationRetries) throw new RecoveryGenerationChurnError(attempt + 1); + await sleep(delay); + delay *= 2; + } +} + +type RecoverPassResult = { consistent: false } | { consistent: true; info: RecoveryInfo; anchors: GenerationAnchors }; +/** One recovery pass: scan + apply the snapshot then the WAL, recording the + * identity of each opened fd BEFORE scanning it (forensics round 1), then + * re-stat both paths AFTER the last read (round 2) and apply the pairing + * rule. Returns the recovered state plus the scanned inode anchors when the + * pass is generation-consistent; the caller retries otherwise. */ +async function recoverPass({ + snapPath, + walPath, + store, + mode, + truncate, + valueMode, +}: { + snapPath: string; + walPath: string; + store: Store; + mode: RecoveryMode; + truncate: boolean; + valueMode: ValueMode; +}): Promise { let snapshotFrames = 0; let snapshotBytes = 0; let snapshotCorrupt: [number, number][] = []; + let snapScanned: FileIdentity | null = null; if (fsSync.existsSync(snapPath)) { const fd = fsSync.openSync(snapPath, 'r'); try { - snapshotBytes = fsSync.fstatSync(fd).size; + const st = fsSync.fstatSync(fd); + snapScanned = { dev: st.dev, ino: st.ino, size: st.size }; + snapshotBytes = st.size; const r = scanFrameRefsFd(fd, { onCorrupt: mode }); applyFrames(r.frames, 'snapshot', fd, store, valueMode); snapshotFrames = r.frames.length; @@ -167,24 +310,25 @@ export async function recover({ let walCorrupt: [number, number][] = []; let truncatedWal = false; let walScanEnd = 0; - let walDev = 0; - let walIno = 0; + let walScanned: FileIdentity | null = null; + // The size this pass itself leaves the WAL at: a torn-tail truncation is + // our own write, so the post-scan re-stat is compared against this floor, + // not against the pre-scan size. + let walSizeFloor = 0; if (fsSync.existsSync(walPath)) { const fd = fsSync.openSync(walPath, 'r'); - let walSize = 0; try { const st = fsSync.fstatSync(fd); - walSize = st.size; + walScanned = { dev: st.dev, ino: st.ino, size: st.size }; + walSizeFloor = st.size; walBytes = 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) { + if (last && last[1] === st.size) { // A torn/corrupt tail is normally truncated so the next writer appends // cleanly. In read-only mode (truncate = false) we must never mutate the // database files: a read-only opener racing a live writer could otherwise @@ -192,6 +336,7 @@ export async function recover({ if (truncate) { await fs.truncate(walPath, last[0]); truncatedWal = true; + walSizeFloor = last[0]; } } } finally { @@ -199,18 +344,34 @@ export async function recover({ } } + // Forensics round 2: re-stat both paths after every byte was read. + const snapAfter = statIdentity(snapPath); + const walAfter = statIdentity(walPath); + if (!sameGeneration(snapScanned, snapAfter, snapScanned?.size ?? 0)) return { consistent: false }; + if (!sameGeneration(walScanned, walAfter, walSizeFloor)) return { consistent: false }; + return { - snapshotFrames, - walFrames, - snapshotBytes, - walBytes, - truncatedWal, - corruptRanges: walCorrupt, - snapshotCorruptRanges: snapshotCorrupt, - lostBytes: [...walCorrupt, ...snapshotCorrupt].reduce((a, [s, e]) => a + (e - s), 0), - walScanEnd, - walDev, - walIno, + consistent: true, + info: { + snapshotFrames, + walFrames, + snapshotBytes, + walBytes, + truncatedWal, + corruptRanges: walCorrupt, + snapshotCorruptRanges: snapshotCorrupt, + lostBytes: [...walCorrupt, ...snapshotCorrupt].reduce((a, [s, e]) => a + (e - s), 0), + walScanEnd, + walDev: walScanned?.dev ?? 0, + walIno: walScanned?.ino ?? 0, + snapshotDev: snapScanned?.dev ?? 0, + snapshotIno: snapScanned?.ino ?? 0, + generationRetries: 0, // recover() overwrites with the real attempt count + }, + anchors: { + snapshot: snapScanned ? { dev: snapScanned.dev, ino: snapScanned.ino } : null, + wal: walScanned ? { dev: walScanned.dev, ino: walScanned.ino } : null, + }, }; } diff --git a/packages/minidb/src/value-reader.ts b/packages/minidb/src/value-reader.ts index 4377fd9cd3c..2b24133a079 100644 --- a/packages/minidb/src/value-reader.ts +++ b/packages/minidb/src/value-reader.ts @@ -20,9 +20,21 @@ export class ValueReader { this.walPath = path.join(dir, 'db.wal'); } - open(): void { + /** Open both files (null-safe per side) and return the dev/ino identity of + * each attached handle (null = the file does not exist). Recovery's + * generation pairing compares these against the inodes it scanned, so a + * rotation landing between the scan and this attach is detected instead of + * serving old offsets from a new file. */ + open(): { snapshot: { dev: number; ino: number } | null; wal: { dev: number; ino: number } | null } { this.snapshotFd = this.openIfExists(this.snapshotPath); this.walFd = this.openIfExists(this.walPath); + return { snapshot: this.ident(this.snapshotFd), wal: this.ident(this.walFd) }; + } + + private ident(fd: number | null): { dev: number; ino: number } | null { + if (fd === null) return null; + const st = fs.fstatSync(fd); + return { dev: st.dev, ino: st.ino }; } private openIfExists(file: string): number | null { diff --git a/packages/minidb/test/cluster/concurrent.test.ts b/packages/minidb/test/cluster/concurrent.test.ts index 61e51f1781b..e17d571ecb0 100644 --- a/packages/minidb/test/cluster/concurrent.test.ts +++ b/packages/minidb/test/cluster/concurrent.test.ts @@ -9,6 +9,7 @@ import assert from 'node:assert/strict'; import path from 'node:path'; import { MiniDb } from '../../src/index.js'; import { ClusterDb, shardDirName } from '../../src/cluster/index.js'; +import { ShardLockPool } from '../../src/cluster/lock-pool.js'; import { shardFor } from '../../src/cluster/utils.js'; import { tmpDir } from '../e2e/helpers/tmp.js'; import { keyOnShard, runWorker, runWorkerOk, rmrf, sleep } from './helpers.js'; @@ -277,3 +278,67 @@ test( } }, ); + +test( + 'fingerprint: a compound-index-only definition change refreshes the cached reader (multi-process)', + { timeout: 180_000 }, + async () => { + const dir = await tmpDir('minidb-cluster-mp-'); + try { + const shards = 4; + const hot = 1; + // Seed the hot shard and release all locks. + const setup = await ClusterDb.open({ dir, shardCount: shards, valueCodec: 'json', fsyncPolicy: 'no', lockHoldMs: 0 }); + const pre: string[] = []; + for (let seq = 0; pre.length < 200; seq++) { + const key = `pre:${seq}`; + if (shardFor(key, shards) === hot) pre.push(key); + } + for (let j = 0; j < pre.length; j++) { + await setup.set(pre[j]!, { n: j, c: `c${j % 7}` }); + } + await setup.close(); + + // The cached read-only shard reader under test. The pool IS the + // fingerprint machinery (a ClusterDb would not expose shard-level + // compound definitions to assert on), so drive it directly. + const pool = new ShardLockPool({ + writerOpts: { valueCodec: 'json' }, + readerOpts: { valueCodec: 'json' }, + lockRenewMs: 0, + lockAcquireTimeoutMs: 5_000, + lockHoldMs: 0, + maxWriters: 4, + maxReaders: 4, + readOnly: true, + applyDefs: async () => {}, + }); + const shardDir = path.join(dir, shardDirName(hot, shards)); + const compoundNames = () => pool.withReader(hot, shardDir, (db) => db.listCompoundIndexes().map((i) => i.name)); + assert.deepEqual(await compoundNames(), []); // warms and caches the reader + const stats0 = { ...pool.stats }; + + // A writer PROCESS creates only a compound index: no data writes, just + // the sidecar rewrite. The next read must detect it from the + // fingerprint and FULLY reopen — an incremental WAL catch-up cannot + // carry a definition change. (Before the fingerprint tracked + // db.compound-indexes.json this change was invisible forever.) + await runWorkerOk(['compound-defs', dir, String(shards), 'create'], { timeoutMs: 120_000 }); + assert.deepEqual(await compoundNames(), ['cg'], 'the refreshed reader sees the new compound index'); + assert.equal(pool.stats.readerReopens - stats0.readerReopens, 1, 'the sidecar change forced a full reopen'); + assert.equal(pool.stats.incrementalCatchups - stats0.incrementalCatchups, 0, 'no incremental catch-up on a def-only change'); + // The refreshed instance still serves data correctly. + const v = await pool.withReader(hot, shardDir, (db) => db.get(pre[0]!)); + assert.equal((v as Doc | undefined)?.n, 0); + + // Dropping it again is detected the same way. + await runWorkerOk(['compound-defs', dir, String(shards), 'drop'], { timeoutMs: 120_000 }); + assert.deepEqual(await compoundNames(), [], 'the refreshed reader sees the drop'); + assert.equal(pool.stats.readerReopens - stats0.readerReopens, 2); + assert.equal(pool.stats.incrementalCatchups - stats0.incrementalCatchups, 0); + await pool.closeAll(); + } finally { + await rmrf(dir); + } + }, +); diff --git a/packages/minidb/test/cluster/mp-worker.ts b/packages/minidb/test/cluster/mp-worker.ts index b28d0b6eb34..446c10bf7aa 100644 --- a/packages/minidb/test/cluster/mp-worker.ts +++ b/packages/minidb/test/cluster/mp-worker.ts @@ -223,6 +223,20 @@ async function main(): Promise { return; } + if (mode === 'compound-defs') { + // compound-defs — change ONLY the + // compound index definitions (sidecar rewrites, no data writes), so the + // parent can verify its cached shard reader detects the change purely + // from the file fingerprint. + const [dir, shardCount, action] = rest; + const db = await ClusterDb.open({ dir: dir!, shardCount: Number(shardCount), valueCodec: 'json', lockHoldMs: 0 }); + if (action === 'create') await db.createCompoundIndex('cg', { groupBy: 'c', orderBy: 'n' }); + else await db.dropCompoundIndex('cg'); + out({ ok: 1, mode, action }); + await db.close(); + return; + } + out({ ok: 0, error: `unknown mode: ${mode}` }); process.exit(1); } diff --git a/packages/minidb/test/compaction-fault.test.ts b/packages/minidb/test/compaction-fault.test.ts index 65b1f3ef7bb..c38b78e23eb 100644 --- a/packages/minidb/test/compaction-fault.test.ts +++ b/packages/minidb/test/compaction-fault.test.ts @@ -6,8 +6,11 @@ // // The later sections cover compaction ROTATION failures end-to-end (through a // real MiniDb on a real temp dir): a throw at wal.close(), at the WAL rename, -// or at the new WAL's open() must not wedge the database — the seal is -// one-way, so recovery swaps in a fresh WAL on db.walPath. +// at the new WAL's open(), or at the rotation's STRICT directory fsyncs must +// not wedge the database — the seal is one-way, so recovery swaps in a fresh +// WAL on db.walPath. A dir-fsync failure additionally must ABORT the rotation +// (it breaks the rename-durability invariant), leaving the disk on one of the +// crash-safe snapshot/WAL pairings. // // NOTE: each test resets the module registry and re-mocks node:fs/promises so a // fresh import of compaction.ts picks up that test's mocked fs. @@ -95,7 +98,7 @@ test('copyFileRange tolerates a destination close() failure (best-effort close)' await expect(copyFileRange('/tmp/src', '/tmp/dst', 0, 0)).resolves.toBeUndefined(); }); -test('fsyncDir swallows a sync() failure and still closes the handle', async () => { +test('fsyncDir strict mode propagates a sync() failure and still closes the handle', async () => { let closed = false; mockFsPromises(async () => ({ sync: async () => { @@ -106,10 +109,49 @@ test('fsyncDir swallows a sync() failure and still closes the handle', async () }, })); const { fsyncDir } = await import('../src/compaction.js'); - await fsyncDir('/tmp/whatever'); + await assert.rejects(fsyncDir('/tmp/whatever', { strict: true }), /sync failed/); assert.equal(closed, true, 'close() is called even after sync() throws'); }); +test('fsyncDir degrades an unsupported directory fsync explicitly: warn once, mark stats, continue', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + mockFsPromises(async () => ({ + sync: async () => { + throw Object.assign(new Error('invalid argument'), { code: 'EINVAL' }); + }, + close: async () => {}, + })); + const { fsyncDir } = await import('../src/compaction.js'); + const stats: { dirFsyncUnsupported?: boolean } = {}; + // Even strict mode does NOT reject on EINVAL/ENOTSUP: the platform simply + // cannot fsync directories, so the downgrade is made observable instead. + await fsyncDir('/tmp/whatever', { strict: true, stats }); + assert.equal(stats.dirFsyncUnsupported, true); + assert.equal(warn.mock.calls.length, 1); + // One-shot: the flag is already set, so later calls stay silent. + await fsyncDir('/tmp/whatever', { strict: true, stats }); + assert.equal(warn.mock.calls.length, 1); + } finally { + warn.mockRestore(); + } +}); + +test('fsyncDir non-strict mode keeps swallowing ordinary failures (legacy callers)', async () => { + let closed = false; + mockFsPromises(async () => ({ + sync: async () => { + throw new Error('sync failed'); + }, + close: async () => { + closed = true; + }, + })); + const { fsyncDir } = await import('../src/compaction.js'); + await expect(fsyncDir('/tmp/whatever')).resolves.toBeUndefined(); + assert.equal(closed, true); +}); + async function tmpDir(): Promise { return fs.mkdtemp(path.join(os.tmpdir(), 'minidb-rotation-fault-')); @@ -138,6 +180,29 @@ function mockFsWithFaults(faults: { vi.doMock('node:fs/promises', () => ({ ...mocked, default: mocked })); } +// Passthrough node:fs/promises mock that breaks the sync() of the db +// DIRECTORY handle on the chosen open calls — the rotation's two fsyncDir +// calls are the only directory syncs in the system, so counting directory +// opens targets "the first/second dir fsync of the first compaction" +// deterministically. +function mockFsWithDirSyncFault(dir: string, failOnCalls: ReadonlySet): void { + let dirOpens = 0; + const open = (async (p: PathLike, flags?: string | number, mode?: string | number) => { + const h = await fs.open(p, flags as string | number | undefined, mode as never); + if (String(p) === dir && flags === 'r') { + dirOpens++; + if (failOnCalls.has(dirOpens)) { + h.sync = async () => { + throw Object.assign(new Error('injected dir fsync failure'), { code: 'EIO' }); + }; + } + } + return h; + }) as typeof fs.open; + const mocked = { ...fs, open }; + vi.doMock('node:fs/promises', () => ({ ...mocked, default: mocked })); +} + test('wal.close() propagates a final-sync failure but still releases the file handle', async () => { const { WAL } = await import('../src/wal.js'); const { encodeFrame, FrameParser, TYPE_SET } = await import('../src/codec.js'); @@ -335,6 +400,101 @@ test('rotation: a new-WAL open() failure after the renames leaves the db writabl } }); +test('rotation: the first directory fsync failure aborts the rotation; rollback + consistent disk pairing', async () => { + const dir = await tmpDir(); + // Fail the rotation's FIRST dir fsync (after the snapshot rename, before + // the WAL rename). + mockFsWithDirSyncFault(dir, new Set([1])); + const { MiniDb } = await import('../src/index.js'); + try { + let db = await MiniDb.open({ dir, valueCodec: 'string', fsyncPolicy: 'no', compactThresholdBytes: 1 << 30 }); + const N = 200; + for (let i = 0; i < N; i++) await db.set(`k${i}`, `v${i}`); + + // The strict fsyncDir turns the failure into an abort, not a swallow. + await assert.rejects(db.compact(), /injected dir fsync failure/); + assert.equal(db.stats.compactions, 0); + assert.equal(db.stats.compactErrors, 1); + assert.match(String(db.lastCompactError), /injected dir fsync failure/); + assert.notEqual(db.stats.dirFsyncUnsupported, true, 'an I/O error is not the platform-degrade path'); + + // The abort went through the existing rollback: the write path is back. + await db.set('post', 'still-writable'); + assert.equal(db.get('k0'), 'v0'); + assert.equal(db.get('post'), 'still-writable'); + + // The rotation died right after the snapshot rename: the disk holds the + // NEW snapshot paired with the OLD full WAL — the crash-safe intermediate + // pairing, which a fresh open recovers completely. + const probe = await MiniDb.open({ dir, valueCodec: 'string', readOnly: true }); + assert.equal(probe.size, N + 1, 'new snapshot + old full WAL is a consistent complete pairing'); + assert.equal(probe.get('k0'), 'v0'); + assert.equal(probe.get('post'), 'still-writable'); + await probe.close(); + + // The fault was one-shot: an explicit retry compacts cleanly. + await db.compact(); + assert.equal(db.stats.compactions, 1); + assert.equal(db.stats.compactErrors, 1); + assert.equal(db.lastCompactError, null); + await db.close(); + + db = await MiniDb.open({ dir, valueCodec: 'string' }); + assert.equal(db.size, N + 1); + assert.equal(db.get(`k${N - 1}`), `v${N - 1}`); + assert.equal(db.get('post'), 'still-writable'); + await db.close(); + } finally { + await fs.rm(dir, { recursive: true, force: true }); + } +}); + +test('rotation: the second directory fsync failure aborts after both renames; rollback + consistent disk pairing', async () => { + const dir = await tmpDir(); + // Fail the rotation's SECOND dir fsync (after the WAL rename — the new + // layout is already on disk). + mockFsWithDirSyncFault(dir, new Set([2])); + const { MiniDb } = await import('../src/index.js'); + try { + let db = await MiniDb.open({ dir, valueCodec: 'string', fsyncPolicy: 'no', compactThresholdBytes: 1 << 30 }); + const N = 200; + for (let i = 0; i < N; i++) await db.set(`k${i}`, `v${i}`); + + await assert.rejects(db.compact(), /injected dir fsync failure/); + assert.equal(db.stats.compactions, 0); + assert.equal(db.stats.compactErrors, 1); + assert.match(String(db.lastCompactError), /injected dir fsync failure/); + + // The write path recovered through the rotation-failure rollback. + await db.set('post', 'still-writable'); + assert.equal(db.get('k0'), 'v0'); + assert.equal(db.get('post'), 'still-writable'); + + // Both renames had landed when the second fsync failed: the disk holds + // the NEW snapshot + NEW WAL — the complete new generation. + const probe = await MiniDb.open({ dir, valueCodec: 'string', readOnly: true }); + assert.equal(probe.size, N + 1, 'new snapshot + new WAL is the complete new generation'); + assert.equal(probe.get(`k${N - 1}`), `v${N - 1}`); + assert.equal(probe.get('post'), 'still-writable'); + await probe.close(); + + await db.compact(); + assert.equal(db.stats.compactions, 1); + assert.equal(db.stats.compactErrors, 1); + assert.equal(db.lastCompactError, null); + await db.close(); + + db = await MiniDb.open({ dir, valueCodec: 'string' }); + assert.equal(db.size, N + 1); + assert.equal(db.get('k0'), 'v0'); + assert.equal(db.get(`k${N - 1}`), `v${N - 1}`); + assert.equal(db.get('post'), 'still-writable'); + await db.close(); + } finally { + await fs.rm(dir, { recursive: true, force: true }); + } +}); + test('a compaction whose onCompacted hook throws counts as a compactError, not a compaction', async () => { const { MiniDb } = await import('../src/index.js'); const dir = await tmpDir(); diff --git a/packages/minidb/test/recovery.test.ts b/packages/minidb/test/recovery.test.ts index 44221c13815..66f94e6eada 100644 --- a/packages/minidb/test/recovery.test.ts +++ b/packages/minidb/test/recovery.test.ts @@ -1,11 +1,18 @@ // test/recovery.test.js -import { test } from 'vitest'; +import { afterEach, test, vi } from 'vitest'; import assert from 'node:assert/strict'; import fs from 'node:fs/promises'; +import type { PathLike } from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { MiniDb } from '../src/index.js'; -import { HEADER_SIZE, CRC_SIZE } from '../src/codec.js'; +import { HEADER_SIZE, CRC_SIZE, encodeFrame, TYPE_SET } from '../src/codec.js'; + +afterEach(() => { + vi.doUnmock('node:fs'); + vi.doUnmock('node:fs/promises'); + vi.resetModules(); +}); async function tmpDir() { return fs.mkdtemp(path.join(os.tmpdir(), 'minidb-recover-')); @@ -191,3 +198,308 @@ for (const valueMode of ['memory', 'disk'] as const) { } }); } + +// ---- generation pairing (stat-pairing; review #15) -------------------------- +// +// Fault-injection tests for recover()'s snapshot/WAL generation pairing. The +// injections run INSIDE mocked fs calls, which makes them deterministic: they +// fire exactly at recover's forensic points (the post-scan path re-stat, the +// ValueReader attach) — no sleeps, no races. The mocks affect only DYNAMIC +// imports made after them (vi.resetModules), so the statically-imported +// MiniDb above keeps the real fs for seeding and verification. + +type FsSyncModule = typeof import('node:fs'); + +/** Mock node:fs so statSync/openSync on the db.wal path fire the given hook + * (with the call count) before delegating to the real module. Everything + * else passes through untouched. */ +function mockFsSyncForWal( + walPath: string, + hooks: { + onStatSync?: (call: number, real: FsSyncModule) => void; + onOpenSync?: (call: number, real: FsSyncModule) => void; + }, +): void { + let statCalls = 0; + let openCalls = 0; + vi.doMock('node:fs', async () => { + const real = await vi.importActual('node:fs'); + const statSync = ((p: unknown, ...args: unknown[]) => { + if (String(p) === walPath) hooks.onStatSync?.(++statCalls, real); + return (real.statSync as (...a: unknown[]) => unknown)(p, ...args); + }) as typeof real.statSync; + const openSync = ((p: unknown, ...args: unknown[]) => { + if (String(p) === walPath) hooks.onOpenSync?.(++openCalls, real); + return (real.openSync as (...a: unknown[]) => unknown)(p, ...args); + }) as typeof real.openSync; + const mocked = { ...real, statSync, openSync }; + return { ...mocked, default: mocked }; + }); +} + +/** Atomically replace db.wal with an identical-content copy on a NEW inode — + * exactly what a compaction rotation's rename does. */ +function swapWalInodeSync(real: FsSyncModule, walPath: string): void { + real.copyFileSync(walPath, `${walPath}.swap`); + real.renameSync(`${walPath}.swap`, walPath); +} + +test('generation pairing: a rotation-like WAL inode swap at the post-scan forensics is retried to a consistent read-only open', async () => { + const dir = await tmpDir(); + try { + const writer = await MiniDb.open({ dir, valueCodec: 'string', fsyncPolicy: 'no', autoCompact: false }); + for (let i = 0; i < 50; i++) await writer.set(`k${i}`, `v${i}`); + + // The injection fires when recover takes its post-scan path stat of + // db.wal (forensics round 2): the WAL is swapped for a new inode between + // the scan and the verification — precisely the rotation race the + // pairing exists to catch. One-shot: the retried pass sees a stable pair. + const walPath = path.join(dir, 'db.wal'); + let swaps = 0; + mockFsSyncForWal(walPath, { + onStatSync: (call, real) => { + if (call === 1) { + swapWalInodeSync(real, walPath); + swaps++; + } + }, + }); + const { MiniDb: MockedMiniDb } = await import('../src/index.js'); + const reader = await MockedMiniDb.open({ dir, valueCodec: 'string', readOnly: true }); + assert.equal(swaps, 1); + assert.equal(reader.recoveryInfo!.generationRetries, 1, 'the swapped inode forced exactly one retry'); + assert.equal(reader.size, 50); + assert.equal(reader.get('k0'), 'v0'); + assert.equal(reader.get('k49'), 'v49'); + await reader.close(); + await writer.close(); + } finally { + await fs.rm(dir, { recursive: true, force: true }); + } +}); + +test('generation pairing: churn beyond the retry budget throws RECOVERY_GENERATION_CHURN and leaves no partial state', async () => { + const dir = await tmpDir(); + try { + const writer = await MiniDb.open({ dir, valueCodec: 'string', fsyncPolicy: 'no', autoCompact: false }); + for (let i = 0; i < 50; i++) await writer.set(`k${i}`, `v${i}`); + await writer.close(); + + // EVERY pass's post-scan stat swaps the WAL inode: recovery can never + // pair a consistent set and must exhaust its bounded retries (1 + 4). + const walPath = path.join(dir, 'db.wal'); + let swaps = 0; + mockFsSyncForWal(walPath, { + onStatSync: (_call, real) => { + swapWalInodeSync(real, walPath); + swaps++; + }, + }); + const { MiniDb: MockedMiniDb } = await import('../src/index.js'); + await assert.rejects( + MockedMiniDb.open({ dir, valueCodec: 'string', readOnly: true }), + (e: unknown) => + (e as { code?: string }).code === 'RECOVERY_GENERATION_CHURN' && (e as Error).name === 'RecoveryGenerationChurnError', + ); + assert.equal(swaps, 5, 'one swap per pass: the initial attempt plus 4 retries'); + + // No partial application state survived the failed open: the swaps + // preserved content, and a clean open recovers every key. + const db = await MiniDb.open({ dir, valueCodec: 'string', readOnly: true }); + assert.equal(db.size, 50); + assert.equal(db.get('k0'), 'v0'); + assert.equal(db.get('k49'), 'v49'); + await db.close(); + } finally { + await fs.rm(dir, { recursive: true, force: true }); + } +}); + +test('generation pairing: append-only WAL growth between the forensic rounds does not trigger a retry', async () => { + const dir = await tmpDir(); + try { + const writer = await MiniDb.open({ dir, valueCodec: 'string', fsyncPolicy: 'no', autoCompact: false }); + for (let i = 0; i < 50; i++) await writer.set(`k${i}`, `v${i}`); + await writer.close(); + + // A live writer's append landing between recover's scan and its post-scan + // stat (same inode, size grew) is the safe staleness window — NOT a + // generation switch — and must not cost a retry. + const walPath = path.join(dir, 'db.wal'); + mockFsSyncForWal(walPath, { + onStatSync: (call, real) => { + if (call === 1) { + real.appendFileSync(walPath, encodeFrame({ type: TYPE_SET, key: Buffer.from('late'), value: Buffer.from('z') })); + } + }, + }); + const { MiniDb: MockedMiniDb } = await import('../src/index.js'); + const reader = await MockedMiniDb.open({ dir, valueCodec: 'string', readOnly: true }); + assert.equal(reader.recoveryInfo!.generationRetries, 0, 'append-only growth must not be retried'); + assert.equal(reader.size, 50); + // The late frame landed after the scan: outside the recovered view, to be + // picked up by a later catch-up instead. + assert.equal(reader.get('late'), undefined); + assert.equal(reader.get('k49'), 'v49'); + await reader.close(); + } finally { + await fs.rm(dir, { recursive: true, force: true }); + } +}); + +test('generation pairing (disk mode): a ValueReader attach to the wrong inode retries the whole recovery', async () => { + const dir = await tmpDir(); + try { + const writer = await MiniDb.open({ dir, valueCodec: 'string', valueMode: 'disk', fsyncPolicy: 'no', autoCompact: false }); + for (let i = 0; i < 50; i++) await writer.set(`k${i}`, `v${i}`); + await writer.close(); + + // The swap fires when the ValueReader opens db.wal to attach (the second + // openSync: the first was recovery's scan) — after recovery's forensics + // verified the old inode. The attach check must reject the mismatched + // handle and retry the whole recovery against the new generation. + const walPath = path.join(dir, 'db.wal'); + let swaps = 0; + mockFsSyncForWal(walPath, { + onOpenSync: (call, real) => { + if (call === 2) { + swapWalInodeSync(real, walPath); + swaps++; + } + }, + }); + const { MiniDb: MockedMiniDb } = await import('../src/index.js'); + const reader = await MockedMiniDb.open({ dir, valueCodec: 'string', valueMode: 'disk', readOnly: true }); + assert.equal(swaps, 1); + assert.equal(reader.recoveryInfo!.generationRetries, 1, 'the mismatched attach forced exactly one retry'); + // Every pointer reads back the right bytes through the correctly-attached + // reader. + assert.equal(reader.size, 50); + for (let i = 0; i < 50; i++) assert.equal(reader.get(`k${i}`), `v${i}`); + await reader.close(); + } finally { + await fs.rm(dir, { recursive: true, force: true }); + } +}); + +test('generation pairing (disk mode): a failing ValueReader open() closes the partially attached reader (no fd leak)', async () => { + const dir = await tmpDir(); + try { + // Seed with a snapshot on disk so the attach opens (and would leak) the + // snapshot fd before the WAL open throws. + const writer = await MiniDb.open({ dir, valueCodec: 'string', valueMode: 'disk', fsyncPolicy: 'no', autoCompact: false }); + for (let i = 0; i < 50; i++) await writer.set(`k${i}`, `v${i}`); + await writer.compact(); + await writer.close(); + + // The throw fires when the ValueReader opens db.wal to attach (the second + // openSync on the WAL path: the first was recovery's scan) — after the + // snapshot fd is already open. The reader is never published, so only the + // callback itself can close it. + const walPath = path.join(dir, 'db.wal'); + mockFsSyncForWal(walPath, { + onOpenSync: (call) => { + if (call === 2) throw Object.assign(new Error('injected wal open failure'), { code: 'EMFILE' }); + }, + }); + const { MiniDb: MockedMiniDb } = await import('../src/index.js'); + const { ValueReader } = await import('../src/value-reader.js'); + let closes = 0; + const origClose = ValueReader.prototype.close; + ValueReader.prototype.close = function (this: InstanceType): void { + closes++; + origClose.call(this); + }; + try { + await assert.rejects( + MockedMiniDb.open({ dir, valueCodec: 'string', valueMode: 'disk', readOnly: true }), + (e: unknown) => (e as { code?: string }).code === 'EMFILE', + ); + } finally { + ValueReader.prototype.close = origClose; + } + assert.equal(closes, 1, 'the partially attached ValueReader was closed instead of leaking its snapshot fd'); + + // The injected failure was transient: a clean open recovers everything. + const db = await MiniDb.open({ dir, valueCodec: 'string', valueMode: 'disk', readOnly: true }); + assert.equal(db.size, 50); + assert.equal(db.get('k49'), 'v49'); + await db.close(); + } finally { + await fs.rm(dir, { recursive: true, force: true }); + } +}); + +test('generation pairing: a stable writer costs a read-only open zero retries', async () => { + const dir = await tmpDir(); + try { + const writer = await MiniDb.open({ dir, valueCodec: 'string', fsyncPolicy: 'no', autoCompact: false }); + for (let i = 0; i < 20; i++) await writer.set(`k${i}`, `v${i}`); + + const reader = await MiniDb.open({ dir, valueCodec: 'string', readOnly: true }); + assert.equal(reader.recoveryInfo!.generationRetries, 0); + assert.equal(reader.size, 20); + await reader.close(); + await writer.close(); + } finally { + await fs.rm(dir, { recursive: true, force: true }); + } +}); + +test('a read-only open racing a compaction rotation always recovers one complete generation', { timeout: 30_000 }, async () => { + // Deterministic interleave, no sleeps: the writer's rotation is parked + // BETWEEN its two renames (the new snapshot is already in place, the old + // full WAL is still at db.wal) while the read-only open runs its whole + // recovery inside that window. The only consistent outcomes are one + // complete generation or the other — never a mix. + let armed = true; + let parked!: () => void; + let release!: () => void; + const parkedPromise = new Promise((r) => (parked = r)); + const gate = new Promise((r) => (release = r)); + const rename = async (src: PathLike, dst: PathLike): Promise => { + if (armed && String(src).endsWith('db.wal.tmp') && String(dst).endsWith('db.wal')) { + armed = false; + parked(); + await gate; + } + return fs.rename(src, dst); + }; + const mocked = { ...fs, rename }; + vi.doMock('node:fs/promises', () => ({ ...mocked, default: mocked })); + + const { MiniDb: MockedMiniDb } = await import('../src/index.js'); + const dir = await tmpDir(); + try { + const writer = await MockedMiniDb.open({ dir, valueCodec: 'string', fsyncPolicy: 'no', autoCompact: false }); + const N = 100; + for (let i = 0; i < N; i++) await writer.set(`k${i}`, `v${i}`); + + const compactPromise = writer.compact(); + await parkedPromise; // the rotation is now parked between the two renames + + // The reader opens in the mid-rotation window: new snapshot + old full + // WAL is a complete, consistent pairing (the replay is idempotent), and + // the stable window costs no retry. + const midReader = await MockedMiniDb.open({ dir, valueCodec: 'string', readOnly: true }); + assert.equal(midReader.size, N); + assert.equal(midReader.get('k0'), 'v0'); + assert.equal(midReader.get(`k${N - 1}`), `v${N - 1}`); + assert.equal(midReader.recoveryInfo!.generationRetries, 0); + await midReader.close(); + + release(); + await compactPromise; + + // After the rotation, a fresh open recovers the new generation, complete. + const postReader = await MockedMiniDb.open({ dir, valueCodec: 'string', readOnly: true }); + assert.equal(postReader.size, N); + assert.equal(postReader.get('k0'), 'v0'); + assert.equal(postReader.get(`k${N - 1}`), `v${N - 1}`); + assert.equal(postReader.recoveryInfo!.generationRetries, 0); + await postReader.close(); + await writer.close(); + } finally { + await fs.rm(dir, { recursive: true, force: true }); + } +}); From 4f2336c96b50b64d93299cfa9491a39b2fb01ea8 Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Mon, 3 Aug 2026 00:36:46 +0800 Subject: [PATCH 08/15] fix(minidb): serialize index-definition sidecar mutations, persist before publish - extract the promise-chain mutex into a shared createSerializer() and give each sidecar family (secondary/compound/text) its own chain: create/drop run uninterruptibly (memory change + rebuild + persist), different families stay independent, and the data write path never shares these chains - reverse the publication order to staged -> persist -> publish: a create stages the definition, rebuilds via the staged builder, persists the sidecar including the new definition, then publishes atomically; any failure discards the staged state leaving live and sidecar untouched (no phantom indexes, retry-safe); a drop persists the sidecar without the definition before removing it live; text index create/drop adopt the same pattern, replacing the hand-rolled unwind, and a dropping marker keeps compaction postings rebuilds out of the persist window - feed staged indexes from the incremental write path (add/remove/ checkUnique/checkUniqueBatch visit live+staged) so writes landing in the persist window are not lost at publish; queries still see live only - harden writeFileAtomic: instance-unique tmp names (.tmp-pid-seq), a strict fsyncDir after rename so a successful persist is crash durable, and whitelist-based stale-tmp cleanup that never touches lock tmp files --- packages/minidb/src/compound-index.ts | 80 ++++- packages/minidb/src/index-manager.ts | 272 +++++++++++------ packages/minidb/src/index.ts | 315 +++++++++++++------- packages/minidb/src/lockfile.ts | 24 +- packages/minidb/src/persistent-files.ts | 20 +- packages/minidb/src/serialize.ts | 33 ++ packages/minidb/test/compound-index.test.ts | 99 ++++++ packages/minidb/test/indexes-extra.test.ts | 284 ++++++++++++++++++ packages/minidb/test/text-index.test.ts | 156 ++++++++++ 9 files changed, 1056 insertions(+), 227 deletions(-) create mode 100644 packages/minidb/src/serialize.ts diff --git a/packages/minidb/src/compound-index.ts b/packages/minidb/src/compound-index.ts index bb54a879928..31be2ed4a46 100644 --- a/packages/minidb/src/compound-index.ts +++ b/packages/minidb/src/compound-index.ts @@ -41,19 +41,71 @@ function getPath(doc: unknown, path: string): unknown { export class CompoundIndexManager { readonly indexes = new Map(); + /** In-flight createCompoundIndex transactions (plan 10's staged → persist → + * publish), same discipline as IndexManager.staged: invisible to every + * query path until the sidecar persist succeeds and publish() moves the + * entry into the live map. */ + private readonly staged = new Map(); - create(name: string, def: CompoundIndexDef): void { - if (this.indexes.has(name)) throw new Error(`compound index "${name}" already exists`); + private static entry(def: CompoundIndexDef): CompoundEntry { const orderType = def.orderType ?? 'number'; const full: Required = { groupBy: def.groupBy, orderBy: def.orderBy, orderType }; const cmp = orderType === 'string' ? (cmpString as Comparator) : (cmpNumber as Comparator); - this.indexes.set(name, { def: full, cmp, groups: new Map(), byPk: new Map() }); + return { def: full, cmp, groups: new Map(), byPk: new Map() }; + } + + create(name: string, def: CompoundIndexDef): void { + if (this.indexes.has(name)) throw new Error(`compound index "${name}" already exists`); + this.indexes.set(name, CompoundIndexManager.entry(def)); + } + + /** Stage a new compound index definition off to the side (see `staged`). */ + stage(name: string, def: CompoundIndexDef): void { + if (this.indexes.has(name) || this.staged.has(name)) throw new Error(`compound index "${name}" already exists`); + this.staged.set(name, CompoundIndexManager.entry(def)); + } + + /** Rebuild ONE staged index from entries of { key, value, dt }. Touches + * nothing live, so a failure midway leaves every published index intact. */ + rebuildStaged(name: string, entries: Iterable<{ key: string | Buffer; value: unknown; dt?: Record | null }>): void { + const entry = this.staged.get(name); + if (!entry) throw new Error(`no staged compound index: ${name}`); + for (const { key, value, dt } of entries) { + this.addToEntry(entry, typeof key === 'string' ? key : Buffer.from(key).toString('binary'), value, dt ?? null); + } + } + + /** The staged definition in its persisted (CompoundIndexInfo) shape. */ + stagedInfo(name: string): CompoundIndexInfo { + const e = this.staged.get(name); + if (!e) throw new Error(`no staged compound index: ${name}`); + return { name, groupBy: e.def.groupBy, orderBy: e.def.orderBy, orderType: e.def.orderType }; + } + + /** Move a staged index into the live registry (its sidecar persist already + * succeeded). */ + publish(name: string): void { + const entry = this.staged.get(name); + if (!entry) throw new Error(`no staged compound index: ${name}`); + this.staged.delete(name); + this.indexes.set(name, entry); + } + + /** Drop a staged index without publishing it (the create failed). */ + discardStaged(name: string): void { + this.staged.delete(name); } drop(name: string): boolean { return this.indexes.delete(name); } + /** Live + staged count (a staged entry must be fed by the write paths + * exactly like a live one; see `staged`). */ + get size(): number { + return this.indexes.size + this.staged.size; + } + list(): CompoundIndexInfo[] { return [...this.indexes.entries()].map(([name, e]) => ({ name, @@ -111,19 +163,25 @@ export class CompoundIndexManager { } } - /** Add/update a document across all compound indexes. */ + /** Add/update a document across all compound indexes — live AND staged (a + * staged entry is kept exactly as current as the live ones, so publish() + * is a bare map move; see `staged`). */ add(pk: string, doc: unknown, dt: Record | null): void { for (const entry of this.indexes.values()) this.addToEntry(entry, pk, doc, dt); + for (const entry of this.staged.values()) this.addToEntry(entry, pk, doc, dt); } remove(pk: string, _doc?: unknown, _dt?: Record | null): void { - for (const entry of this.indexes.values()) { - const prev = entry.byPk.get(pk); - if (prev) { - const oldList = entry.groups.get(prev.group); - if (oldList) oldList.delete(prev.order, pk); - entry.byPk.delete(pk); - } + for (const entry of this.indexes.values()) CompoundIndexManager.removeFromEntry(entry, pk); + for (const entry of this.staged.values()) CompoundIndexManager.removeFromEntry(entry, pk); + } + + private static removeFromEntry(entry: CompoundEntry, pk: string): void { + const prev = entry.byPk.get(pk); + if (prev) { + const oldList = entry.groups.get(prev.group); + if (oldList) oldList.delete(prev.order, pk); + entry.byPk.delete(pk); } } diff --git a/packages/minidb/src/index-manager.ts b/packages/minidb/src/index-manager.ts index 6fe49b0213e..af565c98d5e 100644 --- a/packages/minidb/src/index-manager.ts +++ b/packages/minidb/src/index-manager.ts @@ -124,28 +124,188 @@ function insertDoc(idx: AnyIndex, pk: string, doc: unknown): void { } } +/** Remove one key from an index's given state (the per-index body of + * IndexManager.remove). */ +function removeFromIndex(idx: AnyIndex, pk: string): void { + if (idx.type === 'range') { + const old = idx.byPk.get(pk); + if (old) { + for (const v of old) idx.list.delete(v, pk); + idx.byPk.delete(pk); + } + } else { + const keys = idx.byPk.get(pk); + if (keys) { + for (const sk of keys) { + const set = idx.map.get(sk); + if (set) { + set.delete(pk); + if (set.size === 0) idx.map.delete(sk); + } + } + idx.byPk.delete(pk); + } + } +} + +/** Throw a UniqueViolationError if adding `doc` for `pk` would violate this + * one index (the per-index body of IndexManager.checkUnique). */ +function checkUniqueOnIndex(idx: AnyIndex, pk: string, doc: unknown): void { + if (!idx.unique) return; + const value = getField(doc, idx.field); + if (value === undefined && idx.sparse) return; + for (const v of flatten(value)) { + if (idx.type === 'range') { + if (typeof v !== 'number' || !Number.isFinite(v)) continue; + const hit = idx.list.range({ gte: v, lte: v, count: 1 }); + if (hit.length && hit[0]!.val !== pk) throw new UniqueViolationError(idx.name, v); + } else { + const set = idx.map.get(scalarKey(v)); + if (set && (set.size > 1 || (set.size === 1 && !set.has(pk)))) { + throw new UniqueViolationError(idx.name, v); + } + } + } +} + +/** Validate one unique index for a batch of ops against the index state AFTER + * the whole batch (the per-index body of IndexManager.checkUniqueBatch; + * `lastOp` is the batch's last op per key). */ +function checkUniqueBatchOnIndex(idx: AnyIndex, lastOp: ReadonlyMap): void { + if (!idx.unique) return; + // Batch-local claims: value -> claiming pk. Two different keys finally + // claiming the same value is a conflict regardless of the live index. + // This also covers the "holder is touched and still claims the value" + // case: the holder's own final claims pass through this same map. + const claimed = new Map(); + for (const [pk, o] of lastOp) { + if (o.op === 'del') continue; + const value = getField(o.doc, idx.field); + if (value === undefined && idx.sparse) continue; + for (const v of flatten(value)) { + if (idx.type === 'range') { + if (typeof v !== 'number' || !Number.isFinite(v)) continue; + const prev = claimed.get(v); + if (prev !== undefined && prev !== pk) throw new UniqueViolationError(idx.name, v); + claimed.set(v, pk); + // Current holder in the live index, if any: a conflict unless it + // is the claimant itself or a key the batch vacates. + const hit = idx.list.range({ gte: v, lte: v, count: 1 }); + if (hit.length) assertVacated(idx, hit[0]!.val, pk, v, lastOp); + } else { + const sk = scalarKey(v); + const prev = claimed.get(sk); + if (prev !== undefined && prev !== pk) throw new UniqueViolationError(idx.name, v); + claimed.set(sk, pk); + const set = idx.map.get(sk); + if (set) for (const h of set) assertVacated(idx, h, pk, v, lastOp); + } + } + } +} + export class IndexManager { readonly indexes = new Map(); + /** Definitions of in-flight createIndex transactions (plan 10's staged → + * persist → publish): an index under construction lives ONLY here until + * the definition sidecar is durably persisted and publish() moves it into + * the live map. It is invisible to every QUERY path (get/list/find*), but + * the write-maintenance paths (add/remove/checkUnique) feed it exactly like + * a live index: the staged rebuild covered the store as of stage time and + * every later write transitions it, so publish() is a bare map move and a + * staged unique index already constrains writes during its persist window. + * A failed create discards the staged entry, so the live registry never + * carries a phantom. */ + private readonly staged = new Map(); + + /** Live + staged count. The write paths guard their secondary-index + * maintenance with this (a staged index must be fed exactly like a live + * one); query paths keep using `indexes` directly. */ + get size(): number { + return this.indexes.size + this.staged.size; + } - create(name: string, { field, type = 'equality', unique = false, sparse = true }: IndexDef = {} as IndexDef): AnyIndex { + /** Any unique index, live or staged. While a unique create is in its + * persist window the staged index is fully built and must already + * constrain/check writes (and route them through the unique-write + * serializer), or a concurrent write could violate the constraint the + * publish is about to enforce. */ + hasUnique(): boolean { + for (const idx of this.indexes.values()) if (idx.unique) return true; + for (const idx of this.staged.values()) if (idx.unique) return true; + return false; + } + + /** Validate the definition and construct the (empty) index state. */ + private static build(name: string, { field, type = 'equality', unique = false, sparse = true }: IndexDef): AnyIndex { if (!field) throw new TypeError('index requires a field'); + return type === 'range' + ? { + name, + field, + type, + unique, + sparse, + list: new SkipList({ compareKey: cmpNumber, compareVal: cmpString }), + byPk: new Map(), + } + : { name, field, type, unique, sparse, map: new Map(), byPk: new Map() }; + } + + create(name: string, def: IndexDef = {} as IndexDef): AnyIndex { + // Build first: the field validation precedes the name collision check, + // exactly as it did before the constructor was factored out. + const idx = IndexManager.build(name, def); if (this.indexes.has(name)) throw new Error(`index "${name}" already exists`); - const idx: AnyIndex = - type === 'range' - ? { - name, - field, - type, - unique, - sparse, - list: new SkipList({ compareKey: cmpNumber, compareVal: cmpString }), - byPk: new Map(), - } - : { name, field, type, unique, sparse, map: new Map(), byPk: new Map() }; this.indexes.set(name, idx); return idx; } + /** Stage a new index definition off to the side (see `staged`). The field + * validation runs before the name collision check, matching create()'s + * error precedence. */ + stage(name: string, def: IndexDef): void { + const idx = IndexManager.build(name, def); + if (this.indexes.has(name) || this.staged.has(name)) throw new Error(`index "${name}" already exists`); + this.staged.set(name, idx); + } + + /** Rebuild ONE staged index from an iterator of { key, value } (value = + * decoded doc). Unlike rebuild() this touches nothing live: a failure + * midway leaves every published index fully intact. */ + rebuildStaged(name: string, entries: Iterable<{ key: string | Buffer; value: unknown }>): void { + const idx = this.staged.get(name); + if (!idx) throw new Error(`no staged index: ${name}`); + for (const { key, value } of entries) { + if (!value || typeof value !== 'object') continue; + const pk = typeof key === 'string' ? key : Buffer.from(key).toString('binary'); + insertDoc(idx, pk, value); + } + } + + /** The staged definition in its persisted (IndexInfo) shape — the content a + * create transaction adds to the sidecar BEFORE publishing. */ + stagedInfo(name: string): IndexInfo { + const idx = this.staged.get(name); + if (!idx) throw new Error(`no staged index: ${name}`); + const { name: n, field, type, unique, sparse } = idx; + return { name: n, field, type, unique, sparse }; + } + + /** Move a staged index into the live registry. Pure in-memory switch — the + * sidecar persist already succeeded when this runs. */ + publish(name: string): void { + const idx = this.staged.get(name); + if (!idx) throw new Error(`no staged index: ${name}`); + this.staged.delete(name); + this.indexes.set(name, idx); + } + + /** Drop a staged index without publishing it (the create failed). */ + discardStaged(name: string): void { + this.staged.delete(name); + } + drop(name: string): boolean { return this.indexes.delete(name); } @@ -168,23 +328,9 @@ export class IndexManager { /** Throw a UniqueViolationError if adding `doc` for `pk` would violate a unique index. */ checkUnique(pk: string, doc: unknown): void { - for (const idx of this.indexes.values()) { - if (!idx.unique) continue; - const value = getField(doc, idx.field); - if (value === undefined && idx.sparse) continue; - for (const v of flatten(value)) { - if (idx.type === 'range') { - if (typeof v !== 'number' || !Number.isFinite(v)) continue; - const hit = idx.list.range({ gte: v, lte: v, count: 1 }); - if (hit.length && hit[0]!.val !== pk) throw new UniqueViolationError(idx.name, v); - } else { - const set = idx.map.get(scalarKey(v)); - if (set && (set.size > 1 || (set.size === 1 && !set.has(pk)))) { - throw new UniqueViolationError(idx.name, v); - } - } - } - } + for (const idx of this.indexes.values()) checkUniqueOnIndex(idx, pk, doc); + // A staged unique index already constrains writes (see `staged`). + for (const idx of this.staged.values()) checkUniqueOnIndex(idx, pk, doc); } /** @@ -205,47 +351,20 @@ export class IndexManager { const lastOp = new Map(); for (const o of ops) lastOp.set(o.pk, o); - for (const idx of this.indexes.values()) { - if (!idx.unique) continue; - // Batch-local claims: value -> claiming pk. Two different keys finally - // claiming the same value is a conflict regardless of the live index. - // This also covers the "holder is touched and still claims the value" - // case: the holder's own final claims pass through this same map. - const claimed = new Map(); - for (const [pk, o] of lastOp) { - if (o.op === 'del') continue; - const value = getField(o.doc, idx.field); - if (value === undefined && idx.sparse) continue; - for (const v of flatten(value)) { - if (idx.type === 'range') { - if (typeof v !== 'number' || !Number.isFinite(v)) continue; - const prev = claimed.get(v); - if (prev !== undefined && prev !== pk) throw new UniqueViolationError(idx.name, v); - claimed.set(v, pk); - // Current holder in the live index, if any: a conflict unless it - // is the claimant itself or a key the batch vacates. - const hit = idx.list.range({ gte: v, lte: v, count: 1 }); - if (hit.length) assertVacated(idx, hit[0]!.val, pk, v, lastOp); - } else { - const sk = scalarKey(v); - const prev = claimed.get(sk); - if (prev !== undefined && prev !== pk) throw new UniqueViolationError(idx.name, v); - claimed.set(sk, pk); - const set = idx.map.get(sk); - if (set) for (const h of set) assertVacated(idx, h, pk, v, lastOp); - } - } - } - } + for (const idx of this.indexes.values()) checkUniqueBatchOnIndex(idx, lastOp); + // A staged unique index already constrains writes (see `staged`). + for (const idx of this.staged.values()) checkUniqueBatchOnIndex(idx, lastOp); } /** * Verify that an already-built unique index contains no duplicate values. * Used when creating a unique index over pre-existing data: if the data - * already violates the constraint, the index must not be created. + * already violates the constraint, the index must not be created. A + * createIndex transaction validates its STAGED index (not yet reachable via + * get()), so the staged map is consulted first. */ assertUniqueValid(name: string): void { - const idx = this.get(name); + const idx = this.staged.get(name) ?? this.get(name); if (!idx.unique) return; if (idx.type === 'range') { const owner = new Map(); @@ -268,30 +387,13 @@ export class IndexManager { add(pk: string, doc: unknown): void { for (const idx of this.indexes.values()) insertDoc(idx, pk, doc); + // A staged index is kept exactly as current as the live ones (see `staged`). + for (const idx of this.staged.values()) insertDoc(idx, pk, doc); } remove(pk: string, _doc: unknown): void { - for (const idx of this.indexes.values()) { - if (idx.type === 'range') { - const old = idx.byPk.get(pk); - if (old) { - for (const v of old) idx.list.delete(v, pk); - idx.byPk.delete(pk); - } - } else { - const keys = idx.byPk.get(pk); - if (keys) { - for (const sk of keys) { - const set = idx.map.get(sk); - if (set) { - set.delete(pk); - if (set.size === 0) idx.map.delete(sk); - } - } - idx.byPk.delete(pk); - } - } - } + for (const idx of this.indexes.values()) removeFromIndex(idx, pk); + for (const idx of this.staged.values()) removeFromIndex(idx, pk); } findEq(name: string, value: unknown): string[] { diff --git a/packages/minidb/src/index.ts b/packages/minidb/src/index.ts index 19e5e9cbded..e03f71de4f0 100644 --- a/packages/minidb/src/index.ts +++ b/packages/minidb/src/index.ts @@ -15,7 +15,7 @@ import { WAL } from './wal.js'; import type { WalPoison } from './wal.js'; import { ValueReader } from './value-reader.js'; import { recover, catchUpWal, frameToOps } from './recovery.js'; -import { compact, shouldCompact } from './compaction.js'; +import { compact, shouldCompact, fsyncDir } from './compaction.js'; import { SNAPSHOT_FILE, WAL_FILE, @@ -25,6 +25,7 @@ import { SIDECAR_FILES, STALE_TMP_FILES, STALE_POSTINGS_TMP_PATTERN, + isStaleTmpFile, isPersistentFile, } from './persistent-files.js'; import { IndexManager, UniqueViolationError } from './index-manager.js'; @@ -34,6 +35,7 @@ import { createNgramTokenizer } from './trigram.js'; import { CompoundIndexManager } from './compound-index.js'; import { getPath, match, project } from './query.js'; import { LockFile, LockError } from './lockfile.js'; +import { createSerializer } from './serialize.js'; import { encodeFrame, encodeBatchOps, scanBatchOpRefs, HEADER_SIZE, TYPE_SET, TYPE_DEL, TYPE_BATCH } from './codec.js'; import type { BatchOp as EncodedBatchOp, FrameRef } from './codec.js'; import type { FsyncPolicy } from './wal.js'; @@ -144,12 +146,33 @@ 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); +// Distinct tmp name per write (`tmp-${pid}-${seq}`, the lockfile sidecarSeq +// pattern): the per-sidecar mutation chains are the real serialization fix, +// unique tmps are defense in depth — no write can ever rename (or strand) +// another in-flight write's tmp, and a crashed predecessor's leftovers match +// the open-time isStaleTmpFile cleanup. +let sidecarTmpSeq = 0; + +/** Write a small metadata file atomically (unique tmp + rename + strict + * directory fsync), so a crash cannot leave a torn definition file that + * would force openers into error/rebuild — and a successful return means + * the rename is crash-durable (the stage-9 strict fsyncDir mode; a platform + * without directory fsync degrades via fsyncDir itself). A strict fsync + * failure propagates even though the renamed bytes may already be visible: + * persist = crash-durable by definition, so the caller treats the mutation + * as failed and keeps its previous in-memory state (the same ambiguity rule + * as a WAL commit-point failure). */ +async function writeFileAtomic(file: string, data: string, opts: { stats?: { dirFsyncUnsupported?: boolean } } = {}): Promise { + const tmp = `${file}.tmp-${process.pid}-${++sidecarTmpSeq}`; + try { + await fs.writeFile(tmp, data, 'utf8'); + await fs.rename(tmp, file); + } finally { + // A successful rename already moved the tmp away (this rm is a no-op); a + // failed write/rename must not strand it. + await fs.rm(tmp, { force: true }).catch(() => {}); + } + await fsyncDir(path.dirname(file), { strict: true, stats: opts.stats }); } async function resolveValueMode(mode: ValueModeSetting, dir: string, maxMemoryBytes: number | null): Promise { @@ -281,6 +304,13 @@ export class MiniDb { readonly compound = new CompoundIndexManager(); private readonly text = new Map(); private textDefs: TextIndexDef[] = []; + /** Names staged for drop by dropTextIndex (plan 10's "mark staged-drop, + * persist, then remove from live"). A compaction's postings rebuild + * (rebuildTextPostings) skips them: without the mark, a build starting in + * the drop's persist window could commit AFTER the drop's close+rm — + * re-creating the postings file as an orphan and leaking the reopened + * handle. */ + private readonly textDrops = new Set(); private codec!: ValueCodec; private codecName: ValueCodecName = 'buffer'; @@ -313,7 +343,23 @@ export class MiniDb { maxMemoryBytes: number | null = null; maxMemoryPolicy: 'reject' | 'evict-lru' = 'reject'; private access = new Set(); // pk, insertion-ordered by last touch (Map/Set iteration order): front = LRU - private uniqueWriteLock: Promise = Promise.resolve(); + /** Serializes write ops while any unique index exists (check-then-apply must + * be atomic against other writers). Shared promise-chain pattern — see + * serialize.ts. */ + private readonly serializeUniqueWrites = createSerializer(); + /** Per-sidecar mutation chains (one promise-chain mutex per index-definition + * sidecar file, plan 10): a create/drop runs its whole staged → persist → + * publish sequence under its sidecar's chain, so concurrent mutations of + * the SAME definition file can never interleave (before this, two + * concurrent creates shared one fixed .tmp — one renamed the other's tmp + * away — and a persist failure diverged the live registry from disk). + * Different sidecar types do NOT block each other, and the data write path + * (set/batch/del) never touches these chains. The in-chain rebuild is a + * full Store walk: index changes are rare admin operations, so holding the + * chain across the walk is the accepted trade-off. */ + private readonly secondaryDefChain = createSerializer(); + private readonly compoundDefChain = createSerializer(); + private readonly textDefChain = createSerializer(); /** Serializes in-place WAL recoveries (poison → truncate → resume), the same * promise-chain style as uniqueWriteLock. Never rejects (a failed recovery * lands in writeDisabled instead). */ @@ -468,11 +514,18 @@ export class MiniDb { for (const tmp of STALE_TMP_FILES) { 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)) { + // Unique-suffixed sidecar temps (`.tmp--`) orphaned by + // a crashed writeFileAtomic — whitelisted per known file so a live + // LockFile's db.lock.tmp-* is never matched (isStaleTmpFile). + if (isStaleTmpFile(f)) { + await fs.rm(path.join(db.dir, f), { force: true }); + continue; + } + // 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. if (STALE_POSTINGS_TMP_PATTERN.test(f)) await fs.rm(path.join(db.dir, f), { force: true }); } } @@ -657,7 +710,13 @@ export class MiniDb { * fresh base, so a compaction landing right after open must not redo the * exact same (expensive) pass. */ private async rebuildTextPostings(): Promise { - for (const ti of this.text.values()) { + for (const [name, ti] of this.text) { + // Skip indexes staged for drop (see textDrops): their postings are + // about to be removed, and a build committing after the drop's + // close+rm would re-create the file as an orphan and leak the reopened + // handle. The mark check and ti.build()'s synchronous beginBuild() are + // one tick apart at most — see dropTextIndex for why that is safe. + if (this.textDrops.has(name)) continue; if (ti.needsRebuild()) await ti.build(this.textRecords()); } } @@ -729,8 +788,12 @@ export class MiniDb { if ((e as NodeJS.ErrnoException).code !== 'ENOENT') throw e; } } - private async persistIndexDefinitions(): Promise { - await writeFileAtomic(this.indexPath, JSON.stringify(this.indexes.list())); + /** Persist the given secondary-index definition list. The CONTENT is the + * caller's transaction decision (live list ± the mutation), never an + * implicit snapshot of the registry — a create persists live+staged BEFORE + * publishing, a drop persists live-minus BEFORE removing. */ + private async persistIndexDefinitions(defs: IndexInfo[]): Promise { + await writeFileAtomic(this.indexPath, JSON.stringify(defs), { stats: this.stats }); } private async loadTextIndexDefinitions(): Promise { try { @@ -752,8 +815,10 @@ export class MiniDb { if ((e as NodeJS.ErrnoException).code !== 'ENOENT') throw e; } } - private async persistTextIndexDefinitions(): Promise { - await writeFileAtomic(this.textIndexPath, JSON.stringify(this.textDefs)); + /** Persist the given text-index definition list (same transaction-content + * rule as persistIndexDefinitions). */ + private async persistTextIndexDefinitions(defs: TextIndexDef[]): Promise { + await writeFileAtomic(this.textIndexPath, JSON.stringify(defs), { stats: this.stats }); } private async loadCompoundIndexDefinitions(): Promise { try { @@ -765,8 +830,10 @@ export class MiniDb { if ((e as NodeJS.ErrnoException).code !== 'ENOENT') throw e; } } - private async persistCompoundIndexDefinitions(): Promise { - await writeFileAtomic(this.compoundIndexPath, JSON.stringify(this.compound.list())); + /** Persist the given compound-index definition list (same + * transaction-content rule as persistIndexDefinitions). */ + private async persistCompoundIndexDefinitions(defs: CompoundIndexInfo[]): Promise { + await writeFileAtomic(this.compoundIndexPath, JSON.stringify(defs), { stats: this.stats }); } /** Drop every derived index entry for a key that just expired in the Store. */ @@ -774,7 +841,7 @@ export class MiniDb { this.access.delete(k); this.dt.del(k); this.compound.remove(k); - if (this.indexes.indexes.size) this.indexes.remove(k, undefined); + if (this.indexes.size) this.indexes.remove(k, undefined); for (const ti of this.text.values()) ti.remove(k); } @@ -794,22 +861,10 @@ export class MiniDb { } private hasUniqueIndexes(): boolean { - for (const idx of this.indexes.indexes.values()) if (idx.unique) return true; - return false; - } - - private async withUniqueWriteLock(fn: () => Promise): Promise { - const prev = this.uniqueWriteLock; - let release!: () => void; - this.uniqueWriteLock = new Promise((resolve) => { - release = resolve; - }); - await prev; - try { - return await fn(); - } finally { - release(); - } + // Staged included: while a unique create is in its persist window the + // staged index is fully built and writes must already be checked against + // it (and serialized via serializeUniqueWrites) — see IndexManager.staged. + return this.indexes.hasUnique(); } /** @@ -1192,7 +1247,7 @@ export class MiniDb { // still-poisoned WAL. Null (and zero-cost) when no recovery is running. const recoveryGate = this.walRecoveryGate(); if (recoveryGate) await recoveryGate; - if (this.indexes.indexes.size && this.indexable(value)) this.indexes.checkUnique(op.pk, value); + if (this.indexes.size && this.indexable(value)) this.indexes.checkUnique(op.pk, value); const frame = encodeFrame({ type: TYPE_SET, key: op.key, value: op.value, meta: op.meta, expireAt: op.expireAt }); const wal = this.wal; const appended = wal.appendLoc(frame); @@ -1257,7 +1312,7 @@ export class MiniDb { this.maybeAutoCompact(); }; - if (this.hasUniqueIndexes()) await this.withUniqueWriteLock(() => this.retryOnWalSeal(commit)); + if (this.hasUniqueIndexes()) await this.serializeUniqueWrites(() => this.retryOnWalSeal(commit)); else await this.retryOnWalSeal(commit); } @@ -1323,7 +1378,7 @@ export class MiniDb { const commit = async (): Promise => { const recoveryGate = this.walRecoveryGate(); if (recoveryGate) await recoveryGate; - if (this.indexes.indexes.size) { + if (this.indexes.size) { this.indexes.checkUniqueBatch( prepared.map((o) => ({ pk: o.pk, @@ -1403,7 +1458,7 @@ export class MiniDb { this.maybeAutoCompact(); }; - if (this.hasUniqueIndexes()) await this.withUniqueWriteLock(() => this.retryOnWalSeal(commit)); + if (this.hasUniqueIndexes()) await this.serializeUniqueWrites(() => this.retryOnWalSeal(commit)); else await this.retryOnWalSeal(commit); } @@ -1456,7 +1511,7 @@ export class MiniDb { this.store.set(op.key, op.value!, op.expireAt, op.dtNorm); this.dt.set(op.pk, op.dtNorm); this.compound.add(op.pk, op.valueDecoded, op.dtNorm); - if (this.indexes.indexes.size) { + if (this.indexes.size) { if (this.indexable(oldDoc)) this.indexes.remove(op.pk, oldDoc); if (this.indexable(op.valueDecoded)) this.indexes.add(op.pk, op.valueDecoded); } @@ -1470,7 +1525,7 @@ export class MiniDb { this.access.delete(op.pk); this.dt.del(op.pk); this.compound.remove(op.pk); - if (this.indexes.indexes.size && this.indexable(oldDoc)) this.indexes.remove(op.pk, oldDoc); + if (this.indexes.size && this.indexable(oldDoc)) this.indexes.remove(op.pk, oldDoc); for (const ti of this.text.values()) ti.remove(op.pk); } } @@ -1498,7 +1553,7 @@ export class MiniDb { * rollback: put the key back to `prev` across the store and every derived * index (TTL/access/dt/secondary/compound/text). */ private restoreGroupKey(pk: string, prev: StoreRecord | undefined): void { - if (this.indexes.indexes.size) this.indexes.remove(pk, undefined); + if (this.indexes.size) this.indexes.remove(pk, undefined); for (const ti of this.text.values()) ti.remove(pk); this.dt.del(pk); this.compound.remove(pk); @@ -1532,13 +1587,13 @@ export class MiniDb { // 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; + const oldDoc = this.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); + if (this.indexes.size && this.indexable(oldDoc)) this.indexes.remove(pk, oldDoc); for (const ti of this.text.values()) ti.remove(pk); return; } @@ -1553,10 +1608,10 @@ export class MiniDb { 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) { + if (this.indexes.size || this.text.size || this.compound.size) { const doc = this.decode(buf)!; this.compound.add(pk, doc, op.dt); - if (this.indexes.indexes.size) { + if (this.indexes.size) { if (this.indexable(oldDoc)) this.indexes.remove(pk, oldDoc); if (this.indexable(doc)) this.indexes.add(pk, doc); } @@ -1710,24 +1765,37 @@ export class MiniDb { this.ensureOpen(); this.ensureWritable(); if (this.codecName !== 'json') throw new Error('secondary indexes require valueCodec: "json"'); - this.indexes.create(name, opts); - this.indexes.rebuild(this._liveRecordsRaw()); - try { - // A unique index must not be created over data that already violates it. - this.indexes.assertUniqueValid(name); - } catch (e) { - this.indexes.drop(name); - this.indexes.rebuild(this._liveRecordsRaw()); - throw e; - } - await this.persistIndexDefinitions(); + // Serialized staged → persist → publish (see secondaryDefChain): the + // definition is staged off to the side, rebuilt there, persisted as part + // of the sidecar content, and only then published into the live registry. + // Any failure discards the staged index — the live registry and the + // sidecar keep their previous state, so a retry cannot hit a phantom + // "already exists". + await this.secondaryDefChain(async () => { + this.indexes.stage(name, opts); + try { + this.indexes.rebuildStaged(name, this._liveRecordsRaw()); + // A unique index must not be created over data that already violates it. + this.indexes.assertUniqueValid(name); + await this.persistIndexDefinitions([...this.indexes.list(), this.indexes.stagedInfo(name)]); + } catch (e) { + this.indexes.discardStaged(name); + throw e; + } + this.indexes.publish(name); + }); } async dropIndex(name: string): Promise { this.ensureOpen(); this.ensureWritable(); - const ok = this.indexes.drop(name); - await this.persistIndexDefinitions(); - return ok; + return this.secondaryDefChain(async () => { + // Persist FIRST (content without the definition), remove from the live + // registry only after the sidecar is durable: a persist failure leaves + // the index fully usable instead of diverging memory from disk (which a + // reopen would have resurrected). + await this.persistIndexDefinitions(this.indexes.list().filter((i) => i.name !== name)); + return this.indexes.drop(name); + }); } listIndexes(): IndexInfo[] { return this.indexes.list(); @@ -1753,17 +1821,30 @@ export class MiniDb { this.ensureOpen(); this.ensureWritable(); if (this.codecName !== 'json') throw new Error('compound indexes require valueCodec: "json"'); - this.compound.create(name, def); - this.compound.rebuild(this.liveRecords()); - await this.persistCompoundIndexDefinitions(); + // Serialized staged → persist → publish, the same discipline as + // createIndex (see compoundDefChain). + await this.compoundDefChain(async () => { + this.compound.stage(name, def); + try { + this.compound.rebuildStaged(name, this.liveRecords()); + await this.persistCompoundIndexDefinitions([...this.compound.list(), this.compound.stagedInfo(name)]); + } catch (e) { + this.compound.discardStaged(name); + throw e; + } + this.compound.publish(name); + }); } async dropCompoundIndex(name: string): Promise { this.ensureOpen(); this.ensureWritable(); - const ok = this.compound.drop(name); - await this.persistCompoundIndexDefinitions(); - return ok; + return this.compoundDefChain(async () => { + // Persist FIRST (content without the definition), remove from live only + // after the sidecar is durable (see dropIndex). + await this.persistCompoundIndexDefinitions(this.compound.list().filter((i) => i.name !== name)); + return this.compound.drop(name); + }); } listCompoundIndexes(): CompoundIndexInfo[] { @@ -1799,52 +1880,68 @@ export class MiniDb { this.ensureOpen(); this.ensureWritable(); 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, ...textIndexTokenizers(tokenizer), postingsPath: this.textPostingsPath(name) }); - const def: TextIndexDef = { name, fields: fields ?? null, tokenizer }; - // Register BEFORE building: the build yields to the event loop, and - // registering makes concurrent writes feed the index's build queue, which - // the build replays onto the new base — so the finished index reflects - // every write whenever it landed. Until the build completes, searches on - // the index see only its post-registration delta. A failed build unwinds - // the registration, so a retry cannot hit a phantom "already exists". - this.text.set(name, ti); - try { - await ti.build(this.textRecords()); - } catch (e) { - this.text.delete(name); - ti.close(); - throw e; - } - 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; - } + await this.textDefChain(async () => { + if (this.text.has(name)) throw new Error(`text index "${name}" already exists`); + const ti = new TextIndex({ fields, ...textIndexTokenizers(tokenizer), postingsPath: this.textPostingsPath(name) }); + // The staged definition: joins the persisted set (publish) only after + // the sidecar is durable. + const def: TextIndexDef = { name, fields: fields ?? null, tokenizer }; + // Register BEFORE building: the build yields to the event loop, and + // registering makes concurrent writes feed the index's build queue, which + // the build replays onto the new base — so the finished index reflects + // every write whenever it landed. Until the build completes, searches on + // the index see only its post-registration delta. Any failure below + // discards the staged index, so a retry cannot hit a phantom + // "already exists". + this.text.set(name, ti); + try { + await ti.build(this.textRecords()); + await this.persistTextIndexDefinitions([...this.textDefs, def]); + } catch (e) { + // Discard the staged index 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); + ti.close(); + await fs.rm(this.textPostingsPath(name), { force: true }).catch(() => {}); + throw e; + } + this.textDefs.push(def); + }); } async dropTextIndex(name: string): Promise { this.ensureOpen(); this.ensureWritable(); - const ti = this.text.get(name); - // Dropping mid-build would orphan the in-flight postings write (the file - // is removed while the build is still producing it). - if (ti?.building) throw new Error(`text index "${name}" is still building`); - const ok = this.text.delete(name); - if (ti) { - ti.close(); - await fs.rm(this.textPostingsPath(name), { force: true }).catch(() => {}); - } - this.textDefs = this.textDefs.filter((d) => d.name !== name); - await this.persistTextIndexDefinitions(); - return ok; + return this.textDefChain(async () => { + const ti = this.text.get(name); + // Dropping mid-build would orphan the in-flight postings write (the file + // is removed while the build is still producing it). The build can only + // be a compaction's postings rebuild — createTextIndex builds under this + // same chain. + if (ti?.building) throw new Error(`text index "${name}" is still building`); + // Mark staged-drop BEFORE the persist window: a compaction's postings + // rebuild checks the mark and skips this index (see textDrops), so no + // build can start while the persist below is in flight. The marking is + // synchronous with the building check above, so a build is either + // already running (caught there) or can never start (blocked here). + this.textDrops.add(name); + try { + // Persist FIRST (content without the definition), remove from live and + // release the resources only after the sidecar is durable: a persist + // failure leaves the index fully usable (see dropIndex). + const nextDefs = this.textDefs.filter((d) => d.name !== name); + await this.persistTextIndexDefinitions(nextDefs); + const ok = this.text.delete(name); + if (ti) { + ti.close(); + await fs.rm(this.textPostingsPath(name), { force: true }).catch(() => {}); + } + this.textDefs = nextDefs; + return ok; + } finally { + this.textDrops.delete(name); + } + }); } search(name: string, q: string, opts: { op?: 'AND' | 'OR'; limit?: number; maxVisits?: number } = {}): { key: string; value: V | undefined; score: number }[] { diff --git a/packages/minidb/src/lockfile.ts b/packages/minidb/src/lockfile.ts index bba37dd5a19..a4f5eb108ad 100644 --- a/packages/minidb/src/lockfile.ts +++ b/packages/minidb/src/lockfile.ts @@ -19,6 +19,7 @@ import fsSync from 'node:fs'; import path from 'node:path'; import { randomUUID } from 'node:crypto'; import { renameReplace } from './rename-replace.js'; +import { createSerializer } from './serialize.js'; export class LockError extends Error { readonly code = 'ELOCKED'; @@ -77,29 +78,16 @@ export class LockFile { * carried by every file this instance publishes (lock/bid/watch), and the * sole ownership criterion (`mine`). Null before the first acquire(). */ private token: string | null = null; - /** Serializes acquire/renew/release (the withUniqueWriteLock pattern): each - * op's whole read-check-write completes before the next one starts, so a - * renew already in flight finishes before a release unlinks. */ - private opChain: Promise = Promise.resolve(); + /** Serializes acquire/renew/release (the shared promise-chain pattern of + * serialize.ts): each op's whole read-check-write completes before the next + * one starts, so a renew already in flight finishes before a release + * unlinks. */ + private readonly serialized = createSerializer(); constructor(path: string) { this.path = path; } - private async serialized(fn: () => Promise): Promise { - const prev = this.opChain; - let done!: () => void; - this.opChain = new Promise((resolve) => { - done = resolve; - }); - await prev; - try { - return await fn(); - } finally { - done(); - } - } - /** File body for every file this instance publishes (lock, bid, watch). */ private payload(): string { return JSON.stringify({ pid: process.pid, ts: Date.now(), token: this.token }); diff --git a/packages/minidb/src/persistent-files.ts b/packages/minidb/src/persistent-files.ts index 009f739dbc8..7e74c3b8ce2 100644 --- a/packages/minidb/src/persistent-files.ts +++ b/packages/minidb/src/persistent-files.ts @@ -53,12 +53,24 @@ export function isPersistentFile(name: string): boolean { ); } -/** Atomic-write temp siblings a crashed previous run may have left behind - * (a compaction's snapshot/WAL temps, sidecar-definition temps). Only the - * sole writer may delete them at open — a read-only opener must never touch - * a live writer's in-flight temps. */ +/** Atomic-write temp siblings a crashed previous run may have left behind: + * a compaction's snapshot/WAL temps (fixed names), plus sidecar-definition + * temps from before sidecar writes gained unique suffixes. Current sidecar + * writes use `.tmp--` names, matched by isStaleTmpFile + * instead. Only the sole writer may delete them at open — a read-only + * opener must never touch a live writer's in-flight temps. */ export const STALE_TMP_FILES: readonly string[] = [SNAPSHOT_FILE, WAL_FILE, ...SIDECAR_FILES].map((f) => `${f}.tmp`); +/** Is `name` a unique-suffixed atomic-write temp (`.tmp--`) + * of one of the primary/sidecar files, orphaned by a crash between the tmp + * write and the rename? Whitelisted per known file so a LockFile's + * `db.lock.tmp-*` — possibly in flight in ANOTHER process right now — is + * never matched. Same deletion discipline as STALE_TMP_FILES: only the sole + * writer at open. */ +export function isStaleTmpFile(name: string): boolean { + return [SNAPSHOT_FILE, WAL_FILE, ...SIDECAR_FILES].some((f) => name.startsWith(`${f}.tmp-`)); +} + /** A failed postings rebuild orphans `db.text-*.postings.tmp` (its atomic * rename never ran). Postings are pure derived state, so such temps are * always safe for the writer to delete, for any index name. */ diff --git a/packages/minidb/src/serialize.ts b/packages/minidb/src/serialize.ts new file mode 100644 index 00000000000..cd6f6b75428 --- /dev/null +++ b/packages/minidb/src/serialize.ts @@ -0,0 +1,33 @@ +// src/serialize.ts +// +// A promise-chain mutex, extracted from what used to be two private copies of +// the same pattern (MiniDb.withUniqueWriteLock, LockFile.serialized): each +// queued function's whole body — across every await — completes before the +// next one starts, in issue order. Mutual exclusion for async critical +// sections without blocking the event loop. +// +// Current users: the LockFile lifecycle ops (acquire/renew/release), the +// unique-index write path, and the per-sidecar index-definition mutation +// chains (plan 10). +// +// Internal to the package — NOT re-exported from the root entry point. + +/** Create a serializing executor: functions submitted to it run strictly one + * at a time, in submission order. The executor itself never rejects; each + * call settles with its own function's result or error. */ +export function createSerializer(): (fn: () => Promise) => Promise { + let chain: Promise = Promise.resolve(); + return async function serialized(fn: () => Promise): Promise { + const prev = chain; + let done!: () => void; + chain = new Promise((resolve) => { + done = resolve; + }); + await prev; + try { + return await fn(); + } finally { + done(); + } + }; +} diff --git a/packages/minidb/test/compound-index.test.ts b/packages/minidb/test/compound-index.test.ts index 50c296b4c52..e9843ad6eb9 100644 --- a/packages/minidb/test/compound-index.test.ts +++ b/packages/minidb/test/compound-index.test.ts @@ -96,3 +96,102 @@ test('delete removes from the compound index', async () => { await db.close(); await fs.rm(dir, { recursive: true, force: true }); }); + + +// ---- plan 10: sidecar mutation serialization + staged → persist → publish -- + +/** White-box handle on the private persistCompoundIndexDefinitions, to inject + * a sidecar-write failure at the exact transaction point. */ +function stubCompoundPersist(db: MiniDb, impl: (defs: { name: string }[]) => Promise): () => void { + const priv = db as unknown as { persistCompoundIndexDefinitions: (defs: { name: string }[]) => Promise }; + const saved = priv.persistCompoundIndexDefinitions; + priv.persistCompoundIndexDefinitions = impl; + return () => { + priv.persistCompoundIndexDefinitions = saved; + }; +} + +async function compoundSidecarNames(dir: string): Promise { + try { + return (JSON.parse(await fs.readFile(path.join(dir, 'db.compound-indexes.json'), 'utf8')) as { name: string }[]) + .map((d) => d.name) + .sort(); + } catch (e) { + if ((e as NodeJS.ErrnoException).code === 'ENOENT') return []; + throw e; + } +} + +test('concurrent createCompoundIndex calls are serialized: zero failures; memory == sidecar == reopen', async () => { + const dir = await tmpDir(); + let db = await MiniDb.open({ dir, valueCodec: 'json', fsyncPolicy: 'no', autoCompact: false }); + try { + await db.set('a', { workspaceId: 'W1' }, { dt: { updatedAt: 100, createdAt: 10 } }); + const results = await Promise.allSettled([ + db.createCompoundIndex('byWsUpdated', { groupBy: 'workspaceId', orderBy: 'updatedAt' }), + db.createCompoundIndex('byWsCreated', { groupBy: 'workspaceId', orderBy: 'createdAt' }), + db.dropCompoundIndex('neverThere'), + ]); + assert.deepEqual( + results.map((r) => (r.status === 'rejected' ? String(r.reason) : r.status)), + ['fulfilled', 'fulfilled', 'fulfilled'], + ); + const memory = db.listCompoundIndexes().map((x) => x.name).sort(); + assert.deepEqual(memory, ['byWsCreated', 'byWsUpdated']); + assert.deepEqual(await compoundSidecarNames(dir), memory); + // Both staged rebuilds saw the pre-existing document. + assert.deepEqual(db.compoundRange('byWsUpdated', 'W1').map((r) => r.key), ['a']); + assert.deepEqual(db.compoundRange('byWsCreated', 'W1').map((r) => r.key), ['a']); + await db.close(); + db = await MiniDb.open({ dir, valueCodec: 'json', fsyncPolicy: 'no', autoCompact: false }); + assert.deepEqual(db.listCompoundIndexes().map((x) => x.name).sort(), memory); + await db.close(); + } finally { + await fs.rm(dir, { recursive: true, force: true }); + } +}); + +test('createCompoundIndex persist failure: no phantom in memory or sidecar; retry succeeds', async () => { + const dir = await tmpDir(); + const db = await MiniDb.open({ dir, valueCodec: 'json', fsyncPolicy: 'no', autoCompact: false }); + try { + await db.set('a', { workspaceId: 'W1' }, { dt: { updatedAt: 100 } }); + const boom = new Error('injected sidecar write failure'); + const restore = stubCompoundPersist(db, async () => { + throw boom; + }); + await assert.rejects(db.createCompoundIndex('byWsUpdated', { groupBy: 'workspaceId', orderBy: 'updatedAt' }), (e) => e === boom); + restore(); + assert.deepEqual(db.listCompoundIndexes(), []); + assert.throws(() => db.compoundRange('byWsUpdated', 'W1'), /no such compound index/); + assert.deepEqual(await compoundSidecarNames(dir), []); + await db.createCompoundIndex('byWsUpdated', { groupBy: 'workspaceId', orderBy: 'updatedAt' }); + assert.deepEqual(db.compoundRange('byWsUpdated', 'W1').map((r) => r.key), ['a']); + await db.close(); + } finally { + await fs.rm(dir, { recursive: true, force: true }); + } +}); + +test('dropCompoundIndex persist failure: the live index stays usable and the sidecar is unchanged', async () => { + const dir = await tmpDir(); + const db = await MiniDb.open({ dir, valueCodec: 'json', fsyncPolicy: 'no', autoCompact: false }); + try { + await db.createCompoundIndex('byWsUpdated', { groupBy: 'workspaceId', orderBy: 'updatedAt' }); + await db.set('a', { workspaceId: 'W1' }, { dt: { updatedAt: 100 } }); + const before = await fs.readFile(path.join(dir, 'db.compound-indexes.json'), 'utf8'); + const boom = new Error('injected sidecar write failure'); + const restore = stubCompoundPersist(db, async () => { + throw boom; + }); + await assert.rejects(db.dropCompoundIndex('byWsUpdated'), (e) => e === boom); + restore(); + assert.deepEqual(db.compoundRange('byWsUpdated', 'W1').map((r) => r.key), ['a']); + assert.equal(await fs.readFile(path.join(dir, 'db.compound-indexes.json'), 'utf8'), before); + assert.equal(await db.dropCompoundIndex('byWsUpdated'), true); + assert.deepEqual(db.listCompoundIndexes(), []); + await db.close(); + } finally { + await fs.rm(dir, { recursive: true, force: true }); + } +}); diff --git a/packages/minidb/test/indexes-extra.test.ts b/packages/minidb/test/indexes-extra.test.ts index e539e6bd6ba..55b9d64f276 100644 --- a/packages/minidb/test/indexes-extra.test.ts +++ b/packages/minidb/test/indexes-extra.test.ts @@ -162,3 +162,287 @@ test('unique range index: batch swap, del+reuse, and conflict', async () => { await fs.rm(dir, { recursive: true, force: true }); } }); + +// ---- plan 10: sidecar mutation serialization + staged → persist → publish -- + +/** White-box handle on a private persist*Definitions method, so a test can + * inject a sidecar-write failure at the exact transaction point. Returns a + * restore function. */ +function stubPersist( + db: MiniDb, + key: 'persistIndexDefinitions' | 'persistCompoundIndexDefinitions' | 'persistTextIndexDefinitions', + impl: (defs: { name: string }[]) => Promise, +): () => void { + const priv = db as unknown as Record Promise>; + const saved = priv[key]; + priv[key] = impl; + return () => { + priv[key] = saved; + }; +} + +/** Sidecar definition names, or [] when the file does not exist yet. */ +async function sidecarNames(dir: string, file: string): Promise { + try { + return (JSON.parse(await fs.readFile(path.join(dir, file), 'utf8')) as { name: string }[]).map((d) => d.name).sort(); + } catch (e) { + if ((e as NodeJS.ErrnoException).code === 'ENOENT') return []; + throw e; + } +} + +test('100 concurrent createIndex pairs: zero failures; memory == sidecar == reopen', async () => { + const dir = await tmpDir(); + let db = await MiniDb.open({ dir, valueCodec: 'json', fsyncPolicy: 'no', autoCompact: false }); + try { + // The review #7 repro (100/100 failures before the fix): two concurrent + // creates shared one fixed .tmp and one renamed the other's tmp away, + // leaving the rejected index in the memory registry only. + for (let i = 0; i < 100; i++) { + const results = await Promise.allSettled([db.createIndex(`a${i}`, { field: 'a' }), db.createIndex(`b${i}`, { field: 'b' })]); + assert.deepEqual( + results.map((r) => (r.status === 'rejected' ? String(r.reason) : r.status)), + ['fulfilled', 'fulfilled'], + `round ${i}`, + ); + } + const memory = db.listIndexes().map((x) => x.name).sort(); + assert.equal(memory.length, 200); + assert.deepEqual(await sidecarNames(dir, 'db.indexes.json'), memory); + await db.close(); + db = await MiniDb.open({ dir, valueCodec: 'json', fsyncPolicy: 'no', autoCompact: false }); + assert.deepEqual(db.listIndexes().map((x) => x.name).sort(), memory); + await db.close(); + } finally { + await fs.rm(dir, { recursive: true, force: true }); + } +}); + +test('createIndex persist failure: no phantom in memory or sidecar, original error rethrown, retry succeeds', async () => { + const dir = await tmpDir(); + const db = await MiniDb.open({ dir, valueCodec: 'json', fsyncPolicy: 'no', autoCompact: false }); + try { + await db.createIndex('keep', { field: 'keep' }); + await db.set('k1', { keep: 1, a: 1 }); + const boom = new Error('injected sidecar write failure'); + const restore = stubPersist(db, 'persistIndexDefinitions', async () => { + throw boom; + }); + await assert.rejects(db.createIndex('byA', { field: 'a' }), (e) => e === boom); + restore(); + // The live registry carries no phantom and the old index keeps serving. + assert.deepEqual(db.listIndexes().map((x) => x.name), ['keep']); + assert.throws(() => db.findEq('byA', 1), /no such index/); + assert.deepEqual(db.findEq('keep', 1).map((r) => r.key), ['k1']); + // The sidecar never gained the definition. + assert.deepEqual(await sidecarNames(dir, 'db.indexes.json'), ['keep']); + // Retrying the same create succeeds — no phantom "already exists". + await db.createIndex('byA', { field: 'a' }); + assert.deepEqual(db.findEq('byA', 1).map((r) => r.key), ['k1']); + await db.close(); + } finally { + await fs.rm(dir, { recursive: true, force: true }); + } +}); + +test('dropIndex persist failure: the live index stays usable and the sidecar is unchanged', async () => { + const dir = await tmpDir(); + const db = await MiniDb.open({ dir, valueCodec: 'json', fsyncPolicy: 'no', autoCompact: false }); + try { + await db.createIndex('byA', { field: 'a' }); + await db.set('k1', { a: 1 }); + const before = await fs.readFile(path.join(dir, 'db.indexes.json'), 'utf8'); + const boom = new Error('injected sidecar write failure'); + const restore = stubPersist(db, 'persistIndexDefinitions', async () => { + throw boom; + }); + await assert.rejects(db.dropIndex('byA'), (e) => e === boom); + restore(); + // The index is still live and answering queries. + assert.deepEqual(db.findEq('byA', 1).map((r) => r.key), ['k1']); + // The sidecar is byte-identical (no torn rewrite). + assert.equal(await fs.readFile(path.join(dir, 'db.indexes.json'), 'utf8'), before); + // A later successful drop persists fine. + assert.equal(await db.dropIndex('byA'), true); + assert.deepEqual(db.listIndexes(), []); + await db.close(); + } finally { + await fs.rm(dir, { recursive: true, force: true }); + } +}); + +test('createIndex rebuild failure: no phantom, existing indexes keep serving, unique rollback unchanged', async () => { + const dir = await tmpDir(); + const db = await MiniDb.open({ dir, valueCodec: 'json', fsyncPolicy: 'no', autoCompact: false }); + try { + await db.createIndex('keep', { field: 'keep' }); + await db.set('k1', { keep: 1, a: 1 }); + const boom = new Error('injected rebuild failure'); + const saved = db.indexes.rebuildStaged; + db.indexes.rebuildStaged = () => { + throw boom; + }; + await assert.rejects(db.createIndex('byA', { field: 'a' }), (e) => e === boom); + db.indexes.rebuildStaged = saved; + // No phantom in the registry; the pre-existing index is untouched. + assert.deepEqual(db.listIndexes().map((x) => x.name), ['keep']); + assert.deepEqual(db.findEq('keep', 1).map((r) => r.key), ['k1']); + // The sidecar was never rewritten. + assert.deepEqual(await sidecarNames(dir, 'db.indexes.json'), ['keep']); + // Unique-violation rollback keeps its semantics: the rejected create + // leaves nothing behind either. + await db.set('k2', { keep: 2, u: 'dup' }); + await db.set('k3', { keep: 3, u: 'dup' }); + await assert.rejects(db.createIndex('byU', { field: 'u', unique: true }), /unique/i); + assert.deepEqual(db.listIndexes().map((x) => x.name), ['keep']); + assert.deepEqual(await sidecarNames(dir, 'db.indexes.json'), ['keep']); + // And the staged path still recovers: a valid create lands fine. + await db.createIndex('byA', { field: 'a' }); + assert.deepEqual(db.findEq('byA', 1).map((r) => r.key), ['k1']); + await db.close(); + } finally { + await fs.rm(dir, { recursive: true, force: true }); + } +}); + +test('mixed create/drop across the three sidecar types: every op lands serialized; memory == sidecars == reopen', async () => { + const dir = await tmpDir(); + let db = await MiniDb.open({ dir, valueCodec: 'json', fsyncPolicy: 'no', autoCompact: false }); + try { + await db.createIndex('secOld', { field: 'old' }); + await db.createCompoundIndex('cmpOld', { groupBy: 'ws', orderBy: 'updatedAt' }); + await db.createTextIndex('txtOld', { fields: ['text'] }); + await db.set('d1', { old: 1, a: 1, b: 2, ws: 'W', text: 'hello world' }, { dt: { updatedAt: 100, createdAt: 50 } }); + + const ops: (() => Promise)[] = [ + () => db.createIndex('secA', { field: 'a' }), + () => db.createIndex('secB', { field: 'b' }), + () => db.dropIndex('secOld'), + () => db.createCompoundIndex('cmpA', { groupBy: 'ws', orderBy: 'createdAt' }), + () => db.dropCompoundIndex('cmpOld'), + () => db.createTextIndex('txtA', { fields: ['text'] }), + () => db.dropTextIndex('txtOld'), + ]; + const results = await Promise.allSettled(ops.map((op) => op())); + assert.deepEqual( + results.map((r) => (r.status === 'rejected' ? String(r.reason) : r.status)), + ops.map(() => 'fulfilled'), + ); + + const expectSec = ['secA', 'secB']; + const expectCmp = ['cmpA']; + const expectTxt = ['txtA']; + assert.deepEqual(db.listIndexes().map((x) => x.name).sort(), expectSec); + assert.deepEqual(db.listCompoundIndexes().map((x) => x.name).sort(), expectCmp); + assert.deepEqual(db.search('txtA', 'hello').map((r) => r.key), ['d1']); + assert.throws(() => db.search('txtOld', 'hello'), /no such text index/); + // The staged rebuild also caught the pre-existing document. + assert.deepEqual(db.findEq('secA', 1).map((r) => r.key), ['d1']); + assert.deepEqual(db.compoundRange('cmpA', 'W').map((r) => r.key), ['d1']); + + assert.deepEqual(await sidecarNames(dir, 'db.indexes.json'), expectSec); + assert.deepEqual(await sidecarNames(dir, 'db.compound-indexes.json'), expectCmp); + assert.deepEqual(await sidecarNames(dir, 'db.textindexes.json'), expectTxt); + + await db.close(); + db = await MiniDb.open({ dir, valueCodec: 'json', fsyncPolicy: 'no', autoCompact: false }); + assert.deepEqual(db.listIndexes().map((x) => x.name).sort(), expectSec); + assert.deepEqual(db.listCompoundIndexes().map((x) => x.name).sort(), expectCmp); + assert.deepEqual(db.search('txtA', 'hello').map((r) => r.key), ['d1']); + assert.throws(() => db.search('txtOld', 'hello'), /no such text index/); + await db.close(); + } finally { + await fs.rm(dir, { recursive: true, force: true }); + } +}); + +test('set/batch/del do not share the sidecar mutation chain (writes flow while a create is parked)', async () => { + const dir = await tmpDir(); + const db = await MiniDb.open({ dir, valueCodec: 'json', fsyncPolicy: 'no', autoCompact: false }); + try { + let release!: () => void; + let entered!: () => void; + const gate = new Promise((r) => (release = r)); + const inPersist = new Promise((r) => (entered = r)); + const restore = stubPersist(db, 'persistIndexDefinitions', async () => { + entered(); + await gate; + }); + const create = db.createIndex('byA', { field: 'a' }); + // The create now holds the secondary chain inside its persist. + await inPersist; + // Writes must flow regardless — the data path never touches that chain. + for (let i = 0; i < 50; i++) await db.set(`k${i}`, { a: i }); + await db.batch([ + { op: 'set', key: 'b1', value: { a: 100 } }, + { op: 'del', key: 'k0' }, + ]); + assert.equal(db.size, 50); + release(); + await create; + restore(); + // The create completed, published, and (thanks to the staged index being + // fed by the write path during its persist window) reflects every write + // that landed while it was parked. + assert.deepEqual(db.listIndexes().map((x) => x.name), ['byA']); + assert.deepEqual(db.findEq('byA', 100).map((r) => r.key), ['b1']); + assert.equal(db.findEq('byA', 0).length, 0, 'k0 was deleted before publish'); + assert.deepEqual(db.findEq('byA', 49).map((r) => r.key), ['k49']); + await db.close(); + } finally { + await fs.rm(dir, { recursive: true, force: true }); + } +}); + +test('open cleans up unique-suffixed sidecar tmp leftovers (but never a lock tmp)', async () => { + const dir = await tmpDir(); + const db = await MiniDb.open({ dir, valueCodec: 'json' }); + await db.close(); + // A crash between writeFileAtomic's tmp write and its rename orphans this. + const orphan = path.join(dir, 'db.indexes.json.tmp-99999-1'); + await fs.writeFile(orphan, 'partial{'); + // A LockFile tmp is another component's in-flight file: never matched. + const lockTmp = path.join(dir, 'db.lock.tmp-99999-1'); + await fs.writeFile(lockTmp, 'lock'); + const db2 = await MiniDb.open({ dir, valueCodec: 'json' }); + await assert.rejects(fs.stat(orphan), /ENOENT/); + await fs.stat(lockTmp); // untouched + await db2.close(); + await fs.rm(dir, { recursive: true, force: true }); +}); + + +test('a staged unique index constrains writes during its persist window', async () => { + const dir = await tmpDir(); + const db = await MiniDb.open({ dir, valueCodec: 'json', fsyncPolicy: 'no', autoCompact: false }); + try { + await db.set('k1', { u: 'taken' }); + let release!: () => void; + let entered!: () => void; + const gate = new Promise((r) => (release = r)); + const inPersist = new Promise((r) => (entered = r)); + const restore = stubPersist(db, 'persistIndexDefinitions', async () => { + entered(); + await gate; + }); + const create = db.createIndex('byU', { field: 'u', unique: true }); + // The create is parked inside its persist; the staged unique index is + // fully built and must already be enforced, or a write in this window + // could break the constraint the publish is about to enforce. + await inPersist; + await assert.rejects(db.set('k2', { u: 'taken' }), UniqueViolationError); + await assert.rejects(db.batch([{ op: 'set', key: 'k4', value: { u: 'taken' } }]), UniqueViolationError); + // A conforming write lands and is reflected by the index once published. + await db.set('k3', { u: 'free' }); + release(); + await create; + restore(); + assert.deepEqual(db.findEq('byU', 'free').map((r) => r.key), ['k3']); + // The rejected writes can proceed with non-conflicting values. + await db.set('k2', { u: 'taken2' }); + assert.deepEqual(db.findEq('byU', 'taken2').map((r) => r.key), ['k2']); + await db.close(); + } finally { + await fs.rm(dir, { recursive: true, force: true }); + } +}); diff --git a/packages/minidb/test/text-index.test.ts b/packages/minidb/test/text-index.test.ts index 4d804bae7e5..8a2642acbec 100644 --- a/packages/minidb/test/text-index.test.ts +++ b/packages/minidb/test/text-index.test.ts @@ -784,3 +784,159 @@ test('MiniDb: searchBounded surfaces values, visits and the truncated flag', asy await fs.rm(dir, { recursive: true, force: true }); } }); + + +// ---- plan 10: sidecar mutation serialization + staged → persist → publish -- + +/** White-box handle on the private persistTextIndexDefinitions, to inject a + * sidecar-write failure at the exact transaction point. */ +function stubTextPersist(db: MiniDb, impl: (defs: { name: string }[]) => Promise): () => void { + const priv = db as unknown as { persistTextIndexDefinitions: (defs: { name: string }[]) => Promise }; + const saved = priv.persistTextIndexDefinitions; + priv.persistTextIndexDefinitions = impl; + return () => { + priv.persistTextIndexDefinitions = saved; + }; +} + +async function textSidecarNames(dir: string): Promise { + try { + return (JSON.parse(await fs.readFile(path.join(dir, 'db.textindexes.json'), 'utf8')) as { name: string }[]) + .map((d) => d.name) + .sort(); + } catch (e) { + if ((e as NodeJS.ErrnoException).code === 'ENOENT') return []; + throw e; + } +} + +test('MiniDb: concurrent createTextIndex calls are serialized; memory == sidecar == reopen', async () => { + const dir = await tmpDir(); + let db = await MiniDb.open({ dir, valueCodec: 'json', fsyncPolicy: 'no', autoCompact: false }); + try { + await db.set('a', { title: 'hello world', body: 'full text body' }); + const results = await Promise.allSettled([ + db.createTextIndex('title', { fields: ['title'] }), + db.createTextIndex('body', { fields: ['body'] }), + db.dropTextIndex('neverThere'), + ]); + assert.deepEqual( + results.map((r) => (r.status === 'rejected' ? String(r.reason) : r.status)), + ['fulfilled', 'fulfilled', 'fulfilled'], + ); + assert.deepEqual(await textSidecarNames(dir), ['body', 'title']); + // Both builds (registered before building) saw the pre-existing document. + assert.deepEqual(db.search('title', 'hello').map((r) => r.key), ['a']); + assert.deepEqual(db.search('body', 'text').map((r) => r.key), ['a']); + await db.close(); + db = await MiniDb.open({ dir, valueCodec: 'json', fsyncPolicy: 'no', autoCompact: false }); + assert.deepEqual(await textSidecarNames(dir), ['body', 'title']); + assert.deepEqual(db.search('title', 'hello').map((r) => r.key), ['a']); + await db.close(); + } finally { + await fs.rm(dir, { recursive: true, force: true }); + } +}); + +test('MiniDb: createTextIndex persist failure: no phantom, postings removed, original error rethrown, retry succeeds', async () => { + const dir = await tmpDir(); + const db = await MiniDb.open({ dir, valueCodec: 'json', fsyncPolicy: 'no', autoCompact: false }); + try { + await db.set('a', { text: 'hello world' }); + const boom = new Error('injected sidecar write failure'); + const restore = stubTextPersist(db, async () => { + throw boom; + }); + await assert.rejects(db.createTextIndex('body', { fields: ['text'] }), (e) => e === boom); + restore(); + // No phantom: the index is gone from memory and from the sidecar, and its + // derived postings file was removed (exactly like dropTextIndex would). + assert.throws(() => db.search('body', 'hello'), /no such text index/); + assert.deepEqual(await textSidecarNames(dir), []); + assert.deepEqual( + (await fs.readdir(dir)).filter((f) => f.includes('postings')), + [], + ); + // Retry succeeds — no phantom "already exists". + await db.createTextIndex('body', { fields: ['text'] }); + assert.deepEqual(db.search('body', 'hello').map((r) => r.key), ['a']); + await db.close(); + } finally { + await fs.rm(dir, { recursive: true, force: true }); + } +}); + +test('MiniDb: dropTextIndex persist failure: the index stays searchable and the sidecar is unchanged', async () => { + const dir = await tmpDir(); + const db = await MiniDb.open({ dir, valueCodec: 'json', fsyncPolicy: 'no', autoCompact: false }); + try { + await db.createTextIndex('body', { fields: ['text'] }); + await db.set('a', { text: 'hello world' }); + const before = await fs.readFile(path.join(dir, 'db.textindexes.json'), 'utf8'); + const boom = new Error('injected sidecar write failure'); + const restore = stubTextPersist(db, async () => { + throw boom; + }); + await assert.rejects(db.dropTextIndex('body'), (e) => e === boom); + restore(); + // The index is still live: searchable, and its postings file intact. + assert.deepEqual(db.search('body', 'hello').map((r) => r.key), ['a']); + assert.equal(await fs.readFile(path.join(dir, 'db.textindexes.json'), 'utf8'), before); + // A later successful drop persists fine. + assert.equal(await db.dropTextIndex('body'), true); + assert.deepEqual(await textSidecarNames(dir), []); + await db.close(); + } finally { + await fs.rm(dir, { recursive: true, force: true }); + } +}); + + +test('MiniDb: dropTextIndex persist window: a compaction postings rebuild skips the dropping index (no orphan, no leaked handle)', async () => { + const dir = await tmpDir(); + const db = await MiniDb.open({ dir, valueCodec: 'json', fsyncPolicy: 'no', autoCompact: false }); + try { + await db.createTextIndex('body', { fields: ['text'] }); + // Dirty the index so it is a postings-rebuild candidate. + await db.set('a', { text: 'hello world' }); + let release!: () => void; + let entered!: () => void; + const gate = new Promise((r) => (release = r)); + const inPersist = new Promise((r) => (entered = r)); + // Park INSIDE the persist, then let the real write through: the drop + // completes end-to-end, only delayed. + const privPersist = db as unknown as { persistTextIndexDefinitions(defs: { name: string }[]): Promise }; + const original = privPersist.persistTextIndexDefinitions; + const restore = stubTextPersist(db, async (defs) => { + entered(); + await gate; + await original.call(db, defs); + }); + const drop = db.dropTextIndex('body'); + // The drop is parked inside its persist, the index marked staged-drop. + await inPersist; + const priv = db as unknown as { + rebuildTextPostings(): Promise; + text: Map; + }; + const ti = priv.text.get('body')!; + assert.equal(ti.needsRebuild(), true, 'setup: the index is a rebuild candidate'); + // A background compaction's postings rebuild lands in the window. The + // build (if started) sets ti.building synchronously, so this assertion is + // not timing-dependent. + const rebuild = priv.rebuildTextPostings(); + assert.equal(ti.building, false, 'a dropping index must not start a postings build'); + release(); + assert.equal(await drop, true); + await rebuild; + restore(); + // No late build commit: no orphan postings file re-created after the + // drop's close+rm, and no live handle left on the dropped index. + assert.deepEqual((await fs.readdir(dir)).filter((f) => f.includes('postings')), []); + assert.equal((ti as unknown as { pf: unknown }).pf, null); + assert.deepEqual(await textSidecarNames(dir), []); + await db.close(); + } finally { + await fs.rm(dir, { recursive: true, force: true }); + } +}); From 94d1417b5837885a60a53c229329d7d61371c4df Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Mon, 3 Aug 2026 12:31:21 +0800 Subject: [PATCH 09/15] fix(minidb): validate writes before any side effect, canonicalize values once - canonical value at the write boundary: the json codec re-parses the encoded bytes once and every downstream consumer (unique checks, secondary/compound/text indexes, dt extraction) sees exactly the persisted representation, so getter/toJSON/Proxy documents can no longer diverge between the index view and the storage view - reorder the set/batch pipeline so every fallible check happens before any visible side effect: prepare (key/ttl checks, encoding, canonical decode, index field extraction, tokenization) -> unique checks -> ensureMemoryFor eviction -> commit; a constraint failure now leaves the database untouched (no more evicted victims on rejected inserts), and applyOp is structurally pure against pre-validated data - tokenize at the prepare boundary: TextIndex gains prepareAdd/ addPrepared and the buildQueue carries validated key+tokens mutations instead of raw docs, so a throwing custom tokenizer can no longer poison the live view or the queue, and custom-tokenizer output is rejected per token over 0xffff bytes before it can permanently break postings rebuilds; prepared tokens are keyed by index instance so a same-name drop+create mid-write re-tokenizes instead of crossing tokenizers - strict batch structure validation: scanBatchOpRefs/decodeBatchOps reject unknown op types, out-of-bounds lengths, and trailing bytes (offset must equal body length), so a valid-CRC but malformed batch is skipped as a unit and counted via RecoveryInfo.corruptBatches instead of being partially applied Bench vs the stage-1 baseline: json write throughput regression is within the 5% budget (median ~2-4% depending on the measurement). --- packages/minidb/src/codec.ts | 21 ++- packages/minidb/src/index-manager.ts | 9 +- packages/minidb/src/index.ts | 144 ++++++++++++---- packages/minidb/src/recovery.ts | 36 +++- packages/minidb/src/text-index.ts | 66 +++++++- packages/minidb/test/codec.test.ts | 74 ++++++++ packages/minidb/test/defense.test.ts | 8 +- packages/minidb/test/indexes-extra.test.ts | 78 +++++++++ packages/minidb/test/review-round2.test.ts | 186 +++++++++++++++++++-- packages/minidb/test/text-index.test.ts | 164 +++++++++++++++++- 10 files changed, 721 insertions(+), 65 deletions(-) diff --git a/packages/minidb/src/codec.ts b/packages/minidb/src/codec.ts index 623205869e6..cf4a612353d 100644 --- a/packages/minidb/src/codec.ts +++ b/packages/minidb/src/codec.ts @@ -157,6 +157,11 @@ const SUB_HEADER = 1 + 2 + 4 + 4 + 8; export function encodeBatchOps(ops: BatchOp[]): Buffer { let total = 2; for (const op of ops) { + // Encode-side assertion mirroring the strict decode validation: a batch + // body only ever carries SET/DEL sub-ops (review #9). + if (op.type !== TYPE_SET && op.type !== TYPE_DEL) { + throw new RangeError(`batch op type must be SET or DEL, got ${op.type}`); + } total += SUB_HEADER + op.key.length + (op.value ? op.value.length : 0) + (op.meta ? op.meta.length : 0); } const body = Buffer.allocUnsafe(total); @@ -181,11 +186,12 @@ export function encodeBatchOps(ops: BatchOp[]): Buffer { export function decodeBatchOps(body: Buffer): BatchOp[] { const ops: BatchOp[] = []; let o = 0; - if (body.length < 2) return ops; + if (body.length < 2) throw new RangeError('batch body truncated: op count'); const count = body.readUInt16LE(o); o += 2; for (let i = 0; i < count; i++) { if (o + SUB_HEADER > body.length) throw new RangeError('batch op header truncated'); const type = body.readUInt8(o); o += 1; + if (type !== TYPE_SET && type !== TYPE_DEL) throw new RangeError(`batch op has unknown type ${type}`); const keyLen = body.readUInt16LE(o); o += 2; const valLen = body.readUInt32LE(o); o += 4; const metaLen = body.readUInt32LE(o); o += 4; @@ -196,6 +202,9 @@ export function decodeBatchOps(body: Buffer): BatchOp[] { const meta = metaLen ? Buffer.from(body.subarray(o, o + metaLen)) : null; o += metaLen; ops.push({ type, key, value, meta, expireAt }); } + // All-or-nothing structure check: a valid batch body ends exactly after its + // last op — trailing bytes mean the body is malformed (review #9). + if (o !== body.length) throw new RangeError(`batch body has ${body.length - o} trailing byte(s)`); return ops; } @@ -452,17 +461,22 @@ export function scanFrameRefsFile( } /** Scan BATCH body op refs without copying op values. `bodyOff` is the absolute - * file offset where the BATCH body (the outer frame's value) starts. */ + * file offset where the BATCH body (the outer frame's value) starts. + * Strictly validated (review #9): sub-op types must be SET/DEL, every op must + * stay in bounds, and the body must end exactly after its last op — a + * violation throws, so the caller (frameToOps) skips the whole batch instead + * of half-applying it. */ export function scanBatchOpRefs(body: Buffer, bodyOff: number): BatchOpRef[] { const ops: BatchOpRef[] = []; let o = 0; - if (body.length < 2) return ops; + if (body.length < 2) throw new RangeError('batch body truncated: op count'); const count = body.readUInt16LE(o); o += 2; for (let i = 0; i < count; i++) { if (o + SUB_HEADER > body.length) throw new RangeError('batch op header truncated'); const type = body.readUInt8(o); o += 1; + if (type !== TYPE_SET && type !== TYPE_DEL) throw new RangeError(`batch op has unknown type ${type}`); const keyLen = body.readUInt16LE(o); o += 2; const valLen = body.readUInt32LE(o); @@ -479,6 +493,7 @@ export function scanBatchOpRefs(body: Buffer, bodyOff: number): BatchOpRef[] { o += metaLen; ops.push({ type, key, valueOff, valLen, meta, expireAt }); } + if (o !== body.length) throw new RangeError(`batch body has ${body.length - o} trailing byte(s)`); return ops; } diff --git a/packages/minidb/src/index-manager.ts b/packages/minidb/src/index-manager.ts index af565c98d5e..2c950b13bca 100644 --- a/packages/minidb/src/index-manager.ts +++ b/packages/minidb/src/index-manager.ts @@ -326,7 +326,10 @@ export class IndexManager { })); } - /** Throw a UniqueViolationError if adding `doc` for `pk` would violate a unique index. */ + /** Throw a UniqueViolationError if adding `doc` for `pk` would violate a unique index. + * `doc` must be the CANONICAL (persisted-view) value — what the json codec + * actually stored — so the constraint view always matches what a reopen + * rebuilds (stage 11). */ checkUnique(pk: string, doc: unknown): void { for (const idx of this.indexes.values()) checkUniqueOnIndex(idx, pk, doc); // A staged unique index already constrains writes (see `staged`). @@ -340,7 +343,9 @@ export class IndexManager { * one key and reusing its value in another, are accepted (their final state * is still unique). * - * `ops` is the full op list (set AND del); the last op per key wins. + * `ops` is the full op list (set AND del); the last op per key wins. Every + * `doc` must be the CANONICAL (persisted-view) value, the same contract as + * checkUnique (stage 11). * * Incremental: for every value claimed by the batch it probes only that * value's current posting (O(1) equality / O(log N) range per value) and a diff --git a/packages/minidb/src/index.ts b/packages/minidb/src/index.ts index e03f71de4f0..8b50721ef65 100644 --- a/packages/minidb/src/index.ts +++ b/packages/minidb/src/index.ts @@ -244,7 +244,23 @@ interface PreparedOp { expireAt: number; dtNorm: Record | null; pk: string; - valueDecoded: V | undefined; + /** The ONE value representation every downstream consumer (unique checks, + * secondary / compound / text indexes) sees. For the json codec this is the + * decoded form of `value` — exactly what the WAL stores and what a reopen + * rebuilds — so getter/toJSON/Proxy are consumed exactly once, at encode + * time, and the index view can never diverge from the storage view + * (review #5, stage 11). For the buffer/string codecs (no canonical + * concept, no value-derived indexes) it is the value as passed. */ + canonical: V | undefined; + /** Per-text-index precomputed tokens for `canonical` (null per index = not + * indexable → remove at apply). Tokenization and custom-tokenizer + * validation happen HERE, at the prepare boundary, so a throwing tokenizer + * rejects the write before any side effect and applyOp stays infallible + * (reviews #24/#27). Null when there were no text indexes at prepare time. + * Keyed by the TextIndex INSTANCE, not its name: a same-name drop+create + * between prepare and apply must not feed tokens produced by the old + * index's tokenizer into the new one. */ + textTokens: Map | null; } /** Per-flush-group rollback state: the pre-group logical record of every key @@ -1238,16 +1254,32 @@ export class MiniDb { this.ensureWritable(); this.checkKey(key); await this.awaitRotation(); - const op = this.prepareSet(key, value, { ttl, dt }); - await this.ensureMemoryFor([op]); + // Validation before side effects (stage 11): prepare (key/ttl checks, + // encode + canonical, tokenize + custom-tokenizer validation) and the + // unique check run BEFORE ensureMemoryFor can evict anything, so a + // rejected write leaves the database untouched — no eviction, no WAL, no + // memory change (review #6). The whole pipeline runs inside the + // unique-write chain when a unique index exists: check-then-commit stays + // atomic for the chain's whole lifetime, so a WAL-seal retry needs no + // re-check (every violation-creating writer is serialized out). + const run = async (): Promise => { + const op = this.prepareSet(key, value, { ttl, dt }); + if (this.indexes.size && this.indexable(op.canonical)) this.indexes.checkUnique(op.pk, op.canonical); + await this.ensureMemoryFor([op]); + await this.retryOnWalSeal(() => this.commitSetOp(op)); + }; + if (this.hasUniqueIndexes()) await this.serializeUniqueWrites(run); + else await run(); + } - const commit = async (): Promise => { + /** The set() commit body: append the frame and apply the prepared op, + * rolling back (per-op or group) when the WAL write fails. */ + private async commitSetOp(op: PreparedOp): Promise { // Queue behind any in-place WAL recovery: a write issued after a // failure waits for the truncate + poison-clear instead of hitting the // still-poisoned WAL. Null (and zero-cost) when no recovery is running. const recoveryGate = this.walRecoveryGate(); if (recoveryGate) await recoveryGate; - if (this.indexes.size && this.indexable(value)) this.indexes.checkUnique(op.pk, value); const frame = encodeFrame({ type: TYPE_SET, key: op.key, value: op.value, meta: op.meta, expireAt: op.expireAt }); const wal = this.wal; const appended = wal.appendLoc(frame); @@ -1310,10 +1342,6 @@ export class MiniDb { ); } this.maybeAutoCompact(); - }; - - if (this.hasUniqueIndexes()) await this.serializeUniqueWrites(() => this.retryOnWalSeal(commit)); - else await this.retryOnWalSeal(commit); } async del(key: string | Buffer): Promise { @@ -1372,21 +1400,34 @@ export class MiniDb { this.ensureWritable(); await this.awaitRotation(); if (!ops || ops.length === 0) return; - const prepared = ops.map((o) => this.prepareOp(o)); - await this.ensureMemoryFor(prepared); - - const commit = async (): Promise => { - const recoveryGate = this.walRecoveryGate(); - if (recoveryGate) await recoveryGate; + // Same stage-11 ordering as set(): every fallible validation (per-op + // prepare, then the whole-batch unique check against canonical docs) + // precedes ensureMemoryFor's evictions, so a rejected batch has zero + // side effects; the pipeline holds the unique-write chain end to end, so + // a WAL-seal retry of the commit needs no re-check. + const run = async (): Promise => { + const prepared = ops.map((o) => this.prepareOp(o)); if (this.indexes.size) { this.indexes.checkUniqueBatch( prepared.map((o) => ({ pk: o.pk, op: o.type === TYPE_DEL ? ('del' as const) : ('set' as const), - doc: o.valueDecoded, + doc: o.canonical, })), ); } + await this.ensureMemoryFor(prepared); + await this.retryOnWalSeal(() => this.commitBatchOps(prepared)); + }; + if (this.hasUniqueIndexes()) await this.serializeUniqueWrites(run); + else await run(); + } + + /** The batch() commit body: append one BATCH frame and apply every prepared + * op, rolling the whole batch back when the WAL write fails. */ + private async commitBatchOps(prepared: readonly PreparedOp[]): Promise { + const recoveryGate = this.walRecoveryGate(); + if (recoveryGate) await recoveryGate; const body = encodeBatchOps( prepared.map((op) => ({ type: op.type, key: op.key, value: op.value, meta: op.meta, expireAt: op.expireAt })), ); @@ -1456,10 +1497,6 @@ export class MiniDb { this.publishWalRef(pk, wal, seq, loc, op.expireAt, op.dtNorm); } this.maybeAutoCompact(); - }; - - if (this.hasUniqueIndexes()) await this.serializeUniqueWrites(() => this.retryOnWalSeal(commit)); - else await this.retryOnWalSeal(commit); } private prepareOp(o: BatchInputOp): PreparedOp { @@ -1480,13 +1517,39 @@ export class MiniDb { if (ttl !== undefined && !Number.isFinite(ttl)) throw new RangeError('ttl must be a finite number of milliseconds'); const expireAt = ttl ? Date.now() + Math.floor(ttl) : 0; const vbuf = this.encode(value); + // Canonical value (stage 11): the json codec re-parses the encoded bytes + // ONCE, so every downstream consumer sees exactly the persisted value + // (review #5). The decode is infallible here — it re-parses what + // JSON.stringify just produced. Buffer/string codecs have no canonical + // concept and keep the value as passed (their paths never feed indexes). + const canonical = this.codecName === 'json' ? (this.decode(vbuf) as V) : value; + // Tokenize at the prepare boundary (stage 11): a throwing custom + // tokenizer — or one producing an overlong term — rejects the write here, + // before the store/delta/buildQueue can be polluted (reviews #24/#27). + let textTokens: Map | null = null; + if (this.text.size) { + textTokens = new Map(); + for (const ti of this.text.values()) { + textTokens.set(ti, this.indexable(canonical) ? ti.prepareAdd(canonical) : null); + } + } const meta = dtNorm ? Buffer.from(JSON.stringify({ dt: dtNorm })) : null; - return { type: TYPE_SET, key: toBuf(key), value: vbuf, meta, expireAt, dtNorm, pk, valueDecoded: value }; + return { type: TYPE_SET, key: toBuf(key), value: vbuf, meta, expireAt, dtNorm, pk, canonical, textTokens }; } private prepareDel(key: string | Buffer): PreparedOp { this.checkKey(key); - return { type: TYPE_DEL, key: toBuf(key), value: null, meta: null, expireAt: 0, dtNorm: null, pk: this.pk(key), valueDecoded: undefined }; + return { + type: TYPE_DEL, + key: toBuf(key), + value: null, + meta: null, + expireAt: 0, + dtNorm: null, + pk: this.pk(key), + canonical: undefined, + textTokens: null, + }; } /** Apply a prepared op to the store + derived indexes, writing the key's @@ -1494,12 +1557,15 @@ export class MiniDb { * poison + group-rollback) on failure. `out.prev` is assigned before any * mutation, so it is valid even when the apply throws. * - * CONTRACT: applyOp must not throw — every fallible input validation - * belongs to the prepare phase (stage 11 moves unique checks, the - * tokenizer and canonical extraction there, making this structural). - * Until then the commit bodies wrap the call in a defensive try that - * converts a throw into a WAL poison + group rollback + in-place recovery; - * that path is not the normal one. */ + * CONTRACT: applyOp must not throw. Stage 11 makes this structural: every + * fallible input validation lives in the prepare phase (key/ttl checks, + * encoding, the canonical decode, tokenization + custom-tokenizer output + * validation) and unique checks run before ensureMemoryFor, so the body + * below is pure assignment against pre-validated data. The ONE remaining + * fallible branch is a text index registered between prepare and apply + * (a createTextIndex racing this write — see the comment inline); the + * commit bodies' defensive try (stage 7) stays as the backstop for it and + * for catastrophic store I/O. */ private applyOp(op: PreparedOp, out: { prev: StoreRecord | undefined }): void { const oldBuf = this.store.get(op.pk); out.prev = oldBuf !== undefined ? this.store.map.get(op.pk) : undefined; @@ -1510,14 +1576,28 @@ export class MiniDb { // are durably in db.wal. this.store.set(op.key, op.value!, op.expireAt, op.dtNorm); this.dt.set(op.pk, op.dtNorm); - this.compound.add(op.pk, op.valueDecoded, op.dtNorm); + this.compound.add(op.pk, op.canonical, op.dtNorm); if (this.indexes.size) { if (this.indexable(oldDoc)) this.indexes.remove(op.pk, oldDoc); - if (this.indexable(op.valueDecoded)) this.indexes.add(op.pk, op.valueDecoded); + if (this.indexable(op.canonical)) this.indexes.add(op.pk, op.canonical); } for (const ti of this.text.values()) { - if (this.indexable(op.valueDecoded)) ti.add(op.pk, op.valueDecoded); - else ti.remove(op.pk); + const tokens = op.textTokens?.get(ti); + if (tokens !== undefined) { + // Pre-tokenized and validated at the prepare boundary (null = the + // canonical doc is not indexable → drop the key from this index). + if (tokens === null) ti.remove(op.pk); + else ti.addPrepared(op.pk, tokens); + } else if (this.indexable(op.canonical)) { + // An index registered AFTER this op was prepared (createTextIndex + // registered it mid-write), or replaced by a same-name drop+create + // since: it has no prepared tokens, so tokenize here. A throwing + // tokenizer in this narrow race is covered by the commit body's + // defensive try (stage 7), exactly as before stage 11. + ti.add(op.pk, op.canonical); + } else { + ti.remove(op.pk); + } } } else if (op.type === TYPE_DEL) { const existed = this.store.del(op.key); diff --git a/packages/minidb/src/recovery.ts b/packages/minidb/src/recovery.ts index bec3d667d1a..a17afd89097 100644 --- a/packages/minidb/src/recovery.ts +++ b/packages/minidb/src/recovery.ts @@ -61,6 +61,10 @@ export interface RecoveryInfo { * none). */ snapshotDev: number; snapshotIno: number; + /** BATCH frames whose body failed strict structure validation (valid outer + * CRC but malformed sub-ops — review #9): the whole batch was skipped + * rather than half-applied. */ + corruptBatches: number; /** Generation-churn retries recovery needed before it paired a consistent * snapshot/WAL set (0 on a stable directory — see the file header). */ generationRetries: number; @@ -127,8 +131,15 @@ function* setRefToOps( * 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 { + * whole batch rather than half-applying it (and is reported through + * `onCorruptBatch` so recovery can account it). Unknown frame types yield nothing. */ +export function* frameToOps( + f: FrameRef, + file: ValueLoc['file'], + fd: number, + valueMode: ValueMode, + onCorruptBatch?: () => void, +): Generator { if (f.type === TYPE_SET) { yield* setRefToOps(f, file, fd, valueMode); } else if (f.type === TYPE_DEL) { @@ -141,6 +152,7 @@ export function* frameToOps(f: FrameRef, file: ValueLoc['file'], fd: number, val // 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. + onCorruptBatch?.(); return; } for (const op of ops) { @@ -150,9 +162,16 @@ export function* frameToOps(f: FrameRef, file: ValueLoc['file'], fd: number, val } } -function applyFrames(frames: FrameRef[], file: ValueLoc['file'], fd: number, store: Store, valueMode: ValueMode): void { +function applyFrames( + frames: FrameRef[], + file: ValueLoc['file'], + fd: number, + store: Store, + valueMode: ValueMode, + onCorruptBatch?: () => void, +): void { for (const f of frames) { - for (const op of frameToOps(f, file, fd, valueMode)) { + for (const op of frameToOps(f, file, fd, valueMode, onCorruptBatch)) { 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); } @@ -286,6 +305,10 @@ async function recoverPass({ truncate: boolean; valueMode: ValueMode; }): Promise { + let corruptBatches = 0; + const countCorruptBatch = (): void => { + corruptBatches++; + }; let snapshotFrames = 0; let snapshotBytes = 0; let snapshotCorrupt: [number, number][] = []; @@ -297,7 +320,7 @@ async function recoverPass({ snapScanned = { dev: st.dev, ino: st.ino, size: st.size }; snapshotBytes = st.size; const r = scanFrameRefsFd(fd, { onCorrupt: mode }); - applyFrames(r.frames, 'snapshot', fd, store, valueMode); + applyFrames(r.frames, 'snapshot', fd, store, valueMode, countCorruptBatch); snapshotFrames = r.frames.length; snapshotCorrupt = r.corruptRanges; } finally { @@ -323,7 +346,7 @@ async function recoverPass({ walSizeFloor = st.size; walBytes = st.size; const r = scanFrameRefsFd(fd, { onCorrupt: mode }); - applyFrames(r.frames, 'wal', fd, store, valueMode); + applyFrames(r.frames, 'wal', fd, store, valueMode, countCorruptBatch); walFrames = r.frames.length; walCorrupt = r.corruptRanges; walScanEnd = r.eofOffset; @@ -366,6 +389,7 @@ async function recoverPass({ walIno: walScanned?.ino ?? 0, snapshotDev: snapScanned?.dev ?? 0, snapshotIno: snapScanned?.ino ?? 0, + corruptBatches, generationRetries: 0, // recover() overwrites with the real attempt count }, anchors: { diff --git a/packages/minidb/src/text-index.ts b/packages/minidb/src/text-index.ts index e9ed4b35749..ffb2eb55673 100644 --- a/packages/minidb/src/text-index.ts +++ b/packages/minidb/src/text-index.ts @@ -35,6 +35,11 @@ const CJK = /[\u3400-\u9fff\u3040-\u30ff\uff00-\uffef]+/g; // tokens can never be real query terms — drop them at tokenization so one // pathological document cannot destroy the index. const MAX_TERM_CHARS = 0xffff; +// The same postings uint16 limit in UTF-8 BYTES (encodeRecord's unit). A +// custom tokenizer's output is validated against it at every write boundary +// (tokensFor): an overlong term rejects the write loudly instead of poisoning +// the next postings rebuild (review #27). +const MAX_TERM_BYTES = 0xffff; const yieldToLoop = (): Promise => new Promise((r) => setImmediate(r)); // `build()` yields to the event loop at the first of these two watermarks, so @@ -131,9 +136,12 @@ export interface TextIndexBuild { const EMPTY_MAP: ReadonlyMap = new Map(); -/** One write that landed while a `build()` was in flight (see buildQueue). */ +/** One write that landed while a `build()` was in flight (see buildQueue). + * Carries the VALIDATED mutation — key + precomputed tokens, never the raw + * doc — so the swap-time replay cannot throw on a tokenizer failure + * (review #24). */ type BuildOp = - | { readonly kind: 'add'; readonly key: string; readonly doc: unknown } + | { readonly kind: 'add'; readonly key: string; readonly tokens: readonly string[] } | { readonly kind: 'remove'; readonly key: string }; /** Bounded collector for the K best hits by (score desc, key asc). The heap @@ -189,6 +197,10 @@ export class TextIndex { private readonly fields: readonly string[] | null; private readonly tokenizer: (text: string) => string[]; private readonly queryTokenizer: (text: string) => string[]; + /** True when a custom (injected) index tokenizer is in use: its output is + * untrusted and gets the per-term length validation in tokensFor. The + * built-in tokenizer enforces the limit itself and skips the check. */ + private readonly customTokenizer: boolean; private readonly path: string | null; private readonly cacheTerms: number; @@ -236,6 +248,7 @@ export class TextIndex { constructor(opts: TextIndexOptions = {}) { this.fields = opts.fields ?? null; this.tokenizer = opts.tokenizer ?? tokenize; + this.customTokenizer = opts.tokenizer !== undefined; this.queryTokenizer = opts.queryTokenizer ?? this.tokenizer; this.path = opts.postingsPath ?? null; this.cacheTerms = opts.cacheTerms ?? 1024; @@ -252,6 +265,31 @@ export class TextIndex { return stringLeaves(doc).join(' '); } + /** Extract + tokenize a document, validating a CUSTOM tokenizer's output at + * this boundary: every term must fit the postings record's uint16 utf8 + * length, or one pathological document would make every later postings + * rebuild throw (review #27). Throws BEFORE any state mutates, so a bad + * document can never pollute the live view, the delta, or the build queue + * (review #24). */ + private tokensFor(doc: unknown): string[] { + const tokens = this.tokenizer(this.extract(doc)); + if (this.customTokenizer) { + for (const t of tokens) { + if (Buffer.byteLength(t, 'utf8') > MAX_TERM_BYTES) { + throw new RangeError(`text index tokenizer produced a term longer than ${MAX_TERM_BYTES} utf8 bytes`); + } + } + } + return tokens; + } + + /** The write path's prepare boundary: tokenize + validate a document for a + * later infallible `addPrepared`. A throwing (custom) tokenizer rejects the + * write HERE — before the store, delta, or build queue can be touched. */ + prepareAdd(doc: unknown): readonly string[] { + return this.tokensFor(doc); + } + /** Number of distinct terms currently indexed (base + delta). */ termCount(): number { if (this.memBase) { @@ -353,10 +391,12 @@ export class TextIndex { return { add: (key, value): number => { if (done) throw new Error('text index build already finished'); + // Tokenize (and validate) BEFORE staging anything: a throwing + // tokenizer must not leave a ghost docID in the staged state. + const tokens = this.tokensFor(value); const docID = newKeys.length; newKeys.push(key); newKeyToId.set(key, docID); - const tokens = this.tokenizer(this.extract(value)); const counts = new Map(); for (const t of tokens) counts.set(t, (counts.get(t) ?? 0) + 1); for (const [t, c] of counts) { @@ -461,14 +501,27 @@ export class TextIndex { this.N = n; this.buildQueue = null; for (const op of queue) { - if (op.kind === 'add') this.add(op.key, op.doc); + // The queue carries validated mutations (key + tokens), so the replay + // cannot throw — no half-replayed queue on a tokenizer failure. + if (op.kind === 'add') this.addPrepared(op.key, op.tokens); else this.remove(op.key); } } - /** Add or replace a document. Overwrites tombstone the old docID. */ + /** Add or replace a document. Tokenizes (and validates a custom tokenizer's + * output) BEFORE any state changes, so a throwing tokenizer leaves the + * live view, the delta, and the build queue untouched (review #24); an + * overwrite's old document stays searchable. */ add(key: string, doc: unknown): void { - this.buildQueue?.push({ kind: 'add', key, doc }); + this.addPrepared(key, this.tokensFor(doc)); + } + + /** Apply an already-tokenized, already-validated document write (see + * prepareAdd). Overwrites tombstone the old docID. Must not throw: pure + * map/set bookkeeping — this is the only text-index entry point the db's + * purified applyOp uses. */ + addPrepared(key: string, tokens: readonly string[]): void { + this.buildQueue?.push({ kind: 'add', key, tokens }); // The overwrite's internal remove must NOT queue a second op: replaying // the queue applies the add (which itself displaces the old docID), and a // queued remove would then delete the freshly-added doc. @@ -476,7 +529,6 @@ export class TextIndex { const docID = this.keys.length; this.keys.push(key); this.keyToId.set(key, docID); - const tokens = this.tokenizer(this.extract(doc)); const counts = new Map(); for (const t of tokens) counts.set(t, (counts.get(t) ?? 0) + 1); for (const [t, c] of counts) { diff --git a/packages/minidb/test/codec.test.ts b/packages/minidb/test/codec.test.ts index 0482aeb010f..bb0987f1b8e 100644 --- a/packages/minidb/test/codec.test.ts +++ b/packages/minidb/test/codec.test.ts @@ -3,6 +3,9 @@ import { test } from 'vitest'; import assert from 'node:assert/strict'; import { encodeFrame, + encodeBatchOps, + decodeBatchOps, + scanBatchOpRefs, FrameParser, CorruptFrameError, TYPE_SET, @@ -98,3 +101,74 @@ test('frame length = header + payload + crc trailer', () => { const f = encodeFrame({ type: TYPE_SET, key: B('k'), value: B('v') }); assert.equal(f.length, HEADER_SIZE + 1 + 1 + 4); }); + +// ---- BATCH body strict structure validation (stage 11, review #9) ---------- + +/** Hand-rolled batch-body encoder: unlike encodeBatchOps it imposes no type + * validation, so tests can craft bodies the real encoder would never emit. */ +function rawBatchBody(ops) { + const parts = []; + let total = 2; + for (const op of ops) { + const key = B(op.key); + const value = op.value === undefined ? Buffer.alloc(0) : B(op.value); + total += 1 + 2 + 4 + 4 + 8 + key.length + value.length; + parts.push({ ...op, key, value }); + } + const body = Buffer.alloc(total); + let o = 0; + body.writeUInt16LE(parts.length, o); o += 2; + for (const op of parts) { + body.writeUInt8(op.type, o); o += 1; + body.writeUInt16LE(op.key.length, o); o += 2; + body.writeUInt32LE(op.value.length, o); o += 4; + body.writeUInt32LE(0, o); o += 4; // metaLen + body.writeBigInt64LE(0n, o); o += 8; // expireAt + op.key.copy(body, o); o += op.key.length; + op.value.copy(body, o); o += op.value.length; + } + return body; +} + +test('decodeBatchOps / scanBatchOpRefs round-trip a valid body', () => { + const body = encodeBatchOps([ + { type: TYPE_SET, key: B('a'), value: B('1'), meta: null, expireAt: 0 }, + { type: TYPE_DEL, key: B('b'), value: null, meta: null, expireAt: 0 }, + ]); + const ops = decodeBatchOps(body); + assert.equal(ops.length, 2); + assert.equal(ops[0].type, TYPE_SET); + assert.equal(ops[0].key.toString(), 'a'); + assert.equal(ops[1].type, TYPE_DEL); + const refs = scanBatchOpRefs(body, 0); + assert.equal(refs.length, 2); + assert.equal(refs[0].valueOff, 2 + 19 + 1); // count + sub-header + key +}); + +test('batch decoders reject an unknown sub-op type', () => { + const body = rawBatchBody([ + { type: TYPE_SET, key: 'accepted', value: 'yes' }, + { type: 99, key: 'unknown', value: 'ignored' }, + ]); + assert.throws(() => decodeBatchOps(body), /batch op has unknown type 99/); + assert.throws(() => scanBatchOpRefs(body, 0), /batch op has unknown type 99/); +}); + +test('batch decoders reject trailing bytes after the last op', () => { + const valid = encodeBatchOps([{ type: TYPE_SET, key: B('a'), value: B('1'), meta: null, expireAt: 0 }]); + const body = Buffer.concat([valid, Buffer.from([0xde, 0xad])]); + assert.throws(() => decodeBatchOps(body), /batch body has 2 trailing byte\(s\)/); + assert.throws(() => scanBatchOpRefs(body, 0), /batch body has 2 trailing byte\(s\)/); +}); + +test('batch decoders reject a body shorter than the count field', () => { + assert.throws(() => decodeBatchOps(Buffer.alloc(1)), /batch body truncated: op count/); + assert.throws(() => scanBatchOpRefs(Buffer.alloc(1), 0), /batch body truncated: op count/); +}); + +test('encodeBatchOps refuses to emit a non-SET/DEL sub-op (encode-side assertion)', () => { + assert.throws( + () => encodeBatchOps([{ type: 99, key: B('x'), value: B('y'), meta: null, expireAt: 0 }]), + /batch op type must be SET or DEL, got 99/, + ); +}); diff --git a/packages/minidb/test/defense.test.ts b/packages/minidb/test/defense.test.ts index 69712fdccdb..e0d96ada793 100644 --- a/packages/minidb/test/defense.test.ts +++ b/packages/minidb/test/defense.test.ts @@ -48,9 +48,11 @@ test('encodeFrame rejects a non-buffer meta', () => { // --- codec.decodeBatchOps bounds checks ------------------------------------ -test('decodeBatchOps returns [] for a body shorter than the count field', () => { - assert.deepEqual(decodeBatchOps(Buffer.alloc(0)), []); - assert.deepEqual(decodeBatchOps(Buffer.alloc(1)), []); +test('decodeBatchOps rejects a body shorter than the count field', () => { + // Strict structure validation (stage 11, review #9): a body that cannot + // even carry the op count is malformed — never a silent empty batch. + assert.throws(() => decodeBatchOps(Buffer.alloc(0)), /batch body truncated: op count/); + assert.throws(() => decodeBatchOps(Buffer.alloc(1)), /batch body truncated: op count/); }); test('decodeBatchOps throws on a truncated op header', () => { diff --git a/packages/minidb/test/indexes-extra.test.ts b/packages/minidb/test/indexes-extra.test.ts index 55b9d64f276..fc16437c156 100644 --- a/packages/minidb/test/indexes-extra.test.ts +++ b/packages/minidb/test/indexes-extra.test.ts @@ -446,3 +446,81 @@ test('a staged unique index constrains writes during its persist window', async await fs.rm(dir, { recursive: true, force: true }); } }); + +// ---- stage 11: canonical value — one representation for every view -------- + +test('toJSON/getter/Proxy docs: get, secondary/compound/text indexes and unique checks all see the persisted view (and reopen agrees)', async () => { + const dir = await tmpDir(); + try { + let db = await MiniDb.open({ dir, valueCodec: 'json', fsyncPolicy: 'no', autoCompact: false }); + await db.createIndex('byV', { field: 'v' }); + await db.createIndex('byW', { field: 'w' }); + await db.createIndex('byU', { field: 'u', unique: true }); + await db.createCompoundIndex('byG', { groupBy: 'g', orderBy: 'o' }); + await db.createTextIndex('ft', { fields: ['t'] }); + + // A getter whose value CHANGES per read: encode sees 'v-1'; before stage + // 11 the secondary index re-read the getter and indexed 'v-2' — the live + // index and the store (and every reopen) disagreed. + let reads = 0; + const getterDoc: Record = { u: 'u1', g: 'G', o: 5, t: 'alpha bravo' }; + Object.defineProperty(getterDoc, 'v', { + enumerable: true, + get: () => `v-${++reads}`, + }); + await db.set('k1', getterDoc); + assert.equal(reads, 1, 'the getter is consumed exactly once (at encode)'); + + // toJSON replaces the persisted shape entirely. + const toJsonDoc = { u: 'u2', w: 'raw-w', t: 'hidden', toJSON: () => ({ u: 'u2', w: 'json-w' }) }; + await db.set('k2', toJsonDoc as unknown as Record); + + // A Proxy is transparently persisted as the plain object it wraps. + const proxyDoc = new Proxy({ u: 'u3', w: 'pw', t: 'proxied term' }, {}); + await db.set('k3', proxyDoc); + + const assertViews = (label: string, db: MiniDb) => { + // get(): the decoded stored bytes. + assert.equal((db.get('k1') as { v: string }).v, 'v-1', `${label}: get returns the persisted getter value`); + assert.deepEqual(db.get('k2'), { u: 'u2', w: 'json-w' }, `${label}: get returns the toJSON shape`); + assert.deepEqual(db.get('k3'), { u: 'u3', w: 'pw', t: 'proxied term' }, `${label}: get returns the Proxy target shape`); + // Secondary indexes: the persisted values, never a second getter read. + assert.deepEqual(db.findEq('byV', 'v-1').map((r) => r.key), ['k1'], `${label}: index has the persisted value`); + assert.deepEqual(db.findEq('byV', 'v-2'), [], `${label}: the never-persisted second getter read is NOT indexed`); + assert.deepEqual(db.findEq('byW', 'json-w').map((r) => r.key), ['k2']); + assert.deepEqual(db.findEq('byW', 'raw-w'), [], `${label}: the raw (unpersisted) toJSON field is NOT indexed`); + assert.deepEqual(db.findEq('byW', 'pw').map((r) => r.key), ['k3']); + // Compound index. + assert.deepEqual(db.compoundRange('byG', 'G').map((r) => r.key), ['k1'], `${label}: compound index view`); + // Text index: toJSON dropped 't' from k2, so only k1/k3 have text. + assert.deepEqual(db.search('ft', 'alpha').map((r) => r.key), ['k1'], `${label}: text index view`); + assert.deepEqual(db.search('ft', 'hidden'), [], `${label}: text the toJSON hid is NOT indexed`); + assert.deepEqual(db.search('ft', 'proxied').map((r) => r.key), ['k3']); + }; + assertViews('live', db); + + // Unique checks consume the canonical view too: a conflicting plain doc + // is rejected against the value the getter/toJSON/Proxy actually stored. + await assert.rejects(db.set('k4', { u: 'u1' }), UniqueViolationError); + await assert.rejects(db.batch([{ op: 'set', key: 'k5', value: { u: 'u3' } }]), UniqueViolationError); + // And re-setting k1 (holder = same key) stays legal despite the getter. + await db.set('k1', getterDoc); + assert.equal((db.get('k1') as { v: string }).v, 'v-2', 're-set re-encodes (one more getter read)'); + + await db.close(); + db = await MiniDb.open({ dir, valueCodec: 'json', fsyncPolicy: 'no', autoCompact: false }); + // The reopened index views are rebuilt from the persisted bytes — they + // must be identical to the live views (v-2 now, after the re-set). + assert.deepEqual(db.findEq('byV', 'v-2').map((r) => r.key), ['k1'], 'reopen: same persisted view'); + assert.equal((db.get('k1') as { v: string }).v, 'v-2'); + assert.deepEqual(db.get('k2'), { u: 'u2', w: 'json-w' }); + assert.deepEqual(db.findEq('byW', 'json-w').map((r) => r.key), ['k2']); + assert.deepEqual(db.compoundRange('byG', 'G').map((r) => r.key), ['k1']); + assert.deepEqual(db.search('ft', 'alpha').map((r) => r.key), ['k1']); + assert.deepEqual(db.search('ft', 'proxied').map((r) => r.key), ['k3']); + await assert.rejects(db.set('k6', { u: 'u1' }), UniqueViolationError); + await db.close(); + } finally { + await fs.rm(dir, { recursive: true, force: true }); + } +}); diff --git a/packages/minidb/test/review-round2.test.ts b/packages/minidb/test/review-round2.test.ts index e3d5c4a9763..8396203ea74 100644 --- a/packages/minidb/test/review-round2.test.ts +++ b/packages/minidb/test/review-round2.test.ts @@ -9,8 +9,9 @@ import fs from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; import net from 'node:net'; -import { MiniDb } from '../src/index.js'; +import { MiniDb, UniqueViolationError } from '../src/index.js'; import { startServer } from '../src/server.js'; +import { encodeBatchOps, encodeFrame, TYPE_BATCH, TYPE_SET } from '../src/codec.js'; async function tmpDir() { return fs.mkdtemp(path.join(os.tmpdir(), 'minidb-r2-')); @@ -550,15 +551,18 @@ test('WAL poison: an applyOp contract violation poisons the WAL and rolls the gr const dir = await tmpDir(); let db = await MiniDb.open<{ t: string }>({ dir, valueCodec: 'json', fsyncPolicy: 'no', activeExpireIntervalMs: 0 }); await db.createTextIndex('ft', { fields: ['t'] }); - const ti = (db as unknown as { text: Map void }> }).text.get('ft')!; - const origAdd = ti.add.bind(ti); + // Stage 11: applyOp's text-index entry point is addPrepared (pre-validated + // tokens); breaking its must-not-throw contract exercises the same + // defensive path the old ti.add injection did. + const ti = (db as unknown as { text: Map void }> }).text.get('ft')!; + const origAdd = ti.addPrepared.bind(ti); let boom = true; - ti.add = (k: string, v: unknown) => { + ti.addPrepared = (k: string, t: readonly string[]) => { if (boom) { boom = false; throw new Error('injected apply failure'); } - origAdd(k, v); + origAdd(k, t); }; const err: Error = await db.set('doc', { t: 'hello world' }).then( @@ -662,15 +666,15 @@ test('WAL poison: an applyOp violation queued behind an in-flight batch still le const opA = db.set('a', { t: 'first' }); await waitFor(() => writevCalls === 1, "op A's writev to be in flight"); - const ti = (db as unknown as { text: Map void }> }).text.get('ft')!; - const origAdd = ti.add.bind(ti); + const ti = (db as unknown as { text: Map void }> }).text.get('ft')!; + const origAdd = ti.addPrepared.bind(ti); let boom = true; - ti.add = (k: string, v: unknown) => { + ti.addPrepared = (k: string, t: readonly string[]) => { if (boom) { boom = false; throw new Error('injected apply failure'); } - origAdd(k, v); + origAdd(k, t); }; await assert.rejects(db.set('b', { t: 'second' }), /injected apply failure/); assert.ok(db.wal.poison, 'the apply violation poisoned the pending queue'); @@ -704,8 +708,8 @@ test('WAL poison: an applyOp violation on a never-enqueued frame (sealed WAL) do await db.createTextIndex('ft', { fields: ['t'] }); await db.set('old', { t: 'keep' }); - const ti = (db as unknown as { text: Map void }> }).text.get('ft')!; - ti.add = () => { + const ti = (db as unknown as { text: Map void }> }).text.get('ft')!; + ti.addPrepared = () => { throw new Error('injected apply failure'); }; // Simulate the rotation seal (what db.wal.seal() does inside compaction). @@ -839,3 +843,163 @@ test('backup waits out an in-flight WAL recovery instead of copying the un-acked await fs.rm(backupDir, { recursive: true, force: true }); await fs.rm(restoreDir, { recursive: true, force: true }); }); + +// --- stage 11: validation before side effects -------------------------------- +// +// The write pipeline is prepare (encode + canonical + tokenize) → unique +// check → ensureMemoryFor (eviction) → commit, so a rejected write leaves the +// database untouched (review #6), and a structurally corrupt batch frame is +// skipped wholesale by recovery (review #9). + +test('a unique-conflicting insert is rejected before any eviction side effect (review #6)', async () => { + const dir = await tmpDir(); + try { + const db = await MiniDb.open({ + dir, + valueCodec: 'json', + fsyncPolicy: 'no', + autoCompact: false, + maxMemoryBytes: 190, + maxMemoryPolicy: 'evict-lru', + }); + await db.createIndex('u', { field: 'u', unique: true }); + await db.set('owner', { u: 'taken', pad: 'x'.repeat(20) }); + await db.set('victim', { u: 'free', pad: 'y'.repeat(20) }); + db.get('owner'); // touch: 'victim' becomes the LRU eviction candidate + const evictionsBefore = db.stats.evictions; + + // The conflicting value fails the unique check; before stage 11 the + // eviction ran FIRST and the rejected insert still deleted the victim. + await assert.rejects(db.set('bad', { u: 'taken', pad: 'z'.repeat(100) }), UniqueViolationError); + assert.equal(db.get('bad'), undefined); + assert.ok(db.get('owner'), 'untouched keys stay'); + assert.ok(db.get('victim'), 'the failed insert must not evict the LRU victim'); + assert.equal(db.stats.evictions, evictionsBefore, 'zero side effects: no eviction ran'); + + // Sanity: a LEGAL oversized write under the same budget still evicts — + // the eviction pressure is real, only the ordering changed. + await db.set('big', { u: 'new', pad: 'z'.repeat(100) }); + assert.ok(db.stats.evictions > evictionsBefore, 'a legal write still evicts under pressure'); + await db.close(); + } finally { + await fs.rm(dir, { recursive: true, force: true }); + } +}); + +/** Hand-rolled batch-body encoder without encodeBatchOps' type assertion, so + * a test can craft a body the real encoder would never emit (review #9). */ +function rawBatchBody(ops: { type: number; key: string; value?: string }[]): Buffer { + const parts: { type: number; key: Buffer; value: Buffer }[] = []; + let total = 2; + for (const op of ops) { + const key = Buffer.from(op.key); + const value = op.value === undefined ? Buffer.alloc(0) : Buffer.from(op.value); + total += 1 + 2 + 4 + 4 + 8 + key.length + value.length; + parts.push({ type: op.type, key, value }); + } + const body = Buffer.alloc(total); + let o = 0; + body.writeUInt16LE(parts.length, o); o += 2; + for (const op of parts) { + body.writeUInt8(op.type, o); o += 1; + body.writeUInt16LE(op.key.length, o); o += 2; + body.writeUInt32LE(op.value.length, o); o += 4; + body.writeUInt32LE(0, o); o += 4; // metaLen + body.writeBigInt64LE(0n, o); o += 8; // expireAt + op.key.copy(body, o); o += op.key.length; + op.value.copy(body, o); o += op.value.length; + } + return body; +} + +/** Append a raw TYPE_BATCH frame (valid CRC) to a closed db's WAL and reopen: + * the frame's body fails strict validation, so recovery must skip the WHOLE + * batch — the `accepted` sub-op must not be applied (review #9). */ +async function reopenWithAppendedBatch(body: Buffer): Promise<{ dir: string; db: MiniDb }> { + const dir = await tmpDir(); + let db = await MiniDb.open({ dir, ...MEM_OPTS }); + await db.close(); + await fs.appendFile(path.join(dir, 'db.wal'), encodeFrame({ type: TYPE_BATCH, key: Buffer.alloc(0), value: body })); + db = await MiniDb.open({ dir, ...MEM_OPTS }); + return { dir, db }; +} + +test('recovery skips a batch with an unknown sub-op type wholesale (review #9)', async () => { + const body = rawBatchBody([ + { type: TYPE_SET, key: 'accepted', value: 'yes' }, + { type: 99, key: 'unknown', value: 'ignored' }, + ]); + const { dir, db } = await reopenWithAppendedBatch(body); + try { + assert.equal(db.get('accepted'), undefined, 'the whole batch is skipped, not half-applied'); + assert.equal(db.get('unknown'), undefined); + assert.equal(db.recoveryInfo!.corruptBatches, 1, 'the skipped batch is accounted'); + // The db stays fully usable after the skip. + await db.set('after', 'ok'); + assert.equal(db.get('after'), 'ok'); + await db.close(); + } finally { + await fs.rm(dir, { recursive: true, force: true }); + } +}); + +test('recovery skips a batch with trailing bytes wholesale (review #9)', async () => { + const valid = encodeBatchOps([{ type: TYPE_SET, key: Buffer.from('accepted'), value: Buffer.from('yes'), meta: null, expireAt: 0 }]); + const body = Buffer.concat([valid, Buffer.from([0xde, 0xad])]); + const { dir, db } = await reopenWithAppendedBatch(body); + try { + assert.equal(db.get('accepted'), undefined, 'trailing bytes invalidate the whole batch'); + assert.equal(db.recoveryInfo!.corruptBatches, 1); + await db.set('after', 'ok'); + assert.equal(db.get('after'), 'ok'); + await db.close(); + } finally { + await fs.rm(dir, { recursive: true, force: true }); + } +}); + +test('a mid-batch applyOp violation rolls the whole batch back — memory and reopen agree (stage 7 group rollback)', async () => { + const dir = await tmpDir(); + let db = await MiniDb.open<{ t: string }>({ dir, valueCodec: 'json', fsyncPolicy: 'no', activeExpireIntervalMs: 0 }); + await db.createTextIndex('ft', { fields: ['t'] }); + // Break applyOp's must-not-throw contract on the SECOND op of the batch: + // the first op is already applied when the batch fails, and both must + // disappear (stage 11 keeps applyOp pure; this exercises the defensive + // poison + group rollback that remains as the backstop). + const ti = (db as unknown as { text: Map void }> }).text.get('ft')!; + const origAdd = ti.addPrepared.bind(ti); + let calls = 0; + ti.addPrepared = (k: string, t: readonly string[]) => { + calls++; + if (calls === 2) throw new Error('injected mid-batch apply failure'); + origAdd(k, t); + }; + + const err: Error = await db + .batch([ + { op: 'set', key: 'x1', value: { t: 'first' } }, + { op: 'set', key: 'x2', value: { t: 'second' } }, + ]) + .then( + () => { + throw new Error('expected the batch to reject'); + }, + (e) => e as Error, + ); + assert.match(String(err), /injected mid-batch apply failure/); + assert.equal((err as { ambiguous?: boolean }).ambiguous, true); + assert.equal(db.get('x1'), undefined, 'the op applied before the failure is rolled back too'); + assert.equal(db.get('x2'), undefined); + assert.deepEqual(db.search('ft', 'first'), [], 'no half-batch in the text index'); + assert.deepEqual(db.search('ft', 'second'), []); + + // Later writes land after the in-place recovery; reopen agrees with memory. + await db.set('after', { t: 'fine' }); + await db.close(); + db = await MiniDb.open<{ t: string }>({ dir, valueCodec: 'json', fsyncPolicy: 'no', activeExpireIntervalMs: 0 }); + assert.equal(db.get('x1'), undefined, 'the revoked batch never reached disk'); + assert.equal(db.get('x2'), undefined); + assert.equal(db.get('after')?.t, 'fine'); + await db.close(); + await fs.rm(dir, { recursive: true, force: true }); +}); diff --git a/packages/minidb/test/text-index.test.ts b/packages/minidb/test/text-index.test.ts index 8a2642acbec..c1e00d01076 100644 --- a/packages/minidb/test/text-index.test.ts +++ b/packages/minidb/test/text-index.test.ts @@ -10,7 +10,7 @@ import fssync from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { MiniDb } from '../src/index.js'; -import { TextIndex } from '../src/text-index.js'; +import { TextIndex, tokenize } from '../src/text-index.js'; import { normalizeLiteral, ngramTerm, createNgramTokenizer } from '../src/trigram.js'; import { encodePostingList, @@ -940,3 +940,165 @@ test('MiniDb: dropTextIndex persist window: a compaction postings rebuild skips await fs.rm(dir, { recursive: true, force: true }); } }); + +// ---- stage 11: tokenizer output validated at the write boundary ------------ + +type TiPrivates = { + delta: Map>; + deltaCount: number; + docLen: Map; + keys: (string | undefined)[]; + keyToId: Map; + buildQueue: unknown; +}; + +test('TextIndex: a throwing tokenizer leaves the live view, delta and buildQueue untouched (review #24)', () => { + let fail = false; + const ti = new TextIndex({ + tokenizer: (s) => { + if (fail) throw new Error('boom'); + return tokenize(s); + }, + // A working query tokenizer (the ngram pair pattern): searches must not + // go through the failing index-side tokenizer. + queryTokenizer: (s) => tokenize(s), + }); + const priv = ti as unknown as TiPrivates; + + ti.add('k', { bio: 'hello world' }); + assert.deepEqual(ti.search('hello').map((h) => h.key), ['k']); + + // Overwrite with a failing tokenizer: rejected BEFORE any mutation, so the + // old document stays searchable instead of being tombstoned into a ghost. + fail = true; + assert.throws(() => { + ti.add('k', { bio: 'goodbye world' }); + }, /boom/); + assert.equal(ti.N, 1); + assert.deepEqual(ti.search('hello').map((h) => h.key), ['k'], 'old doc survives the failed overwrite'); + assert.deepEqual(ti.search('goodbye'), []); + assert.equal(priv.docLen.size, 1, 'no ghost docLen entry'); + assert.equal(priv.keys.length, 1, 'no ghost docID'); + assert.equal(priv.deltaCount, 2, 'delta holds exactly the old doc’s terms'); + + // A fresh key fails just as cleanly. + assert.throws(() => { + ti.add('fresh', { bio: 'quux' }); + }, /boom/); + assert.equal(ti.N, 1); + assert.equal(priv.keyToId.has('fresh'), false); + + // Mid-build: the failed write never reaches the build queue either, so the + // swap-time replay cannot re-throw half-way through the queue. + fail = false; + const b = ti.beginBuild(); + b.add('staged', { bio: 'staged doc' }); + fail = true; + assert.throws(() => { + ti.add('q', { bio: 'queued?' }); + }, /boom/); + assert.equal((priv.buildQueue as unknown[] | null)?.length, 0, 'the failed add was never queued'); + assert.deepEqual(ti.search('hello').map((h) => h.key), ['k'], 'live view intact during the build'); + b.abort(); + ti.close(); +}); + +test('TextIndex: an overlong custom-tokenizer term is rejected before any mutation (review #27)', async () => { + const dir = await tmpDir(); + try { + const ti = new TextIndex({ postingsPath: path.join(dir, 't.postings'), tokenizer: () => ['你'.repeat(30000)] }); + const priv = ti as unknown as TiPrivates; + // '你'.repeat(30000) is 90000 utf8 bytes > 0xffff — it would have made + // every postings rebuild throw RangeError, permanently poisoning the index. + assert.throws(() => { + ti.add('k', { bio: 'x' }); + }, /longer than 65535 utf8 bytes/); + assert.equal(ti.N, 0); + assert.equal(priv.docLen.size, 0); + assert.equal(priv.deltaCount, 0); + // The build path validates at the same boundary (and aborts cleanly). + await assert.rejects(ti.build([{ key: 'k', value: { bio: 'x' } }]), /longer than 65535 utf8 bytes/); + ti.close(); + + // A healthy index over the same postings path builds and searches fine. + const ti2 = new TextIndex({ postingsPath: path.join(dir, 't.postings') }); + ti2.add('k', { bio: 'hello world' }); + await ti2.build([{ key: 'k', value: { bio: 'hello world' } }]); + assert.deepEqual(ti2.search('hello').map((h) => h.key), ['k']); + ti2.close(); + } finally { + await fs.rm(dir, { recursive: true, force: true }); + } +}); + +test('MiniDb: a throwing tokenizer rejects set with zero side effects; the old doc stays searchable (review #24)', async () => { + const dir = await tmpDir(); + try { + let db = await MiniDb.open({ dir, valueCodec: 'json', fsyncPolicy: 'no', autoCompact: false }); + await db.createTextIndex('ft', { fields: ['t'] }); + await db.set('k1', { t: 'hello world' }); + const ti = (db as unknown as { text: Map string[] }> }).text.get('ft')!; + const priv = ti as unknown as TiPrivates & { N: number }; + const origTokenizer = ti.tokenizer; + ti.tokenizer = () => { + throw new Error('boom'); + }; + + // Insert rejected: store, delta, buildQueue, N, docLen all untouched. + await assert.rejects(db.set('k2', { t: 'bad insert' }), /boom/); + assert.equal(db.get('k2'), undefined); + assert.equal(db.size, 1); + // Overwrite rejected: the old document is fully intact. + await assert.rejects(db.set('k1', { t: 'goodbye' }), /boom/); + assert.deepEqual(db.get('k1'), { t: 'hello world' }); + assert.deepEqual(db.search('ft', 'hello').map((h) => h.key), ['k1'], 'old doc still searchable'); + assert.deepEqual(db.search('ft', 'goodbye'), []); + assert.equal(priv.N, 1); + assert.equal(priv.docLen.size, 1); + assert.equal(priv.deltaCount, 2, 'delta holds exactly the old doc’s terms'); + assert.equal(priv.buildQueue, null); + + // Restored tokenizer: writes flow again; reopen agrees. + ti.tokenizer = origTokenizer; + await db.set('k2', { t: 'fine' }); + await db.close(); + db = await MiniDb.open({ dir, valueCodec: 'json', fsyncPolicy: 'no', autoCompact: false }); + assert.deepEqual(db.get('k1'), { t: 'hello world' }); + assert.deepEqual(db.get('k2'), { t: 'fine' }); + assert.deepEqual(db.search('ft', 'hello').map((h) => h.key), ['k1']); + await db.close(); + } finally { + await fs.rm(dir, { recursive: true, force: true }); + } +}); + +test('MiniDb: an overlong tokenizer term rejects set/batch; the postings rebuild stays healthy (review #27)', async () => { + const dir = await tmpDir(); + try { + const db = await MiniDb.open({ dir, valueCodec: 'json', fsyncPolicy: 'no', autoCompact: false }); + await db.createTextIndex('ft', { fields: ['t'] }); + await db.set('good', { t: 'hello world' }); + const ti = (db as unknown as { text: Map string[]; customTokenizer: boolean }> }).text.get('ft')!; + const origTokenizer = ti.tokenizer; + // Simulate a custom tokenizer (the MiniDb definition only ever wires + // default/ngram): the length validation only guards custom output. + ti.customTokenizer = true; + ti.tokenizer = () => ['你'.repeat(30000)]; + + await assert.rejects(db.set('bad', { t: 'x' }), /longer than 65535 utf8 bytes/); + await assert.rejects(db.batch([{ op: 'set', key: 'bad2', value: { t: 'x' } }]), /longer than 65535 utf8 bytes/); + assert.equal(db.get('bad'), undefined, 'the poisonous doc never enters the store'); + assert.equal(db.get('bad2'), undefined); + + // Back to a healthy tokenizer: writes and the postings rebuild both work. + ti.customTokenizer = false; + ti.tokenizer = origTokenizer; + await db.set('good2', { t: 'another doc' }); + await db.compact(); // rotates + rebuilds text postings from the store + assert.deepEqual(db.search('ft', 'hello').map((h) => h.key), ['good']); + assert.deepEqual(db.search('ft', 'another').map((h) => h.key), ['good2']); + await db.close(); + } finally { + await fs.rm(dir, { recursive: true, force: true }); + } +}); From 207c5a9d64d0d34f340d7425bbb3c2e2b2272e6e Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Mon, 3 Aug 2026 13:40:42 +0800 Subject: [PATCH 10/15] feat(minidb): add OpTracker drain primitive and atomic backup, harden tests - introduce the internal OpTracker (close gate + in-flight counter with enter/leave/close/whenIdle and reference-counted pause/resume) and drive every shutdown/drain path from it: WAL background syncs are tracked so close() waits out an in-flight sync before closing the fd, cluster lock-pool closeAll() closes the gates and drains busy callbacks before closing handles, and MiniDb writes pass a write gate - make backup() atomic with a defined linearization point: pause the write gate, drain in-flight writes (every acknowledged write is now included), copy to a sibling temp dir with per-file fsyncs, write the manifest last as the commit marker, and rename into place; failures clean up and leave no partial backup, and concurrent writes are rejected with BACKUP_IN_PROGRESS - reap emptied compound-index groups on remove (the groups map no longer grows monotonically), move the open-time mkdir behind the readOnly check so a read-only open of a missing directory fails with ENOENT instead of creating it, and never run a destructive rebuild for a read-only open failure (explicit or onLockFail fallback) - consolidate every review fault-injection repro into the formal suite behind deterministic barrier helpers (programmable writev/sync/ rename/tokenize hooks) and convert the six timing-based tests to barrier/tick-driven assertions; the .tmp repro scripts are removed The converted timing tests and the full suite pass 50 repeat runs (including under CPU load injection) with zero flakes. --- packages/minidb/README.md | 10 +- packages/minidb/src/cluster/lock-pool.ts | 95 ++-- packages/minidb/src/compound-index.ts | 8 +- packages/minidb/src/index.ts | 493 +++++++++++++------- packages/minidb/src/op-tracker.ts | 130 ++++++ packages/minidb/src/wal.ts | 47 +- packages/minidb/test/cluster/lock.test.ts | 71 +++ packages/minidb/test/compound-index.test.ts | 34 ++ packages/minidb/test/db.test.ts | 111 +++++ packages/minidb/test/degrade.test.ts | 57 +++ packages/minidb/test/e2e/stress.test.ts | 30 +- packages/minidb/test/helpers.ts | 145 ++++++ packages/minidb/test/review-fixes.test.ts | 19 +- packages/minidb/test/stats.test.ts | 32 +- packages/minidb/test/text-index.test.ts | 31 +- packages/minidb/test/wal.test.ts | 89 +++- 16 files changed, 1119 insertions(+), 283 deletions(-) create mode 100644 packages/minidb/src/op-tracker.ts create mode 100644 packages/minidb/test/helpers.ts diff --git a/packages/minidb/README.md b/packages/minidb/README.md index f77f736715a..9872b0c0435 100644 --- a/packages/minidb/README.md +++ b/packages/minidb/README.md @@ -278,9 +278,13 @@ await db.backup('./backup'); const restored = await MiniDb.restore('./backup', './restored', { valueCodec: 'json' }); ``` -`backup()` flushes the WAL, optionally compacts, and copies the snapshot, WAL, -index definitions, and text postings while writers are briefly parked, producing -a consistent directory that `restore()` can reopen. +`backup()` optionally compacts, then fences writes at a linearization point — +writes submitted while the backup runs reject with a `BACKUP_IN_PROGRESS` +error, and every write acknowledged before the fence is included — and copies +the snapshot, WAL, index definitions, and text postings into a sibling temp +directory that is fsync'd and atomically renamed over the destination (the +manifest, written last, is the commit marker). A failed backup leaves no +partial directory behind, and `restore()` can reopen the result. ### Write throughput & SSD endurance diff --git a/packages/minidb/src/cluster/lock-pool.ts b/packages/minidb/src/cluster/lock-pool.ts index 5948878ec8b..2ea55ee46b8 100644 --- a/packages/minidb/src/cluster/lock-pool.ts +++ b/packages/minidb/src/cluster/lock-pool.ts @@ -27,6 +27,7 @@ import fs from 'node:fs/promises'; import path from 'node:path'; import type { MiniDb } from '../index.js'; import { LockError } from '../lockfile.js'; +import { OpTracker } from '../op-tracker.js'; import { FINGERPRINT_FILES } from '../persistent-files.js'; import { ShardHandle } from './shard.js'; import type { ShardOpenOptions } from './shard.js'; @@ -101,6 +102,13 @@ export class ShardLockPool { private readonly openingWriters = new Map>(); private readonly openingReaders = new Map>(); private closed = false; + /** Lifecycle gates behind closeAll() (review #18): every withWriter / + * withReader — acquire AND callback — runs inside one enter/leave, so the + * trackers' drain in closeAll() means "no callback can still touch a + * handle". The per-entry busy counters stay: LRU eviction, retire and the + * reader-reopen drain make their synchronous decisions on them. */ + private readonly writerOps = new OpTracker(); + private readonly readerOps = new OpTracker(); readonly stats = { writerOpens: 0, @@ -125,21 +133,25 @@ export class ShardLockPool { * 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++; + if (!this.writerOps.enter()) throw new Error('ClusterDb is closed'); 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(() => {}); + 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(); } - await this.evictWriters(); + } finally { + this.writerOps.leave(); } } @@ -147,39 +159,48 @@ export class ShardLockPool { * 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(); + if (!this.readerOps.enter()) throw new Error('ClusterDb is closed'); + try { + 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); + 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(); + } } finally { - entry.busy--; - entry.lastUsedAt = Date.now(); - await this.evictReaders(); + this.readerOps.leave(); } } async closeAll(): Promise { if (this.closed) return; this.closed = true; - // Opens already in flight are not in writers/readers yet: wait for every - // one of them to settle FIRST, so their entries land in the maps below and - // their handles get closed too. Without this a late open would outlive - // closeAll — holding its lock and recreating db.wal/lock files after the - // owner had already started tearing the directory down. New opens cannot - // start meanwhile: withWriter/withReader throw on this.closed. + // Close both gates and wait for every in-flight op — including its user + // callback — to settle (review #18). New withWriter/withReader reject at + // enter() from now on, so after the drain no busy counter can be non-zero + // and no callback can still touch a handle. Before this, closeAll was the + // pool's only close path that ignored busy: it closed handles under live + // callbacks, which then died on 'MiniDb is closed'. + await Promise.all([this.writerOps.close(), this.readerOps.close()]); + // Defense in depth: opens in flight are wrapped by the trackers above, + // but a late entry landing here after the drain would outlive closeAll — + // holding its lock and recreating db.wal/lock files after the owner had + // already started tearing the directory down. for (const opening of [...this.openingWriters.values(), ...this.openingReaders.values()]) { await opening.catch(() => {}); } diff --git a/packages/minidb/src/compound-index.ts b/packages/minidb/src/compound-index.ts index 31be2ed4a46..7c69c234228 100644 --- a/packages/minidb/src/compound-index.ts +++ b/packages/minidb/src/compound-index.ts @@ -180,7 +180,13 @@ export class CompoundIndexManager { const prev = entry.byPk.get(pk); if (prev) { const oldList = entry.groups.get(prev.group); - if (oldList) oldList.delete(prev.order, pk); + if (oldList) { + oldList.delete(prev.order, pk); + // Reap the emptied group (same rule as addToEntry's move path): + // without this the groups map grew monotonically with the number of + // distinct group values ever seen (review #25). + if (oldList.length === 0) entry.groups.delete(prev.group); + } entry.byPk.delete(pk); } } diff --git a/packages/minidb/src/index.ts b/packages/minidb/src/index.ts index 8b50721ef65..da6eb5cfcce 100644 --- a/packages/minidb/src/index.ts +++ b/packages/minidb/src/index.ts @@ -16,6 +16,7 @@ import type { WalPoison } from './wal.js'; import { ValueReader } from './value-reader.js'; import { recover, catchUpWal, frameToOps } from './recovery.js'; import { compact, shouldCompact, fsyncDir } from './compaction.js'; +import { OpTracker } from './op-tracker.js'; import { SNAPSHOT_FILE, WAL_FILE, @@ -153,6 +154,9 @@ async function fileSize(file: string): Promise { // the open-time isStaleTmpFile cleanup. let sidecarTmpSeq = 0; +/** Unique suffixes for backup's temp/aside dirs (see copyBackupAtomic). */ +let backupTmpSeq = 0; + /** Write a small metadata file atomically (unique tmp + rename + strict * directory fsync), so a crash cannot leave a torn definition file that * would force openers into error/rebuild — and a successful return means @@ -389,6 +393,21 @@ export class MiniDb { /** The poison object the current recovery chain covers (dedupe key for * kickWalRecovery; each poison event is a fresh object identity). */ private walRecoveryCovers: WalPoison | null = null; + /** Write-op gate + in-flight counter (plan 12's OpTracker): set/del/batch/ + * expire run inside enter/leave, and backup() pauses the gate — the drain + * completion is backup's linearization point (every write acknowledged + * before it is in the backup; writes submitted meanwhile reject with + * BACKUP_IN_PROGRESS). close() does NOT drain it: an op in flight at close + * keeps its stage-7/8 semantics (its frame rejects as the WAL closes and + * the op rolls back). */ + private readonly writeOps = new OpTracker(); + /** Serializes whole backup() runs: two backups to the same destination would + * otherwise swap each other's freshly-renamed result aside and delete it, + * and even to different destinations they would duplicate the compaction + + * copy work. The write-gate pause itself is reference-counted and safe to + * overlap (see op-tracker.ts). Same promise-chain pattern as + * serializeUniqueWrites. */ + private readonly serializeBackups = createSerializer(); /** Set when in-place WAL recovery's truncate fails (persistent I/O error): * from then on every write op throws a WAL_WRITE_DISABLED error * immediately; reads and close() keep working. The value is the truncate @@ -504,10 +523,15 @@ export class MiniDb { throw new RangeError('maxMemoryBytes must be a positive finite number'); } - await fs.mkdir(db.dir, { recursive: true }); + db.readOnly = !!opts.readOnly; + // A read-only open must never create the directory (review #26): probe it + // up front so a missing dir fails with a clear ENOENT here instead of + // being mkdir'd into an empty database the caller believes held data. A + // writer open still creates it. + if (db.readOnly) await fs.readdir(db.dir); + else await fs.mkdir(db.dir, { recursive: true }); db.valueMode = await resolveValueMode(valueMode, db.dir, db.maxMemoryBytes); - db.readOnly = !!opts.readOnly; if (!db.readOnly) { db.lock = new LockFile(path.join(db.dir, 'db.lock')); const got = await db.lock.acquire(); @@ -639,6 +663,11 @@ export class MiniDb { await db.lock.release().catch(() => {}); db.lock = null; } + // Tag failures of a read-only open (requested OR degraded via + // onLockFail:'readonly'): the instance never owned the directory, so + // openOrRebuild must not "rebuild" (delete) anything in it — it rethrows + // instead of touching a live writer's files (lock-review repro). + if (db.readOnly && err && typeof err === 'object') (err as { readOnlyOpen?: boolean }).readOnlyOpen = true; throw err; } return db; @@ -648,6 +677,13 @@ export class MiniDb { * Open a database, and if opening fails due to corruption (not due to a live * lock), delete the directory and open a fresh empty database. Recommended for * a rebuildable cache. A live lock is rethrown. + * + * The destructive rebuild only ever runs for an open that could OWN the + * directory: an error tagged `readOnlyOpen` (opts.readOnly, or a lock that + * degraded via onLockFail:'readonly') is rethrown untouched — rebuilding + * means deleting files, and a read-only bystander must never mutate a live + * writer's directory (lock-review repro: the readonly fallback deleted the + * writer's sidecar, and in the strict-recovery shape the whole directory). */ static async openOrRebuild( opts: OpenOptions, @@ -663,6 +699,7 @@ export class MiniDb { // because of a recoverable system error. const rebuildable = err instanceof SyntaxError || (err as { name?: string }).name === 'CorruptFrameError'; if (!rebuildable) throw err; + if ((err as { readOnlyOpen?: boolean }).readOnlyOpen) throw err; if (hooks.onRebuild) hooks.onRebuild(err); if (err instanceof SyntaxError) { // A corrupted index-definition sidecar holds only derived metadata and @@ -1253,23 +1290,28 @@ export class MiniDb { this.ensureOpen(); this.ensureWritable(); this.checkKey(key); - await this.awaitRotation(); - // Validation before side effects (stage 11): prepare (key/ttl checks, - // encode + canonical, tokenize + custom-tokenizer validation) and the - // unique check run BEFORE ensureMemoryFor can evict anything, so a - // rejected write leaves the database untouched — no eviction, no WAL, no - // memory change (review #6). The whole pipeline runs inside the - // unique-write chain when a unique index exists: check-then-commit stays - // atomic for the chain's whole lifetime, so a WAL-seal retry needs no - // re-check (every violation-creating writer is serialized out). - const run = async (): Promise => { - const op = this.prepareSet(key, value, { ttl, dt }); - if (this.indexes.size && this.indexable(op.canonical)) this.indexes.checkUnique(op.pk, op.canonical); - await this.ensureMemoryFor([op]); - await this.retryOnWalSeal(() => this.commitSetOp(op)); - }; - if (this.hasUniqueIndexes()) await this.serializeUniqueWrites(run); - else await run(); + if (!this.writeOps.enter()) throw this.backupInProgressError(); + try { + await this.awaitRotation(); + // Validation before side effects (stage 11): prepare (key/ttl checks, + // encode + canonical, tokenize + custom-tokenizer validation) and the + // unique check run BEFORE ensureMemoryFor can evict anything, so a + // rejected write leaves the database untouched — no eviction, no WAL, no + // memory change (review #6). The whole pipeline runs inside the + // unique-write chain when a unique index exists: check-then-commit stays + // atomic for the chain's whole lifetime, so a WAL-seal retry needs no + // re-check (every violation-creating writer is serialized out). + const run = async (): Promise => { + const op = this.prepareSet(key, value, { ttl, dt }); + if (this.indexes.size && this.indexable(op.canonical)) this.indexes.checkUnique(op.pk, op.canonical); + await this.ensureMemoryFor([op]); + await this.retryOnWalSeal(() => this.commitSetOp(op)); + }; + if (this.hasUniqueIndexes()) await this.serializeUniqueWrites(run); + else await run(); + } finally { + this.writeOps.leave(); + } } /** The set() commit body: append the frame and apply the prepared op, @@ -1347,80 +1389,90 @@ export class MiniDb { async del(key: string | Buffer): Promise { this.ensureOpen(); this.ensureWritable(); - await this.awaitRotation(); - const existed = this.store.has(toKStr(key)); - if (!existed) return false; - const op = this.prepareDel(key); - await this.ensureMemoryFor([op]); - const commit = async (): Promise => { - const recoveryGate = this.walRecoveryGate(); - if (recoveryGate) await recoveryGate; - const wal = this.wal; - const appended = wal.appendLoc(encodeFrame({ type: TYPE_DEL, key: op.key })); - const group = this.groupFor(wal, appended.batchId); - const applied = this.applyBox; - let prev: StoreRecord | undefined; - let seq: number | undefined; - try { - this.applyOp(op, applied); - prev = applied.prev; - seq = this.store.map.get(op.pk)?.seq; - } catch (err) { - // See set() for this defensive path (applyOp's must-not-throw contract). - void appended.done.catch(() => {}); // this op throws here; swallow the frame's rejection - if (group) { - wal.poisonPending(err); - this.groupNoteKey(group, op.pk, applied.prev); - this.rollbackGroup(group, wal, appended.batchId); + if (!this.writeOps.enter()) throw this.backupInProgressError(); + try { + await this.awaitRotation(); + const existed = this.store.has(toKStr(key)); + if (!existed) return false; + const op = this.prepareDel(key); + await this.ensureMemoryFor([op]); + const commit = async (): Promise => { + const recoveryGate = this.walRecoveryGate(); + if (recoveryGate) await recoveryGate; + const wal = this.wal; + const appended = wal.appendLoc(encodeFrame({ type: TYPE_DEL, key: op.key })); + const group = this.groupFor(wal, appended.batchId); + const applied = this.applyBox; + let prev: StoreRecord | undefined; + let seq: number | undefined; + try { + this.applyOp(op, applied); + prev = applied.prev; + seq = this.store.map.get(op.pk)?.seq; + } catch (err) { + // See set() for this defensive path (applyOp's must-not-throw contract). + void appended.done.catch(() => {}); // this op throws here; swallow the frame's rejection + if (group) { + wal.poisonPending(err); + this.groupNoteKey(group, op.pk, applied.prev); + this.rollbackGroup(group, wal, appended.batchId); + this.kickWalRecovery(wal); + } else { + this.restoreGroupKey(op.pk, applied.prev); + } + throw this.markAmbiguous(err); + } + this.groupNoteKey(group, op.pk, prev); + try { + await appended.done; + } catch (e) { + if (group) this.rollbackGroup(group, wal, appended.batchId); + else this.restoreKey(op.pk, prev, seq); this.kickWalRecovery(wal); - } else { - this.restoreGroupKey(op.pk, applied.prev); + throw this.markAmbiguous(e); } - throw this.markAmbiguous(err); - } - this.groupNoteKey(group, op.pk, prev); - try { - await appended.done; - } catch (e) { - if (group) this.rollbackGroup(group, wal, appended.batchId); - else this.restoreKey(op.pk, prev, seq); - this.kickWalRecovery(wal); - throw this.markAmbiguous(e); - } - this.settleGroup(group, wal, appended.batchId); - this.maybeAutoCompact(); - }; - await this.retryOnWalSeal(commit); - return true; + this.settleGroup(group, wal, appended.batchId); + this.maybeAutoCompact(); + }; + await this.retryOnWalSeal(commit); + return true; + } finally { + this.writeOps.leave(); + } } /** Atomically apply a batch of operations (all-or-nothing). */ async batch(ops: readonly BatchInputOp[]): Promise { this.ensureOpen(); this.ensureWritable(); - await this.awaitRotation(); - if (!ops || ops.length === 0) return; - // Same stage-11 ordering as set(): every fallible validation (per-op - // prepare, then the whole-batch unique check against canonical docs) - // precedes ensureMemoryFor's evictions, so a rejected batch has zero - // side effects; the pipeline holds the unique-write chain end to end, so - // a WAL-seal retry of the commit needs no re-check. - const run = async (): Promise => { - const prepared = ops.map((o) => this.prepareOp(o)); - if (this.indexes.size) { - this.indexes.checkUniqueBatch( - prepared.map((o) => ({ - pk: o.pk, - op: o.type === TYPE_DEL ? ('del' as const) : ('set' as const), - doc: o.canonical, - })), - ); - } - await this.ensureMemoryFor(prepared); - await this.retryOnWalSeal(() => this.commitBatchOps(prepared)); - }; - if (this.hasUniqueIndexes()) await this.serializeUniqueWrites(run); - else await run(); + if (!this.writeOps.enter()) throw this.backupInProgressError(); + try { + await this.awaitRotation(); + if (!ops || ops.length === 0) return; + // Same stage-11 ordering as set(): every fallible validation (per-op + // prepare, then the whole-batch unique check against canonical docs) + // precedes ensureMemoryFor's evictions, so a rejected batch has zero + // side effects; the pipeline holds the unique-write chain end to end, so + // a WAL-seal retry of the commit needs no re-check. + const run = async (): Promise => { + const prepared = ops.map((o) => this.prepareOp(o)); + if (this.indexes.size) { + this.indexes.checkUniqueBatch( + prepared.map((o) => ({ + pk: o.pk, + op: o.type === TYPE_DEL ? ('del' as const) : ('set' as const), + doc: o.canonical, + })), + ); + } + await this.ensureMemoryFor(prepared); + await this.retryOnWalSeal(() => this.commitBatchOps(prepared)); + }; + if (this.hasUniqueIndexes()) await this.serializeUniqueWrites(run); + else await run(); + } finally { + this.writeOps.leave(); + } } /** The batch() commit body: append one BATCH frame and apply every prepared @@ -1721,73 +1773,78 @@ export class MiniDb { async expire(key: string | Buffer, ttlMs: number): Promise { this.ensureOpen(); this.ensureWritable(); - await this.awaitRotation(); - const k = toKStr(key); - const cur = this.store.getRecord(k); - if (cur === undefined) return false; - // Same validation as set(): the TTL is stored as an int64, so it must be a - // finite integer of milliseconds (fractional values are floored). - if (!Number.isFinite(ttlMs)) throw new RangeError('ttl must be a finite number of milliseconds'); - const expireAt = Date.now() + Math.floor(ttlMs); - const curValue = this.store.get(k); - if (curValue === undefined) return false; - 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 commit = async (): Promise => { - const recoveryGate = this.walRecoveryGate(); - if (recoveryGate) await recoveryGate; - const wal = this.wal; - const appended = wal.appendLoc(frame); - const group = this.groupFor(wal, appended.batchId); - // 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); - let seq: number | undefined; - try { - this.store.set(k, curValue, expireAt, cur.dt); - seq = this.store.map.get(k)?.seq; - } catch (err) { - // The in-memory mutation failed: an enqueued frame poisons the WAL - // exactly like a write failure and rolls the group back; a - // never-enqueued one only needs the per-op undo (see set()). - void appended.done.catch(() => {}); // this op throws here; swallow the frame's rejection - if (group) { - wal.poisonPending(err); - this.groupNoteKey(group, k, prev); - this.rollbackGroup(group, wal, appended.batchId); + if (!this.writeOps.enter()) throw this.backupInProgressError(); + try { + await this.awaitRotation(); + const k = toKStr(key); + const cur = this.store.getRecord(k); + if (cur === undefined) return false; + // Same validation as set(): the TTL is stored as an int64, so it must be a + // finite integer of milliseconds (fractional values are floored). + if (!Number.isFinite(ttlMs)) throw new RangeError('ttl must be a finite number of milliseconds'); + const expireAt = Date.now() + Math.floor(ttlMs); + const curValue = this.store.get(k); + if (curValue === undefined) return false; + 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 commit = async (): Promise => { + const recoveryGate = this.walRecoveryGate(); + if (recoveryGate) await recoveryGate; + const wal = this.wal; + const appended = wal.appendLoc(frame); + const group = this.groupFor(wal, appended.batchId); + // 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); + let seq: number | undefined; + try { + this.store.set(k, curValue, expireAt, cur.dt); + seq = this.store.map.get(k)?.seq; + } catch (err) { + // The in-memory mutation failed: an enqueued frame poisons the WAL + // exactly like a write failure and rolls the group back; a + // never-enqueued one only needs the per-op undo (see set()). + void appended.done.catch(() => {}); // this op throws here; swallow the frame's rejection + if (group) { + wal.poisonPending(err); + this.groupNoteKey(group, k, prev); + this.rollbackGroup(group, wal, appended.batchId); + this.kickWalRecovery(wal); + } else { + this.restoreGroupKey(k, prev); + } + throw this.markAmbiguous(err); + } + this.groupNoteKey(group, k, prev); + try { + await appended.done; + } catch (e) { + if (group) this.rollbackGroup(group, wal, appended.batchId); + else this.restoreKey(k, prev, seq); this.kickWalRecovery(wal); - } else { - this.restoreGroupKey(k, prev); + throw this.markAmbiguous(e); } - throw this.markAmbiguous(err); - } - this.groupNoteKey(group, k, prev); - try { - await appended.done; - } catch (e) { - if (group) this.rollbackGroup(group, wal, appended.batchId); - else this.restoreKey(k, prev, seq); - this.kickWalRecovery(wal); - throw this.markAmbiguous(e); - } - this.settleGroup(group, wal, appended.batchId); - 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; + this.settleGroup(group, wal, appended.batchId); + 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; + } finally { + this.writeOps.leave(); + } } ttl(key: string | Buffer): number { @@ -2343,7 +2400,29 @@ export class MiniDb { } } - /** Write a consistent online backup of this database directory. */ + /** The rejection a write op gets while a backup holds the write gate: the + * fence is short (file copies) and retryable, so callers can simply + * re-issue the write afterwards. */ + private backupInProgressError(): Error { + return Object.assign(new Error('MiniDb backup is in progress: writes are fenced until it completes'), { + code: 'BACKUP_IN_PROGRESS', + }); + } + + /** Write a consistent online backup of this database directory. + * + * Semantics (plan 12): backup pauses the write gate — new writes reject + * with BACKUP_IN_PROGRESS — and waits for every in-flight write to settle. + * That drain completion IS the linearization point: every write + * acknowledged before it is included in the backup, every write submitted + * after it is not. The copy itself is an atomic commit: persistent files + * go to a sibling temp dir, every copied file is fsync'd, the manifest is + * written LAST (the commit marker — a manifest on disk implies every file + * it lists is fully copied and durable), then the temp dir is renamed over + * the destination (an existing previous backup is swapped aside first and + * restored if the rename fails). A failure anywhere before the rename + * leaves the destination untouched and the temp dir removed — never a half + * backup. Concurrent backups serialize on serializeBackups. */ async backup(destDir: string, opts: { compact?: boolean } = {}): Promise { this.ensureOpen(); if (!destDir) throw new TypeError('backup: destDir is required'); @@ -2351,32 +2430,100 @@ export class MiniDb { if (opts.compact !== false && !this.readOnly) await this.compact(); if (this.compacting) await this._compactDone; - let releaseRotation!: () => void; - this._rotateLock = new Promise((resolve) => { - releaseRotation = resolve; - }); + // The gate closes SYNCHRONOUSLY here (pause's first statement): a write + // submitted from the same synchronous segment as this backup() call + // already sees the fence. pause is reference-counted, so a second backup + // queued on the serializer keeps the gate closed until the last one + // resumes. + const drain = this.writeOps.pause(); + try { + await this.serializeBackups(async () => { + // Fence + drain. After the drain resolves, no write op is running and + // no new one can start, so the on-disk files are quiescent (this + // instance is the only writer — a read-only instance backing up a + // LIVE writer's dir can only fence itself and stays best-effort, as + // before). + await drain; + // A compaction kicked by the just-drained writes must finish before + // the copy; no new one can start with the gate closed (kicks come from + // write commit bodies only). + if (this.compacting) await this._compactDone; + // Wait out any in-flight WAL recovery inside the fence: a WAL failure + // racing the backup leaves un-acked bytes in db.wal that the recovery + // is about to truncate away, and the copy must land on the recovered + // (possibly truncated) file rather than copying bytes that are about + // to disappear. With the gate closed no new recovery can be kicked, + // and a persistent failure keeps the WAL poisoned, so the flush below + // rejects the backup. + await this.walRecoveryChain; + await this.wal.flush(); + await this.copyBackupAtomic(destDir); + }); + } finally { + this.writeOps.resume(); + } + } + + /** The atomic-copy core of backup(): temp dir → per-file fsync → manifest + * (commit marker) → dir fsync → rename swap → parent fsync. Runs with the + * write gate paused. */ + private async copyBackupAtomic(destDir: string): Promise { + const parent = path.dirname(destDir); + const base = path.basename(destDir); + const tmp = path.join(parent, `.${base}.backup-tmp-${process.pid}-${++backupTmpSeq}`); + const aside = path.join(parent, `.${base}.backup-old-${process.pid}-${++backupTmpSeq}`); + await fs.mkdir(parent, { recursive: true }); + // Sweep orphans from a crashed previous backup of this destination. + for (const name of await fs.readdir(parent)) { + if (name.startsWith(`.${base}.backup-tmp-`) || name.startsWith(`.${base}.backup-old-`)) { + await fs.rm(path.join(parent, name), { recursive: true, force: true }); + } + } + await fs.mkdir(tmp); try { - // Wait out any in-flight WAL recovery before fencing: a WAL failure - // racing the backup leaves un-acked bytes in db.wal that the recovery - // is about to truncate away, and the fence must land on the recovered - // (possibly truncated) file rather than copying bytes that are about - // to disappear. A persistent failure keeps the WAL poisoned and the - // flush below then rejects the backup. (Stage 12 rewrites backup with - // OpTracker; this is the minimal guard.) - await this.walRecoveryChain; - await this.wal.flush(); - await fs.mkdir(destDir, { recursive: true }); const files = await this.persistentFiles(); const copied: string[] = []; - for (const name of files) if (await this.copyIfExists(name, destDir)) copied.push(name); - await fs.writeFile( - path.join(destDir, 'backup.manifest.json'), - JSON.stringify({ version: 1, createdAt: Date.now(), files: copied }, null, 2), - 'utf8', - ); + for (const name of files) if (await this.copyIfExists(name, tmp)) copied.push(name); + // Fsync every copied file BEFORE the manifest: the manifest is the + // commit marker, so a durable manifest must imply durable payloads. + for (const name of copied) { + const h = await fs.open(path.join(tmp, name), 'r'); + try { + await h.sync(); + } finally { + await h.close(); + } + } + const manifest = path.join(tmp, 'backup.manifest.json'); + await fs.writeFile(manifest, JSON.stringify({ version: 1, createdAt: Date.now(), files: copied }, null, 2), 'utf8'); + const mh = await fs.open(manifest, 'r'); + try { + await mh.sync(); + } finally { + await mh.close(); + } + await fsyncDir(tmp, { strict: true, stats: this.stats }); + // Swap into place: move an existing previous backup aside, rename the + // temp dir over the destination, restore the aside on failure. + let asideUsed = false; + try { + try { + await fs.rename(destDir, aside); + asideUsed = true; + } catch (e) { + if ((e as NodeJS.ErrnoException).code !== 'ENOENT') throw e; + } + await fs.rename(tmp, destDir); + } catch (err) { + if (asideUsed) await fs.rename(aside, destDir).catch(() => {}); + throw err; + } + await fs.rm(aside, { recursive: true, force: true }); + await fsyncDir(parent, { strict: true, stats: this.stats }); } finally { - releaseRotation(); - this._rotateLock = null; + // A successful rename already moved the temp dir away (this rm is a + // no-op); a failed copy must not strand it (review #22: no half backup). + await fs.rm(tmp, { recursive: true, force: true }).catch(() => {}); } } diff --git a/packages/minidb/src/op-tracker.ts b/packages/minidb/src/op-tracker.ts new file mode 100644 index 00000000000..0f688d97a9c --- /dev/null +++ b/packages/minidb/src/op-tracker.ts @@ -0,0 +1,130 @@ +// src/op-tracker.ts +// +// OpTracker — the single "close gate + in-flight count" lifecycle primitive +// behind every drain path in MiniDB / cluster (plan 12). It generalizes the +// stage-8 close state machine: stage 8 made cleanup exception-safe, this +// makes the work in flight at shutdown bounded and convergent. +// +// The shape: +// +// enter(): boolean — gate open: count +1 and the caller owns one leave(); +// gate closed: returns false, the caller rejects/skips +// the operation (never blocks). +// leave(): void — releases one enter(); at count 0 every idle waiter +// resolves. +// close(): Promise — TERMINAL: closes the gate for good and resolves once +// the count drains to 0. Concurrent calls share the +// same promise. +// pause()/resume() — a REOPENABLE close: pause() closes the gate and drains +// (the drain completion is a quiescence/linearization +// point — every op acknowledged before it is fenced in, +// every op after it was rejected), resume() reopens. +// pause() is reference-counted: overlapping pausers all +// drain, the gate reopens only when the LAST one +// resumes, and the gate closes synchronously at pause() +// call time (before its returned promise is awaited). +// resume() after close() is a no-op. +// whenIdle(): Promise — resolves the next time the count reaches 0 without +// touching the gate (a point-in-time quiescence wait: +// new ops may still enter while it is pending). +// +// Current consumers: +// - wal.ts: the everysec background sync registers each in-flight sync; +// WAL.close() = stop the timer → bgSync.close() (drain) → flush → final +// sync → close the fd (review #13). +// - cluster/lock-pool.ts: one tracker per handle class; every +// withWriter/withReader wraps acquire + callback in enter/leave, so +// closeAll() = close both gates → both drains → close the handles — a +// callback in flight at closeAll runs to completion instead of dying on a +// 'MiniDb is closed' handle teardown (review #18). +// - index.ts (MiniDb): set/del/batch/expire enter the write tracker; +// backup() = pause() (the fence + drain IS its linearization point) → +// copy → resume() (review #22). +// +// kap-server integration (consumed by plan 13): give every lifecycle-managed +// resource that spawns background work (index sync coordinator, WAL catch-up +// loop, session transcript journal) its own OpTracker. Background task +// bodies wrap themselves in `if (!tracker.enter()) return; try { … } finally +// { tracker.leave(); }` so a shutdown is exactly `await tracker.close()` +// before disposing the resource — no detached promise can touch a disposed +// resource. A maintenance window that must exclude concurrent work (compact, +// resnapshot, generation swap) uses pause()/resume() the way MiniDb.backup +// does: pause's drain completion is the linearization point, work submitted +// meanwhile rejects synchronously at enter() — it never parks, so shutdown +// can never deadlock against it. +// +// Internal to the package — NOT re-exported from the root entry point. + +export class OpTracker { + private count = 0; + private open = true; + private pausers = 0; + private permanentlyClosed = false; + private idleWaiters: (() => void)[] = []; + private closePromise: Promise | null = null; + + /** In-flight op count (diagnostics and tests). */ + get inFlight(): number { + return this.count; + } + + /** False once the gate is closed (pause or close). */ + get gateOpen(): boolean { + return this.open; + } + + /** Try to register one op. True: the caller owns exactly one leave(). + * False: the gate is closed — reject or skip the op, never block. */ + enter(): boolean { + if (!this.open) return false; + this.count++; + return true; + } + + /** Release one enter(). Resolves every idle waiter when the count hits 0. */ + leave(): void { + if (this.count <= 0) throw new Error('OpTracker: leave() without a matching enter()'); + this.count--; + if (this.count === 0) { + const waiters = this.idleWaiters; + this.idleWaiters = []; + for (const resolve of waiters) resolve(); + } + } + + /** Resolve the next time the in-flight count reaches 0 (gate untouched). */ + whenIdle(): Promise { + if (this.count === 0) return Promise.resolve(); + return new Promise((resolve) => this.idleWaiters.push(resolve)); + } + + /** Close the gate and wait for the drain; reopen with resume(). The gate + * closes SYNCHRONOUSLY at call time (the async keyword only defers the + * whenIdle await), and pause is reference-counted: overlapping pausers all + * drain and the gate reopens only when the last one resumes. The drain + * completion is the caller's quiescence point. */ + async pause(): Promise { + this.pausers++; + this.open = false; + await this.whenIdle(); + } + + /** Release one pause(); reopens the gate when the last pauser resumes. + * No-op after close() (close is terminal). */ + resume(): void { + if (this.permanentlyClosed) return; + if (this.pausers > 0) this.pausers--; + if (this.pausers === 0) this.open = true; + } + + /** Permanently close the gate and resolve once drained. Idempotent and + * shared: concurrent close() calls await the same drain. */ + close(): Promise { + if (!this.closePromise) { + this.permanentlyClosed = true; + this.open = false; + this.closePromise = this.whenIdle(); + } + return this.closePromise; + } +} diff --git a/packages/minidb/src/wal.ts b/packages/minidb/src/wal.ts index 31c9a3560a8..f3dbecf0b42 100644 --- a/packages/minidb/src/wal.ts +++ b/packages/minidb/src/wal.ts @@ -25,6 +25,7 @@ import fs from 'node:fs/promises'; import type { FileHandle } from 'node:fs/promises'; +import { OpTracker } from './op-tracker.js'; export type FsyncPolicy = 'always' | 'everysec' | 'no'; @@ -118,6 +119,10 @@ export class WAL { /** Set while a background (everysec) sync is in flight, so a slow fsync * never stacks a second background fsync on top of itself. */ private bgSyncing = false; + /** Tracks every in-flight background sync so close() can drain them before + * the final flush/sync/fd-close (review #13): the timer only FIRES ticks, + * the tracker owns their lifetimes. */ + private readonly bgSync = new OpTracker(); constructor(path: string, opts: WALOptions = {}) { const policy = opts.fsyncPolicy ?? 'everysec'; @@ -136,24 +141,35 @@ export class WAL { this.nextOffset = st.size; if (this.policy === 'everysec') { this.timer = setInterval(() => { - // Skip idle ticks entirely: an everysec WAL with no unsynced writes - // must not fsync (the previous unconditional fsync cost one syscall + - // disk wake-up per second for the database's whole lifetime). - // Sync failures do not reject any write (the page-cache copy is the - // acknowledged one); they are recorded in stats.walFsyncErrors / - // lastWalFsyncError instead of being silently swallowed. - if (this.writeGen === this.syncedGen || this.bgSyncing) return; - this.bgSyncing = true; - this.sync() - .catch(() => {}) - .finally(() => { - this.bgSyncing = false; - }); + void this.backgroundTick(); }, this.syncIntervalMs); this.timer.unref?.(); } } + /** One everysec background-sync tick. Extracted from the timer callback so + * tests can drive it deterministically (the wal/stats suites open with a + * huge syncIntervalMs and call this directly instead of racing the wall + * clock). Returns the tracked sync's settle promise, or null when the tick + * was skipped: the WAL is clean (idle ticks must not fsync — the previous + * unconditional fsync cost one syscall + disk wake-up per second for the + * database's whole lifetime), a sync is already in flight, or close() shut + * the tracker's gate. Sync failures do not reject any write (the page-cache + * copy is the acknowledged one); they are recorded in stats.walFsyncErrors / + * lastWalFsyncError instead of being silently swallowed. */ + private backgroundTick(): Promise | null { + if (this.writeGen === this.syncedGen || this.bgSyncing) return null; + if (!this.bgSync.enter()) return null; + this.bgSyncing = true; + const run = this.sync() + .catch(() => {}) + .finally(() => { + this.bgSyncing = false; + this.bgSync.leave(); + }); + return run; + } + /** Reject new appends from now on; already-queued frames stay flushable. * Idempotent. */ seal(): void { @@ -423,6 +439,11 @@ export class WAL { clearInterval(this.timer); this.timer = null; } + // Drain any background sync already in flight before the final + // flush/sync/fd-close below (review #13): the timer is stopped, so no new + // tick fires, and the closed tracker gate rejects any tick racing in — + // what is already flying settles here instead of fsync'ing a dying fd. + await this.bgSync.close(); // Release the file handle even when the final flush/fsync fails: the error // still propagates to the caller, but a half-closed WAL must not leak its // fd (a compaction rotation recovering from a failed close swaps in a diff --git a/packages/minidb/test/cluster/lock.test.ts b/packages/minidb/test/cluster/lock.test.ts index 56cb0235b20..ffd1147029e 100644 --- a/packages/minidb/test/cluster/lock.test.ts +++ b/packages/minidb/test/cluster/lock.test.ts @@ -9,9 +9,11 @@ import assert from 'node:assert/strict'; import fs from 'node:fs/promises'; import path from 'node:path'; import { ClusterDb } from '../../src/cluster/index.js'; +import { ShardLockPool } from '../../src/cluster/lock-pool.js'; import { shardDirName } from '../../src/cluster/utils.js'; import { tmpDir, rmrf } from '../e2e/helpers/tmp.js'; import { keyOnShard, sleep } from './helpers.js'; +import { deferred } from '../helpers.js'; test('two writers contend on the same shard; loser times out with LockError', async () => { const dir = await tmpDir('minidb-cluster-'); @@ -173,3 +175,72 @@ test('close() waits for an in-flight shard open and releases its lock', async () await rmrf(dir); } }); + +test('closeAll() drains in-flight callbacks before closing handles — no MiniDb-is-closed leak (review #18)', async () => { + const dir = await tmpDir('minidb-cluster-'); + try { + const pool = new ShardLockPool({ + writerOpts: { valueCodec: 'json' }, + readerOpts: { valueCodec: 'json' }, + lockRenewMs: 0, + lockAcquireTimeoutMs: 1_000, + lockHoldMs: 0, + maxWriters: 4, + maxReaders: 4, + readOnly: false, + applyDefs: async () => {}, + }); + const shardDir = path.join(dir, shardDirName(2, 4)); + + // A callback parked mid-flight: closeAll must wait for it instead of + // closing the handle under it (the old 'MiniDb is closed' leak). + const gate = deferred(); + const entered = deferred(); + const op = pool.withWriter(2, shardDir, async (db) => { + entered.resolve(); + await gate.promise; + // The handle must still be alive here: closeAll may not tear it down + // while this callback runs. + await db.set('late', { v: 1 }); + return db.get('late'); + }); + await entered.promise; + + let opSettled = false; + void op.then( + () => (opSettled = true), + () => (opSettled = true), + ); + const closing = pool.closeAll(); + let closeReturned = false; + void closing.then(() => (closeReturned = true)); + // closeAll's only path to resolution goes through the parked callback's + // drain, so no amount of yielding can settle it here. + for (let i = 0; i < 5; i++) await new Promise((r) => setImmediate(r)); + assert.equal(closeReturned, false, 'closeAll waits for the in-flight callback'); + + gate.resolve(); + assert.deepEqual(await op, { v: 1 }, 'the in-flight callback ran to completion on a live handle'); + await closing; + assert.equal(opSettled, true, 'every callback is settled by the time closeAll returns'); + + // New ops reject at the closed gate; the shard lock is released for the + // next pool. + await assert.rejects(pool.withWriter(2, shardDir, (db) => db.get('x')), /ClusterDb is closed/); + const pool2 = new ShardLockPool({ + writerOpts: { valueCodec: 'json' }, + readerOpts: { valueCodec: 'json' }, + lockRenewMs: 0, + lockAcquireTimeoutMs: 300, + lockHoldMs: 0, + maxWriters: 4, + maxReaders: 4, + readOnly: false, + applyDefs: async () => {}, + }); + assert.deepEqual(await pool2.withWriter(2, shardDir, (db) => db.get('late')), { v: 1 }, 'the callback’s write is durable'); + await pool2.closeAll(); + } finally { + await rmrf(dir); + } +}); diff --git a/packages/minidb/test/compound-index.test.ts b/packages/minidb/test/compound-index.test.ts index e9843ad6eb9..5a7f895f88f 100644 --- a/packages/minidb/test/compound-index.test.ts +++ b/packages/minidb/test/compound-index.test.ts @@ -97,6 +97,40 @@ test('delete removes from the compound index', async () => { await fs.rm(dir, { recursive: true, force: true }); }); +test('remove() reaps emptied groups: the groups map stays bounded after high-cardinality churn (review #25)', async () => { + const dir = await tmpDir(); + const db = await MiniDb.open({ dir, valueCodec: 'json' }); + await db.createCompoundIndex('byWsUpdated', { groupBy: 'workspaceId', orderBy: 'updatedAt' }); + const entry = (db.compound as unknown as { indexes: Map; byPk: Map }> }).indexes.get( + 'byWsUpdated', + )!; + try { + // One group per key (max cardinality): the groups map tracks live groups. + const N = 300; + for (let i = 0; i < N; i++) await db.set(`k${i}`, { workspaceId: `W${i}` }, { dt: { updatedAt: i } }); + assert.equal(entry.groups.size, N); + + // Remove half via del (the removeFromEntry path), half by overwriting + // with a doc that no longer belongs to any group (the addToEntry path): + // both must reap the emptied group. + for (let i = 0; i < N; i += 2) await db.del(`k${i}`); + for (let i = 1; i < N; i += 2) await db.set(`k${i}`, { other: 1 }); + assert.equal(entry.groups.size, 0, 'every emptied group is reaped, del and overwrite alike'); + assert.equal(entry.byPk.size, 0); + + // Churn again to prove the map does not grow monotonically across rounds. + for (let round = 0; round < 3; round++) { + for (let i = 0; i < N; i++) await db.set(`k${i}`, { workspaceId: `W${i}` }, { dt: { updatedAt: i } }); + for (let i = 0; i < N; i++) await db.del(`k${i}`); + } + assert.equal(entry.groups.size, 0, 'bounded across add/remove rounds'); + await db.close(); + } finally { + await db.close().catch(() => {}); + await fs.rm(dir, { recursive: true, force: true }); + } +}); + // ---- plan 10: sidecar mutation serialization + staged → persist → publish -- diff --git a/packages/minidb/test/db.test.ts b/packages/minidb/test/db.test.ts index 8d88be7146a..9bdd3c4bb2b 100644 --- a/packages/minidb/test/db.test.ts +++ b/packages/minidb/test/db.test.ts @@ -6,6 +6,7 @@ import os from 'node:os'; import path from 'node:path'; import { MiniDb } from '../src/index.js'; import { encodeFrame, TYPE_SET } from '../src/codec.js'; +import { barrier } from './helpers.js'; const B = (s) => Buffer.from(s); @@ -170,6 +171,116 @@ test('backup + restore preserves data, indexes, and text search', async () => { } }); +test('backup fences writes at a linearization point: every pre-fence ack is in, concurrent writes reject (review #22)', async () => { + const dir = await tmpDir(); + const parent = await tmpDir(); + const backupDir = path.join(parent, 'backup-dest'); // intentionally absent + const restoreDir = await tmpDir(); + try { + const db = await MiniDb.open({ dir, valueCodec: 'string', fsyncPolicy: 'no', autoCompact: false }); + await db.set('seed1', 'a'); + await db.set('seed2', 'b'); + + // Park the WAL writev of the NEXT write so a set is provably in flight + // when the backup starts: the backup's drain must wait for it, and its + // ack then lands INSIDE the backup (it entered before the fence). + const fh = (db as unknown as { wal: { fh: { writev: (...a: unknown[]) => Promise } } }).wal.fh; + const gate = barrier(fh, 'writev', 1); + const inFlight = db.set('inflight', 'c'); + await gate.entered; + + const backingUp = db.backup(backupDir, { compact: false }); + let backupDone = false; + void backingUp.then(() => { + backupDone = true; + }); + + // Writes submitted behind the fence reject with a clear, retryable error. + await assert.rejects(db.set('late', 'd'), (e: unknown) => (e as { code?: string }).code === 'BACKUP_IN_PROGRESS'); + await assert.rejects(db.batch([{ op: 'set', key: 'late2', value: 'd' }]), /backup is in progress/i); + // The backup cannot complete while the fenced-in write is still parked. + for (let i = 0; i < 5; i++) await new Promise((r) => setImmediate(r)); + assert.equal(backupDone, false, 'backup waits for the in-flight write (its linearization point)'); + + gate.release(); + gate.restore(); + await inFlight; + await backingUp; + assert.equal(backupDone, true); + + // After the backup the gate is lifted: writes work again. + await db.set('after', 'e'); + assert.equal(db.get('after'), 'e'); + await db.close(); + + // The manifest is the commit marker and lists the copied files. + const manifest = JSON.parse(await fs.readFile(path.join(backupDir, 'backup.manifest.json'), 'utf8')) as { files: string[] }; + assert.ok(manifest.files.includes('db.wal'), 'manifest lists the WAL'); + + // Restore: every write ack'd before the fence is in; the rejected ones are not. + const restored = await MiniDb.restore(backupDir, restoreDir, { valueCodec: 'string' }); + assert.equal(restored.get('seed1'), 'a'); + assert.equal(restored.get('seed2'), 'b'); + assert.equal(restored.get('inflight'), 'c', 'the write drained before the fence is included'); + assert.equal(restored.get('late'), undefined); + assert.equal(restored.get('late2'), undefined); + assert.equal(restored.get('after'), undefined, 'a post-backup write is not included'); + await restored.close(); + } finally { + await fs.rm(dir, { recursive: true, force: true }); + await fs.rm(parent, { recursive: true, force: true }); + await fs.rm(restoreDir, { recursive: true, force: true }); + } +}); + +test('backup copy failure leaves no partial backup behind and reopens the write gate (review #22)', async () => { + const dir = await tmpDir(); + const parent = await tmpDir(); + const backupDir = path.join(parent, 'backup-dest'); + try { + const db = await MiniDb.open({ dir, valueCodec: 'string', fsyncPolicy: 'no', autoCompact: false }); + await db.set('k', 'v'); + + // Fail the copy phase: every fs.copyFile rejects. + const boom = new Error('injected copy failure'); + const origCopy = fs.copyFile; + (fs as unknown as { copyFile: unknown }).copyFile = () => Promise.reject(boom); + try { + await assert.rejects(db.backup(backupDir, { compact: false }), /injected copy failure/); + } finally { + (fs as unknown as { copyFile: unknown }).copyFile = origCopy; + } + + // No half backup: the destination was never created, and no temp/aside + // dir is stranded next to it. + assert.equal(await fs.stat(backupDir).then(() => true, () => false), false, 'destination untouched'); + const leftovers = (await fs.readdir(parent)).filter((n) => n.includes('.backup-')); + assert.deepEqual(leftovers, [], `stranded temp dirs: ${leftovers.join(', ')}`); + + // The write gate was released: the db keeps working. + await db.set('after', 'w'); + assert.equal(db.get('after'), 'w'); + await db.close(); + } finally { + await fs.rm(dir, { recursive: true, force: true }); + await fs.rm(parent, { recursive: true, force: true }); + } +}); + +test('readOnly open of a non-existent directory fails with ENOENT and creates nothing (review #26)', async () => { + const parent = await tmpDir(); + const dir = path.join(parent, 'missing'); + try { + await assert.rejects( + MiniDb.open({ dir, valueCodec: 'string', readOnly: true }), + (e: unknown) => (e as NodeJS.ErrnoException).code === 'ENOENT', + ); + assert.equal(await fs.stat(dir).then(() => true, () => false), false, 'no directory was created'); + } finally { + await fs.rm(parent, { recursive: true, force: true }); + } +}); + test('valueMode disk stores value pointers and reads from WAL', async () => { const dir = await tmpDir(); try { diff --git a/packages/minidb/test/degrade.test.ts b/packages/minidb/test/degrade.test.ts index a9e056fc724..30c2c2b4f3d 100644 --- a/packages/minidb/test/degrade.test.ts +++ b/packages/minidb/test/degrade.test.ts @@ -62,3 +62,60 @@ test('openOrRebuild does NOT delete a live-locked db', async () => { await fs.rm(dir, { recursive: true, force: true }); } }); + +test('openOrRebuild with onLockFail readonly must not mutate a live writer: corrupt sidecar is rethrown, not "repaired" (lock-review repro)', async () => { + const dir = await tmpDir(); + const writer = await MiniDb.open({ dir, valueCodec: 'string', fsyncPolicy: 'no' }); + await writer.set('kept', 'value'); + // A corrupt secondary-index sidecar: a plain readonly open fails on it with + // a SyntaxError, which openOrRebuild would normally "fix" by dropping the + // sidecar — but the readonly fallback must never delete a live writer's + // files. + await fs.writeFile(path.join(dir, 'db.indexes.json'), '{broken-json'); + try { + await assert.rejects( + MiniDb.openOrRebuild({ dir, valueCodec: 'string', fsyncPolicy: 'no', onLockFail: 'readonly' }), + SyntaxError, + ); + const sidecar = await fs.readFile(path.join(dir, 'db.indexes.json'), 'utf8'); + assert.equal(sidecar, '{broken-json', 'the live writer’s sidecar is untouched'); + assert.equal(writer.get('kept'), 'value', 'the live writer is undisturbed'); + } finally { + await writer.close(); + await fs.rm(dir, { recursive: true, force: true }); + } +}); + +test('openOrRebuild with onLockFail readonly + a garbage WAL (strict) degrades to a live read-only view and never wipes the directory (lock-review repro)', async () => { + const dir = await tmpDir(); + const writer = await MiniDb.open({ dir, valueCodec: 'string', fsyncPolicy: 'no' }); + await writer.set('kept', 'value'); + // A garbage WAL. Strict recovery at HEAD stops at the first bad frame + // instead of throwing (stage 7/9 semantics), so the readonly open succeeds; + // the historical bug this pins is the full-directory wipe openOrRebuild ran + // when such an open DID fail destructively. + await fs.writeFile(path.join(dir, 'db.wal'), Buffer.from('not-a-valid-frame')); + const walBefore = await fs.readFile(path.join(dir, 'db.wal')); + try { + const rebuilt = await MiniDb.openOrRebuild({ + dir, + valueCodec: 'string', + fsyncPolicy: 'no', + recovery: 'strict', + onLockFail: 'readonly', + }); + assert.equal(rebuilt.readOnly, true, 'degraded to read-only behind the live writer'); + assert.equal(rebuilt.get('kept'), undefined, 'the garbage WAL replays nothing'); + await rebuilt.close(); + const walAfter = await fs.readFile(path.join(dir, 'db.wal')); + assert.ok(walBefore.equals(walAfter), 'the live writer’s WAL is byte-identical'); + assert.equal(writer.get('kept'), 'value', 'the live writer is undisturbed'); + // The writer's own lock line is still on disk (no fresh db was opened + // over the directory). + const lockRaw = JSON.parse(await fs.readFile(path.join(dir, 'db.lock'), 'utf8')) as { token?: string }; + assert.equal(typeof lockRaw.token, 'string', 'lock file intact'); + } finally { + await writer.close(); + await fs.rm(dir, { recursive: true, force: true }); + } +}); diff --git a/packages/minidb/test/e2e/stress.test.ts b/packages/minidb/test/e2e/stress.test.ts index 7a77126f2a5..e9c4c05ff56 100644 --- a/packages/minidb/test/e2e/stress.test.ts +++ b/packages/minidb/test/e2e/stress.test.ts @@ -372,30 +372,40 @@ test( const SET = '*3\r\n$3\r\nSET\r\n$4\r\nkey1\r\n$1000\r\n' + 'v'.repeat(1000) + '\r\n'; const PING = 'PING\r\n'; + // Completion is reply-count driven instead of the old fixed 300ms window + // (review #28): the round ends exactly when the 21st reply terminator + // arrives (SET's +OK plus 20 × +PONG), and the assertion is the whole + // byte-exact reply stream, so a dropped, torn or inverted reply fails + // deterministically on any machine speed. + const REPLIES = 21; const round = (): Promise => new Promise((resolve, reject) => { const sock = net.createConnection(port, '127.0.0.1'); let buf = ''; - sock.on('data', (d) => (buf += String(d))); + sock.on('data', (d) => { + buf += String(d); + // Each simple-string reply (+OK / +PONG) ends with exactly one CRLF. + if ((buf.match(/\r\n/g) ?? []).length >= REPLIES) { + sock.end(); + resolve(buf); + } + }); sock.on('error', reject); + sock.on('close', () => resolve(buf)); // server-side teardown: fail on the assertion below sock.write(SET); + // Best-effort packet split so the PINGs arrive in a later 'data' + // event than the SET (the original race shape); the assertion does + // not depend on the split happening. setTimeout(() => sock.write(PING.repeat(20)), 1); - setTimeout(() => { - sock.end(); - resolve(buf); - }, 300); }); - let inverted = 0; + const WANT = '+OK\r\n' + '+PONG\r\n'.repeat(20); const ROUNDS = 12; try { for (let r = 0; r < ROUNDS; r++) { const reply = await round(); - const okIdx = reply.indexOf('+OK'); - const pongIdx = reply.indexOf('+PONG'); - if (pongIdx !== -1 && (okIdx === -1 || pongIdx < okIdx)) inverted++; + expect(reply, `round ${r}: replies out of order or incomplete`).toBe(WANT); } - expect(inverted, `${inverted}/${ROUNDS} connections saw PINGs answered before the earlier SET`).toBe(0); } finally { await close(); await rmrf(dir); diff --git a/packages/minidb/test/helpers.ts b/packages/minidb/test/helpers.ts new file mode 100644 index 00000000000..735f3157de8 --- /dev/null +++ b/packages/minidb/test/helpers.ts @@ -0,0 +1,145 @@ +// test/helpers.ts +// +// Shared test plumbing for the minidb suites: temp dirs plus the deterministic +// fault-injection barrier facility (review #28). A barrier replaces the old +// "patch a method, then gamble on setImmediate/sleep interleavings" pattern +// with a programmable hook: the patched method runs the original until a call +// matches the trigger (1-based call count or predicate), that call signals +// `entered` and parks on a deferred until the test releases it. The test then +// KNOWS the call is in flight instead of inferring it from wall-clock delays. +// +// const gate = barrier(fh, 'writev', 1); // park the first writev +// const op = wal.append(frame); // drives the writev +// await gate.entered; // provably in flight now +// …assertions while the write is stuck… +// gate.release(); // let it land +// await op; +// +// Works on any object method: FileHandle writev/sync, the fs/promises default +// export's rename/copyFile/truncate (all importers share that object), and +// prototype methods such as TextIndex.build (patch the prototype, every +// instance is gated). For tokenizer-style plain functions, wrap them with +// deferred() directly. + +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +export async function tmpDir(prefix = 'minidb-'): Promise { + return fs.mkdtemp(path.join(os.tmpdir(), prefix)); +} + +export async function rmrf(dir: string): Promise { + await fs.rm(dir, { recursive: true, force: true }); +} + +export interface Deferred { + promise: Promise; + resolve: (value: T | PromiseLike) => void; + reject: (err?: unknown) => void; +} + +export function deferred(): Deferred { + let resolve!: (value: T | PromiseLike) => void; + let reject!: (err?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +/** Poll a condition on the macrotask queue until it holds (or the timeout + * makes the failure loud). Use this instead of a fixed sleep when waiting + * for an asynchronous state a barrier cannot signal directly. */ +export async function waitFor(cond: () => boolean, what: string, timeoutMs = 10_000): Promise { + const t0 = Date.now(); + while (!cond()) { + if (Date.now() - t0 > timeoutMs) throw new Error(`timed out waiting for ${what}`); + await new Promise((r) => setImmediate(r)); + } +} + +/** A live barrier over one object method (see the header). */ +export interface MethodBarrier { + /** Total calls the patched method has seen (1-based trigger counts use this). */ + readonly calls: number; + /** Resolves with the call number once the gated call is ENTERED (parked). */ + readonly entered: Promise; + /** Let the gated call(s) proceed to the original method. Idempotent. */ + release(): void; + /** Restore the original method. */ + restore(): void; +} + +type AnyMethod = (this: unknown, ...args: unknown[]) => unknown; + +/** Patch `owner[name]` so the call(s) selected by `when` (a 1-based call + * number or a predicate over the call number) park on a deferred. Matching + * calls signal `entered` and resume in order once release()d; non-matching + * calls pass through synchronously. */ +export function barrier( + owner: Owner, + name: keyof Owner & string, + when: number | ((call: number) => boolean) = 1, +): MethodBarrier { + const original = (owner as unknown as Record)[name]!; + const matches = typeof when === 'number' ? (call: number) => call === when : when; + const enteredD = deferred(); + let released = false; + let release!: () => void; + const gate = new Promise((r) => { + release = () => { + if (!released) { + released = true; + r(); + } + }; + }); + let calls = 0; + (owner as Record)[name] = function (this: unknown, ...args: unknown[]) { + calls++; + const call = calls; + if (matches(call)) { + enteredD.resolve(call); + return gate.then(() => original.apply(this, args)); + } + return original.apply(this, args); + }; + return { + get calls() { + return calls; + }, + entered: enteredD.promise, + release, + restore() { + (owner as Record)[name] = original; + }, + }; +} + +/** One-shot failure injection: the call(s) selected by `when` reject with + * `error`; every other call runs the original. */ +export function failCalls( + owner: Owner, + name: keyof Owner & string, + error: unknown, + when: number | ((call: number) => boolean) = 1, +): { readonly calls: number; restore(): void } { + const original = (owner as unknown as Record)[name]!; + const matches = typeof when === 'number' ? (call: number) => call === when : when; + let calls = 0; + (owner as Record)[name] = function (this: unknown, ...args: unknown[]) { + calls++; + if (matches(calls)) return Promise.reject(error); + return original.apply(this, args); + }; + return { + get calls() { + return calls; + }, + restore() { + (owner as Record)[name] = original; + }, + }; +} diff --git a/packages/minidb/test/review-fixes.test.ts b/packages/minidb/test/review-fixes.test.ts index fa5fd4ace40..73e20602a68 100644 --- a/packages/minidb/test/review-fixes.test.ts +++ b/packages/minidb/test/review-fixes.test.ts @@ -6,6 +6,7 @@ import os from 'node:os'; import path from 'node:path'; import { MiniDb } from '../src/index.js'; import { WAL } from '../src/wal.js'; +import { barrier } from './helpers.js'; async function tmpDir() { return fs.mkdtemp(path.join(os.tmpdir(), 'minidb-fix-')); @@ -18,13 +19,19 @@ test('WAL.flush() drains frames queued behind an in-flight batch', async () => { try { const wal = new WAL(path.join(dir, 'a.wal'), { fsyncPolicy: 'always' }); await wal.open(); - const big = Buffer.alloc(1024 * 1024, 0x61); - const pA = wal.append(big); - await new Promise((r) => setImmediate(r)); - await new Promise((r) => setImmediate(r)); - const pB = wal.append(Buffer.from('B')); - await wal.flush(); + // Deterministic barrier instead of the old two-setImmediate guess (review + // #28): the first writev parks, so batch A is PROVABLY in flight when B + // is appended behind it. + const fh = (wal as unknown as { fh: { writev: (...a: unknown[]) => Promise } }).fh; + const gate = barrier(fh, 'writev', 1); + const pA = wal.append(Buffer.alloc(1024 * 1024, 0x61)); + await gate.entered; // batch A is inside writev now + const pB = wal.append(Buffer.from('B')); // queued behind the in-flight batch + const flushing = wal.flush(); // must await A AND then drain B + gate.release(); + await flushing; const pending = (wal as unknown as { queue: unknown[] }).queue.length; + gate.restore(); await wal.close(); await pA; await pB; diff --git a/packages/minidb/test/stats.test.ts b/packages/minidb/test/stats.test.ts index 2c586b4669e..4c9fab622ba 100644 --- a/packages/minidb/test/stats.test.ts +++ b/packages/minidb/test/stats.test.ts @@ -11,8 +11,6 @@ async function tmpDir() { return fs.mkdtemp(path.join(os.tmpdir(), 'minidb-stats-')); } -const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); - // Frame overhead is 22 (header) + 4 (crc) = 26 bytes, plus key + value. const FRAME_OVERHEAD = 26; @@ -96,20 +94,26 @@ test('batch writes a single frame (lower write amplification than per-key sets)' test("everysec: idle db performs zero background fsyncs; a dirty window syncs once then goes quiet", async () => { const dir = await tmpDir(); - const db = await MiniDb.open({ dir, valueCodec: 'string', fsyncPolicy: 'everysec', syncIntervalMs: 25, autoCompact: false }); + // Huge syncIntervalMs: the real timer never fires during the test. Every + // background-sync tick is driven explicitly through the WAL's extracted + // tick method (review #28), so the fsync counts are exact by construction + // instead of inferred from 25ms-interval sleeps. + const db = await MiniDb.open({ dir, valueCodec: 'string', fsyncPolicy: 'everysec', syncIntervalMs: 3_600_000, autoCompact: false }); + const tick = (): Promise | null => + (db.wal as unknown as { backgroundTick(): Promise | null }).backgroundTick(); try { - await sleep(120); + assert.equal(await tick(), null, 'idle ticks are skipped'); assert.equal(db.stats.walFsyncs, 0, 'idle everysec db must not fsync in the background'); await db.set('k', 'v'); - await sleep(120); - assert.equal(db.stats.walFsyncs, 1, 'the dirty interval fsyncs once'); + await tick(); + assert.equal(db.stats.walFsyncs, 1, 'the dirty window fsyncs once'); - await sleep(120); + assert.equal(await tick(), null, 'clean again: the next tick is skipped'); assert.equal(db.stats.walFsyncs, 1, 'no fsync growth after the writes stopped'); await db.set('k2', 'v2'); - await sleep(120); + await tick(); assert.equal(db.stats.walFsyncs, 2, 'the next dirty window fsyncs once more'); } finally { await db.close(); // final close sync runs after our assertions @@ -119,7 +123,9 @@ test("everysec: idle db performs zero background fsyncs; a dirty window syncs on test('everysec background sync failure is observable in stats but does not change write semantics', async () => { const dir = await tmpDir(); - const db = await MiniDb.open({ dir, valueCodec: 'string', fsyncPolicy: 'everysec', syncIntervalMs: 25, autoCompact: false }); + const db = await MiniDb.open({ dir, valueCodec: 'string', fsyncPolicy: 'everysec', syncIntervalMs: 3_600_000, autoCompact: false }); + const tick = (): Promise | null => + (db.wal as unknown as { backgroundTick(): Promise | null }).backgroundTick(); try { const fh = (db as unknown as { wal: { fh: { sync: () => Promise } } }).wal.fh; const orig = fh.sync.bind(fh); @@ -129,14 +135,14 @@ test('everysec background sync failure is observable in stats but does not chang // The cache-rebuildable write contract is unchanged: sets resolve from the // page cache even while every background fsync fails. await db.set('k', 'v'); - await sleep(150); - assert.ok(db.stats.walFsyncErrors >= 1, `expected walFsyncErrors >= 1, got ${db.stats.walFsyncErrors}`); + await tick(); + assert.equal(db.stats.walFsyncErrors, 1, 'the failing tick records exactly one error'); assert.equal(db.stats.lastWalFsyncError, boom, 'the failure is observable via stats'); assert.equal(db.stats.walFsyncs, 0, 'no successful fsync yet'); fh.sync = orig; - await sleep(150); - assert.ok(db.stats.walFsyncs >= 1, 'background sync recovers once the failure clears'); + await tick(); + assert.equal(db.stats.walFsyncs, 1, 'background sync recovers once the failure clears'); assert.equal(db.stats.lastWalFsyncError, boom, 'sticky error survives later successes'); } finally { await db.close(); diff --git a/packages/minidb/test/text-index.test.ts b/packages/minidb/test/text-index.test.ts index c1e00d01076..0126b1e11ce 100644 --- a/packages/minidb/test/text-index.test.ts +++ b/packages/minidb/test/text-index.test.ts @@ -19,6 +19,7 @@ import { decodeRecord, PostingsFile, } from '../src/text-postings.js'; +import { barrier } from './helpers.js'; async function tmpDir(): Promise { return fs.mkdtemp(path.join(os.tmpdir(), 'minidb-text-')); @@ -339,23 +340,32 @@ test('MiniDb: compaction skips the postings rebuild when the index is clean', as test('MiniDb: writes during a compaction postings rebuild stay consistent', async () => { const dir = await tmpDir(); + // Deterministic barrier instead of the old "3000 docs keep the compaction + // busy long enough" timing inference (review #28): the compaction's + // postings rebuild (TextIndex.build) is parked on a deferred, so the writes + // PROVABLY land while the rebuild is in flight, and each write's promise is + // explicitly settled instead of fire-and-forget. The barrier arms AFTER + // createTextIndex so its call 1 is the compaction rebuild, not the create. + let gate!: ReturnType; try { const db = await MiniDb.open({ dir, valueCodec: 'json', autoCompact: false }); await db.createTextIndex('bio', { fields: ['bio'] }); - // More docs than the snapshot yield cadence, so the compaction is still - // running when the setImmediate writes below land. - for (let i = 0; i < 3000; i++) await db.set(`d${i}`, { bio: `hello doc${i}` }); + for (let i = 0; i < 50; i++) await db.set(`d${i}`, { bio: `hello doc${i}` }); + gate = barrier(TextIndex.prototype, 'build'); const compactP = db.compact(); - setImmediate(() => { - void db.set('extra', { bio: 'hello extra' }); - void db.set('d0', { bio: 'goodbye replaced' }); - void db.del('d1'); - }); + await gate.entered; // the compaction is provably inside the postings rebuild + const writes = Promise.all([ + db.set('extra', { bio: 'hello extra' }), + db.set('d0', { bio: 'goodbye replaced' }), + db.del('d1'), + ]); + gate.release(); + await writes; await compactP; assert.equal(db.stats.compactions, 1); - assert.equal(db.search('bio', 'hello', { limit: 10_000 }).length, 2999); + assert.equal(db.search('bio', 'hello', { limit: 10_000 }).length, 49); assert.deepEqual(db.search('bio', 'extra').map((h) => h.key), ['extra']); assert.deepEqual(db.search('bio', 'goodbye').map((h) => h.key), ['d0']); assert.deepEqual(db.search('bio', 'doc1').map((h) => h.key), []); @@ -363,10 +373,11 @@ test('MiniDb: writes during a compaction postings rebuild stay consistent', asyn // The mid-compaction writes are durable and consistent across a reopen. const db2 = await MiniDb.open({ dir, valueCodec: 'json' }); - assert.equal(db2.search('bio', 'hello', { limit: 10_000 }).length, 2999); + assert.equal(db2.search('bio', 'hello', { limit: 10_000 }).length, 49); assert.deepEqual(db2.search('bio', 'extra').map((h) => h.key), ['extra']); await db2.close(); } finally { + gate?.restore(); await fs.rm(dir, { recursive: true, force: true }); } }); diff --git a/packages/minidb/test/wal.test.ts b/packages/minidb/test/wal.test.ts index d0ae2d0c37f..8f2bebf8059 100644 --- a/packages/minidb/test/wal.test.ts +++ b/packages/minidb/test/wal.test.ts @@ -6,9 +6,16 @@ import os from 'node:os'; import path from 'node:path'; import { WAL } from '../src/wal.js'; import { encodeFrame, FrameParser, CorruptFrameError, TYPE_SET, TYPE_DEL } from '../src/codec.js'; +import { barrier } from './helpers.js'; const B = (s) => Buffer.from(s); -const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +/** Drive one everysec background-sync tick deterministically (review #28): + * tests open the WAL with a huge syncIntervalMs so the real timer never + * fires, then call the extracted tick directly instead of racing sleeps + * against a 25ms interval. Returns the tick's settle promise (null when the + * tick was skipped — clean WAL / stacked / closed gate). */ +const tick = (wal) => wal.backgroundTick(); function freshStats() { return { @@ -165,23 +172,27 @@ test("everysec: idle WAL performs zero background fsyncs; only dirty intervals s const { dir, file } = await tmpWalPath(); try { const stats = freshStats(); - const wal = new WAL(file, { fsyncPolicy: 'everysec', syncIntervalMs: 25, stats }); + // Huge interval: the real timer never fires during the test — every tick + // below is driven explicitly (see `tick`), so the fsync counts are exact + // by construction, not by sleeping past a 25ms interval (review #28). + const wal = new WAL(file, { fsyncPolicy: 'everysec', syncIntervalMs: 3_600_000, stats }); await wal.open(); - // ~5 intervals with no writes: not a single fsync. - await sleep(120); + // Idle ticks are skipped without an fsync. + assert.equal(await tick(wal), null, 'an idle tick is skipped'); + assert.equal(await tick(wal), null); assert.equal(stats.walFsyncs, 0, 'idle everysec WAL must not fsync'); // A write dirties the WAL: exactly one background fsync, then quiet again. await wal.append(encodeFrame({ type: TYPE_SET, key: B('k'), value: B('v') })); - await sleep(120); - assert.equal(stats.walFsyncs, 1, 'one background fsync per dirty interval'); - await sleep(120); + await tick(wal); + assert.equal(stats.walFsyncs, 1, 'one background fsync per dirty window'); + assert.equal(await tick(wal), null, 'clean again: the next tick is skipped'); assert.equal(stats.walFsyncs, 1, 'fsync count does not grow once synced'); // Another write: one more fsync, no burst. await wal.append(encodeFrame({ type: TYPE_SET, key: B('k2'), value: B('v2') })); - await sleep(120); + await tick(wal); assert.equal(stats.walFsyncs, 2); // close() keeps its unconditional final sync even though the WAL is clean. @@ -210,10 +221,12 @@ test('background sync failure is recorded but neither rejects writes nor clears const { dir, file } = await tmpWalPath(); try { const stats = freshStats(); - const wal = new WAL(file, { fsyncPolicy: 'everysec', syncIntervalMs: 25, stats }); + // Explicitly driven ticks again (see `tick`): the failure and the retry + // land exactly where the test puts them, no sleep windows (review #28). + const wal = new WAL(file, { fsyncPolicy: 'everysec', syncIntervalMs: 3_600_000, stats }); await wal.open(); - const fh = (wal as unknown as { fh: { sync: () => Promise } }).fh; + const fh = wal.fh; const orig = fh.sync.bind(fh); const boom = new Error('injected fsync failure'); fh.sync = () => Promise.reject(boom); @@ -221,29 +234,71 @@ test('background sync failure is recorded but neither rejects writes nor clears // Writes are acknowledged from the page cache: the failing background // fsync never rejects them. await wal.append(encodeFrame({ type: TYPE_SET, key: B('k'), value: B('v') })); - await sleep(120); - assert.ok(stats.walFsyncErrors >= 1, `expected fsync errors, got ${stats.walFsyncErrors}`); + await tick(wal); + assert.equal(stats.walFsyncErrors, 1, 'the failing tick records exactly one error'); assert.equal(stats.lastWalFsyncError, boom, 'sticky error is observable'); assert.equal(stats.walFsyncs, 0, 'no successful fsync meanwhile'); // A failed sync must not clear the dirty mark: once the failure goes away // the next tick retries and the WAL converges to synced. fh.sync = orig; - await sleep(120); - assert.ok(stats.walFsyncs >= 1, 'sync retried after the failure'); + await tick(wal); + assert.equal(stats.walFsyncs, 1, 'sync retried after the failure'); assert.equal(stats.lastWalFsyncError, boom, 'sticky error is not cleared by a later success'); assert.equal(wal.poison, null, 'a background sync failure must never poison the WAL'); // Clean again: no more background fsyncs. - const n = stats.walFsyncs; - await sleep(120); - assert.equal(stats.walFsyncs, n); + assert.equal(await tick(wal), null, 'synced: the next tick is skipped'); + assert.equal(stats.walFsyncs, 1); await wal.close(); } finally { await fs.rm(dir, { recursive: true, force: true }); } }); +test('close() waits for an in-flight background sync before closing the fd (review #13)', async () => { + const { dir, file } = await tmpWalPath(); + try { + const stats = freshStats(); + const wal = new WAL(file, { fsyncPolicy: 'everysec', syncIntervalMs: 3_600_000, stats }); + await wal.open(); + await wal.append(encodeFrame({ type: TYPE_SET, key: B('k'), value: B('v') })); + + // Park the background sync inside fh.sync, then start close(): it must + // drain the tracker instead of closing the fd under the flying sync. + const fh = wal.fh; + const gate = barrier(fh, 'sync', 1); + const events: string[] = []; + const origClose = fh.close.bind(fh); + fh.close = async () => { + events.push('fd-close'); + await origClose(); + }; + + const bg = tick(wal); + void bg!.then(() => events.push('bg-sync-settled')); + await gate.entered; // the background sync is provably in flight + + const closing = wal.close(); + let closed = false; + void closing.then(() => { + closed = true; + }); + // close()'s only path to resolution goes through the parked tracker + // drain, so no amount of yielding can settle it here. + for (let i = 0; i < 5; i++) await new Promise((r) => setImmediate(r)); + assert.equal(closed, false, 'close() must wait for the in-flight background sync'); + + gate.release(); + await bg; + await closing; + assert.deepEqual(events, ['bg-sync-settled', 'fd-close'], 'the fd closes only after the background sync settled'); + assert.equal(stats.walFsyncs, 2, 'the background sync plus close() final sync'); + } finally { + await fs.rm(dir, { recursive: true, force: true }); + } +}); + test('queue depth and group-commit counters track the append buffer', async () => { const { dir, file } = await tmpWalPath(); try { From 0c9f69f34a8dc73a6aabeba4d7e4baf558bee7bb Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Mon, 3 Aug 2026 16:55:54 +0800 Subject: [PATCH 11/15] feat(minidb): persist derived indexes as atomic generations, open from WAL delta - checkpoint the store, dt/secondary/compound indexes, and text dictionary/postings/docs into immutable generations under generations/g-NNNNNN published atomically (tmp build, per-file checksums and fsyncs, dir rename, CURRENT swap, strict dir fsyncs); the manifest records the format version, WAL/snapshot checkpoint anchors, per-index definition hashes, and codec/value-mode compatibility - open now loads the published generation and replays only the WAL delta after its checkpoint: no full value decode, corpus tokenization, or postings rewrite on a normal reopen (warm opens are 3.5-13.8x faster at 100k/1M records); a definition change rebuilds only the affected index, and corrupt generation files fall back to the previous generation or the legacy full recovery without ever touching the authoritative snapshot/WAL - build generations transactionally with compaction (rotation plus derived state publish as one unit, replacing the synchronous rebuildTextPostings tail), capture concurrent writes through a sealed op queue with byte/op caps, hard-link clean postings and the snapshot into the new generation, and repoint every live text base into the CURRENT generation after publish - cluster/read-only refresh watches CURRENT and the WAL watermark: pure generation publishes keep readers on incremental catch-up while rotations reopen onto the new generation; writers building the next generation never disturb readers of the current one - legacy databases open through the old path unchanged and gain their first generation in the background; OpenOptions.indexGenerations: false fully restores the pre-generation behavior --- AGENTS.md | 2 +- packages/minidb/src/cluster/lock-pool.ts | 25 +- packages/minidb/src/compaction.ts | 11 +- packages/minidb/src/compound-index.ts | 58 + packages/minidb/src/dt-index.ts | 38 +- packages/minidb/src/gen-codec.ts | 871 +++++++++++++ packages/minidb/src/generation-files.ts | 190 +++ packages/minidb/src/generation.ts | 260 ++++ packages/minidb/src/index-manager.ts | 75 +- packages/minidb/src/index.ts | 1156 +++++++++++++++-- packages/minidb/src/persistent-files.ts | 77 -- packages/minidb/src/recovery.ts | 47 +- packages/minidb/src/skiplist.ts | 53 + packages/minidb/src/store.ts | 29 +- packages/minidb/src/text-index.ts | 151 ++- packages/minidb/src/text-postings.ts | 45 +- packages/minidb/src/wal.ts | 9 + packages/minidb/test/compaction-fault.test.ts | 10 +- .../minidb/test/e2e/recovery-matrix.test.ts | 8 +- packages/minidb/test/generation.test.ts | 841 ++++++++++++ packages/minidb/test/recovery.test.ts | 24 +- packages/minidb/test/stats.test.ts | 14 +- packages/minidb/test/text-index.test.ts | 170 ++- 23 files changed, 3860 insertions(+), 304 deletions(-) create mode 100644 packages/minidb/src/gen-codec.ts create mode 100644 packages/minidb/src/generation-files.ts create mode 100644 packages/minidb/src/generation.ts delete mode 100644 packages/minidb/src/persistent-files.ts create mode 100644 packages/minidb/test/generation.test.ts diff --git a/AGENTS.md b/AGENTS.md index 37160a4a5d4..5596f198574 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -30,7 +30,7 @@ This is a TypeScript monorepo built for agent-assisted development. Keep the roo - `packages/klient`: the client SDK — a contract-driven facade over agent-core-v2 with aggregated `global.*` / `session(id).*` / `agent(id).*` methods, zod validation on every call, and klient-level typed event forwarding. Transport is chosen once at creation via subpath entry (`@moonshot-ai/klient/ipc|memory`); both return the same `Klient`. The package also hosts the e2e suites: the legacy `/api/v1` live suites (`test/e2e/legacy/`) and the docker e2e runner (`pnpm --filter @moonshot-ai/klient docker:e2e`). See `packages/klient/AGENTS.md`. - `packages/server-e2e`: live e2e tests and scenarios against a running server (`KIMI_SERVER_URL`, default `http://127.0.0.1:58627`). See `packages/server-e2e/AGENTS.md`. - `packages/tree-sitter-bash`: a pure-TypeScript bash parser (no runtime deps, no wasm) that produces a syntax tree with tree-sitter-bash 0.25.0 named-node type names and UTF-16 code-unit offsets. `parse(source, { timeoutMs, maxNodes })` runs under a deterministic budget (default 50 ms / 50k nodes, plus per-chain recursion depth caps) and returns a discriminated `ParseResult` (`{ ok, rootNode, hasError }` or `{ ok: false, reason: 'aborted' }`) — callers must treat aborted/hasError trees as "cannot analyze" and degrade. Parser only, no safety judgments; consumers (e.g. Bash tool permission matching) live elsewhere. Known deviations from the reference are tracked in the package README's "Known differences" section, pinned by differential fixtures tested against the real `tree-sitter-bash` wasm (dev-only). -- `packages/minidb`: the embedded JSON document store (`MiniDb`) behind kap-server's search index — snapshot + WAL persistence with an exclusive write lock (losers open read-only and catch up from the WAL), plus a larger-than-RAM full-text layer: `src/text-index.ts` is the inverted index (in-RAM dictionary + delta, on-disk postings in `src/text-postings.ts`, rebuilt from the Store on open and on compaction) with an injectable `tokenizer`/`queryTokenizer`; the default tokenizer keeps ASCII words and CJK uni/bigrams, while `src/trigram.ts` provides the hashed 2/3-gram tokenizer (NFKC + lowercase, code-point windows) that backs substring-exact search. Text-index definitions (including the tokenizer name) persist in `db.textindexes.json`. +- `packages/minidb`: the embedded JSON document store (`MiniDb`) behind kap-server's search index — snapshot + WAL persistence with an exclusive write lock (losers open read-only and catch up from the WAL), plus a larger-than-RAM full-text layer: `src/text-index.ts` is the inverted index (in-RAM dictionary + delta, on-disk postings in `src/text-postings.ts`) with an injectable `tokenizer`/`queryTokenizer`; the default tokenizer keeps ASCII words and CJK uni/bigrams, while `src/trigram.ts` provides the hashed 2/3-gram tokenizer (NFKC + lowercase, code-point windows) that backs substring-exact search. Text-index definitions (including the tokenizer name) persist in `db.textindexes.json`. Derived state (store image, dt/secondary/compound indexes, text dictionary + postings + doc table) is checkpointed as persistent index **generations** (`generations/g-NNNNNN/` + `CURRENT`, format v1 — `src/generation.ts` layout/manifest, `src/gen-codec.ts` binary images): the writer builds them into a `g-N.tmp-*` dir and atomically publishes (rename + CURRENT swap, fsyncs strict), each compaction's rotation and the generation publish form one transaction (replacing the old synchronous `rebuildTextPostings()` tail), and open loads the published generation + WAL delta replay instead of re-decoding every value / re-tokenizing the corpus / rewriting postings (the legacy full recovery remains the automatic fallback for missing/invalid/unknown-version generations; `OpenOptions.indexGenerations: false` forces the legacy path). Load-time integrity is per-file crc32 + definition hashes — a corrupt or definition-mismatched image rebuilds only the affected index from the loaded store. ## Environment Requirements diff --git a/packages/minidb/src/cluster/lock-pool.ts b/packages/minidb/src/cluster/lock-pool.ts index 2ea55ee46b8..6d36db66117 100644 --- a/packages/minidb/src/cluster/lock-pool.ts +++ b/packages/minidb/src/cluster/lock-pool.ts @@ -10,16 +10,18 @@ // 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 (dev:ino:size:mtimeMs of the shard's WAL, snapshot -// and every index-definition sidecar — FINGERPRINT_FILES, derived from the -// authoritative persistent-files module so a newly added file can never be +// cheap file fingerprint (dev:ino:size:mtimeMs of the shard's WAL, snapshot, +// CURRENT, and every index-definition sidecar — FINGERPRINT_FILES, derived +// from the authoritative generation module so a newly added file can never be // missed). 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). +// - anything else (rotation, truncation, a generation switch on CURRENT, +// snapshot/index-def changes, an offset that turns out not to be a frame +// boundary) falls back to a close + full reopen — with a published +// generation that reopen loads the new checkpoint instead of rebuilding +// every index from the full corpus. // 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. @@ -28,7 +30,7 @@ import path from 'node:path'; import type { MiniDb } from '../index.js'; import { LockError } from '../lockfile.js'; import { OpTracker } from '../op-tracker.js'; -import { FINGERPRINT_FILES } from '../persistent-files.js'; +import { FINGERPRINT_FILES } from '../generation.js'; import { ShardHandle } from './shard.js'; import type { ShardOpenOptions } from './shard.js'; import { sleep } from './utils.js'; @@ -325,8 +327,17 @@ export class ShardLockPool { // reopen below). A compound/secondary/text definition change lands on // this reopen path too: it rewrites its sidecar, which the fingerprint // tracks. + // + // CURRENT (parts[2]) is deliberately NOT part of the wal-only verdict: + // a pure generation publish (background build / manual rebuild) changes + // CURRENT without rotating the snapshot, and the reader's WAL-catch-up + // view stays perfectly valid — forcing a reopen would just churn. A + // compaction always rotates the snapshot, and THAT is what sends the + // reader to a reopen (which then loads the new generation instead of + // rebuilding from the corpus). let walOnly = true; for (let i = 1; i < parts.length; i++) { + if (i === 2) continue; // CURRENT — see above if (parts[i] !== cached.fpParts[i]) { walOnly = false; break; diff --git a/packages/minidb/src/compaction.ts b/packages/minidb/src/compaction.ts index 85de3402899..9de2ca5fec4 100644 --- a/packages/minidb/src/compaction.ts +++ b/packages/minidb/src/compaction.ts @@ -33,8 +33,11 @@ // 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 + awaiting onCompacted() (rebuild derived text -// postings — yields to the event loop, writers unaffected). +// 4. bookkeeping — stats + awaiting onCompacted() (stage 5: build and +// publish the new index generation — store image, index +// images, text postings — as one transaction with the +// rotated snapshot/WAL; legacy mode rebuilds derived text +// postings instead). // // Crash safety: recovery is `load db.snapshot` + `replay db.wal`, last-writer // wins. We rename the snapshot BEFORE the WAL. If a crash lands between the two @@ -93,8 +96,8 @@ export interface CompactionTarget { * closed before the rotation renames (see rotateReplace). */ valueReader?: { reopenBoth(): void; close?(): void }; /** Optional hook invoked (and awaited) after the snapshot + WAL rotation - * succeeds, so the owner can rewrite derived on-disk state (e.g. text - * postings) against the new live set. */ + * succeeds, so the owner can publish derived on-disk state (stage 5's + * index generation; legacy mode: text postings) against the new live set. */ onCompacted?: () => void | Promise; } diff --git a/packages/minidb/src/compound-index.ts b/packages/minidb/src/compound-index.ts index 7c69c234228..622946a471a 100644 --- a/packages/minidb/src/compound-index.ts +++ b/packages/minidb/src/compound-index.ts @@ -10,6 +10,7 @@ import { SkipList, cmpNumber, cmpString } from './skiplist.js'; import type { Comparator, RangeOptions } from './skiplist.js'; +import type { CompoundImageIndex } from './gen-codec.js'; export type OrderType = 'number' | 'string'; @@ -234,4 +235,61 @@ export class CompoundIndexManager { }, }; } + + /** Stage-5 generation: export every LIVE compound index's full state for + * image serialization (group entries in ascending (order, pk) order). + * Indexes whose group values include a non-serializable type (objects — + * Map identity semantics cannot survive a round-trip) are SKIPPED and + * named in `skipped`; the loader rebuilds those from the store. */ + exportImage(): { images: CompoundImageIndex[]; skipped: string[] } { + const images: CompoundImageIndex[] = []; + const skipped: string[] = []; + for (const [name, entry] of this.indexes) { + let serializable = true; + const groups: CompoundImageIndex['groups'] = []; + for (const [group, list] of entry.groups) { + const t = typeof group; + if (group !== null && t !== 'number' && t !== 'string' && t !== 'boolean') { + serializable = false; + break; + } + groups.push({ + group: group as number | string | boolean | null, + entries: list.toArray().map((n) => ({ order: n.key as number | string, pk: n.val })), + }); + } + if (!serializable) { + skipped.push(name); + continue; + } + images.push({ + name, + groupBy: entry.def.groupBy, + orderBy: entry.def.orderBy, + orderType: entry.def.orderType, + groups, + }); + } + return { images, skipped }; + } + + /** Replace ONE live compound index's state from a loaded generation image + * (the caller already matched the definition hash). Group lists are + * bulk-built in O(N); the byPk placement map is derived from them. */ + loadImage(image: CompoundImageIndex): void { + const entry = this.indexes.get(image.name); + if (!entry) throw new Error(`no such compound index: ${image.name}`); + const groups = new Map>(); + const byPk = new Map(); + for (const g of image.groups) { + const list = SkipList.bulkLoad( + g.entries.map((e) => ({ key: e.order, val: e.pk })), + { compareKey: entry.cmp, compareVal: cmpString }, + ); + groups.set(g.group, list); + for (const e of g.entries) byPk.set(e.pk, { group: g.group, order: e.order }); + } + entry.groups = groups; + entry.byPk = byPk; + } } diff --git a/packages/minidb/src/dt-index.ts b/packages/minidb/src/dt-index.ts index 28ce03fd44b..23185d6f150 100644 --- a/packages/minidb/src/dt-index.ts +++ b/packages/minidb/src/dt-index.ts @@ -2,11 +2,13 @@ // // Ordered indexes over declared datetime columns (dt1..dtN). Each column is a // SkipList ordered by epoch-ms (numeric) with the record key as tie-break, giving -// O(log N) range / rank on every dt column. Pure in-memory derived state; rebuilt -// from the store on startup. +// O(log N) range / rank on every dt column. Derived state: restored from the +// published index generation on open (stage 5) or rebuilt from the store as +// the fallback. import { SkipList, cmpNumber, cmpString } from './skiplist.js'; import type { RangeEntry } from './skiplist.js'; +import type { DtImageColumn } from './gen-codec.js'; interface DtColumn { list: SkipList; @@ -102,6 +104,38 @@ export class DtIndex { b.commit(); } + /** Stage-5 generation: export the whole index as columns with entries in + * ascending (ms, key) order — the image serialization order. */ + exportImage(): DtImageColumn[] { + return [...this.cols.entries()].map(([name, c]) => ({ + name, + entries: c.list.toArray().map((n: RangeEntry) => ({ ms: n.key, key: n.val })), + })); + } + + /** Replace the whole index from a loaded generation image: the columns are + * bulk-built (O(N)) and the byKey reverse map is derived from them. */ + loadImage(cols: DtImageColumn[]): void { + const nextCols = new Map(); + const nextByKey = new Map>(); + for (const { name, entries } of cols) { + const list = SkipList.bulkLoad( + entries.map((e) => ({ key: e.ms, val: e.key })), + { compareKey: cmpNumber, compareVal: cmpString }, + ); + const byKey = new Map(); + for (const e of entries) { + byKey.set(e.key, e.ms); + const rec = nextByKey.get(e.key) ?? {}; + rec[name] = e.ms; + nextByKey.set(e.key, rec); + } + nextCols.set(name, { list, byKey }); + } + this.cols = nextCols; + this.byKey = nextByKey; + } + /** Stage a rebuild in fresh state and swap it in on commit(), so a rebuild * that fails midway leaves the previous index fully intact. Rebuild keys * are unique (one store record each), so add() is a pure insert — the diff --git a/packages/minidb/src/gen-codec.ts b/packages/minidb/src/gen-codec.ts new file mode 100644 index 00000000000..4c46056ba1b --- /dev/null +++ b/packages/minidb/src/gen-codec.ts @@ -0,0 +1,871 @@ +// src/gen-codec.ts +// +// Binary codecs for the per-file payloads of a persistent index generation +// (stage 5): the store image and every derived-index image. Each file is a +// self-describing envelope — 4-byte magic, u32 format version, payload, and a +// trailing crc32 over everything before it — so a torn or mismatched write is +// detected at load and only ever costs that one file (a corrupt store image +// rejects the whole generation; a corrupt index image rebuilds that index +// from the loaded store; neither touches the authoritative snapshot/WAL). +// +// Writers stream to disk in ~1 MiB writev batches with a running crc (large +// store images never sit wholly in RAM twice); readers load the whole file — +// the payload becomes in-memory state anyway — and verify the crc up front. +// +// Key encoding pitfall: MiniDb's canonical key strings are BINARY strings +// (each char code == one utf8 byte of the key). They are always written as +// raw bytes (u16 length + Buffer.from(k, 'binary')); writing them as utf8 +// would corrupt every non-ASCII key. Genuine text (index names, fields, +// scalar keys, group/order strings, terms) is written as utf8. +// +// Internal to the package — NOT re-exported from the root entry point. + +import fs from 'node:fs/promises'; +import fsSync from 'node:fs'; +import type { FileHandle } from 'node:fs/promises'; +import { crc32 } from './crc32.js'; +import type { ValueRef } from './store.js'; + +/** Thrown by every reader on a malformed/truncated/crc-mismatched generation + * file. Distinct from CorruptFrameError so the loader can route precisely. */ +export class GenerationCorruptError extends Error { + readonly code = 'GENERATION_CORRUPT'; + constructor(message: string) { + super(message); + this.name = 'GenerationCorruptError'; + } +} + +// ---- byte-level writer/reader ---------------------------------------------- + +/** Growable record encoder; one generation file record must fit one chunk. */ +class ByteWriter { + buf: Buffer; + off = 0; + + constructor(sizeHint = 64) { + this.buf = Buffer.allocUnsafe(sizeHint); + } + + ensure(n: number): void { + if (this.off + n <= this.buf.length) return; + let cap = this.buf.length * 2; + while (cap < this.off + n) cap *= 2; + const next = Buffer.allocUnsafe(cap); + this.buf.copy(next, 0, 0, this.off); + this.buf = next; + } + + u8(v: number): void { + this.ensure(1); + this.buf.writeUInt8(v, this.off); + this.off += 1; + } + + u16(v: number): void { + this.ensure(2); + this.buf.writeUInt16LE(v, this.off); + this.off += 2; + } + + u32(v: number): void { + this.ensure(4); + this.buf.writeUInt32LE(v >>> 0, this.off); + this.off += 4; + } + + u64(v: number): void { + this.ensure(8); + this.buf.writeBigUInt64LE(BigInt(v), this.off); + this.off += 8; + } + + i64(v: number): void { + this.ensure(8); + this.buf.writeBigInt64LE(BigInt(v), this.off); + this.off += 8; + } + + f64(v: number): void { + this.ensure(8); + this.buf.writeDoubleLE(v, this.off); + this.off += 8; + } + + bytes(b: Buffer): void { + this.ensure(b.length); + b.copy(this.buf, this.off); + this.off += b.length; + } + + /** A canonical (binary) key string: u16 byte length + raw bytes. */ + key(kstr: string): void { + const b = Buffer.from(kstr, 'binary'); + this.u16(b.length); + this.bytes(b); + } + + /** A genuine text string: u32 utf8 byte length + utf8 bytes. */ + text(s: string): void { + const b = Buffer.from(s, 'utf8'); + this.u32(b.length); + this.bytes(b); + } + + /** A term (bounded by the text index's uint16 limit): u16 + utf8. */ + term(s: string): void { + const b = Buffer.from(s, 'utf8'); + this.u16(b.length); + this.bytes(b); + } +} + +export class ByteReader { + off = 0; + + constructor(readonly buf: Buffer) {} + + private need(n: number): void { + if (this.off + n > this.buf.length) throw new GenerationCorruptError('generation file truncated'); + } + + u8(): number { + this.need(1); + const v = this.buf.readUInt8(this.off); + this.off += 1; + return v; + } + + u16(): number { + this.need(2); + const v = this.buf.readUInt16LE(this.off); + this.off += 2; + return v; + } + + u32(): number { + this.need(4); + const v = this.buf.readUInt32LE(this.off); + this.off += 4; + return v; + } + + u64(): number { + this.need(8); + const v = Number(this.buf.readBigUInt64LE(this.off)); + this.off += 8; + return v; + } + + i64(): number { + this.need(8); + const v = Number(this.buf.readBigInt64LE(this.off)); + this.off += 8; + return v; + } + + f64(): number { + this.need(8); + const v = this.buf.readDoubleLE(this.off); + this.off += 8; + return v; + } + + bytes(n: number): Buffer { + this.need(n); + const b = this.buf.subarray(this.off, this.off + n); + this.off += n; + return b; + } + + key(): string { + const n = this.u16(); + return this.bytes(n).toString('binary'); + } + + text(): string { + const n = this.u32(); + return this.bytes(n).toString('utf8'); + } + + term(): string { + const n = this.u16(); + return this.bytes(n).toString('utf8'); + } + + get done(): boolean { + return this.off === this.buf.length; + } +} + +// ---- file envelope + streaming writer -------------------------------------- + +const FLUSH_BYTES = 1 << 20; + +/** Streaming generation-file writer: envelope header, ~1 MiB writev batches, + * running crc32, fsync on finish. The crc/bytes it reports feed the + * manifest's per-file integrity records. */ +export class GenFileWriter { + private readonly chunks: Buffer[] = []; + private queued = 0; + private crc = 0; + bytes = 0; + private readonly rec = new ByteWriter(256); + + private constructor( + private readonly fh: FileHandle, + magic: string, + version: number, + ) { + const w = new ByteWriter(8); + for (let i = 0; i < 4; i++) w.u8(magic.charCodeAt(i)); + w.u32(version); + const head = w.buf.subarray(0, w.off); + this.chunks.push(Buffer.from(head)); + this.queued = head.length; + } + + static async open(path: string, magic: string, version: number): Promise { + if (magic.length !== 4) throw new RangeError('generation file magic must be 4 chars'); + const fh = await fs.open(path, 'w'); + try { + return new GenFileWriter(fh, magic, version); + } catch (e) { + await fh.close().catch(() => {}); + throw e; + } + } + + /** Encode one record with `encode(w)` and queue it for the next batch. */ + async writeRecord(encode: (w: ByteWriter) => void): Promise { + const w = this.rec; + w.off = 0; + encode(w); + const b = Buffer.from(w.buf.subarray(0, w.off)); + this.chunks.push(b); + this.queued += b.length; + if (this.queued >= FLUSH_BYTES) await this.flush(); + } + + private async flush(): Promise { + if (this.chunks.length === 0) return; + const bufs = this.chunks.splice(0, this.chunks.length); + this.queued = 0; + for (const b of bufs) this.crc = crc32(b, this.crc); + let off = 0; + let cur = bufs; + while (cur.length > 0) { + const toWrite = off > 0 ? [cur[0]!.subarray(off), ...cur.slice(1)] : cur; + const { bytesWritten } = await this.fh.writev(toWrite); + if (bytesWritten === 0) throw new Error('generation file writev made no progress (short write)'); + this.bytes += bytesWritten; + let rem = bytesWritten; + while (rem > 0 && cur.length > 0) { + const left = cur[0]!.length - off; + if (rem < left) { + off += rem; + rem = 0; + } else { + rem -= left; + cur.shift(); + off = 0; + } + } + } + } + + /** Flush, append the crc trailer, fsync, close. Returns the manifest's + * per-file integrity record ({ bytes, crc32 }). */ + async finish(): Promise<{ bytes: number; crc32: number }> { + try { + await this.flush(); + const trailer = Buffer.allocUnsafe(4); + trailer.writeUInt32LE(this.crc >>> 0, 0); + let written = 0; + while (written < 4) { + const { bytesWritten } = await this.fh.write(trailer, written); + if (bytesWritten === 0) throw new Error('generation file write made no progress (short write)'); + written += bytesWritten; + } + this.bytes += 4; + await this.fh.sync(); + return { bytes: this.bytes, crc32: this.crc >>> 0 }; + } finally { + await this.fh.close().catch(() => {}); + } + } + + /** Abort without finishing: close (the caller removes the tmp file). */ + async abort(): Promise { + await this.fh.close().catch(() => {}); + } +} + +/** A verified generation file: the payload reader plus the whole-file + * integrity record (byte length + crc32) for cross-checking against the + * manifest. */ +export interface VerifiedGenerationFile { + payload: ByteReader; + bytes: number; + crc32: number; +} + +/** Read + verify one whole generation file: magic, version, and the crc + * trailer. Returns the payload reader and the computed integrity record. */ +export async function readGenerationFile(path: string, magic: string, version: number): Promise { + let buf: Buffer; + try { + buf = await fs.readFile(path); + } catch (e) { + throw new GenerationCorruptError(`generation file unreadable: ${(e as NodeJS.ErrnoException).code ?? String(e)}`); + } + return parseGenerationBuffer(buf, magic, version); +} + +/** Pure-buffer variant of readGenerationFile (tests, in-memory verification). */ +export function parseGenerationBuffer(buf: Buffer, magic: string, version: number): VerifiedGenerationFile { + if (buf.length < 8 + 4) throw new GenerationCorruptError('generation file too short'); + for (let i = 0; i < 4; i++) { + if (buf.readUInt8(i) !== magic.charCodeAt(i)) throw new GenerationCorruptError(`bad magic (want ${magic})`); + } + if (buf.readUInt32LE(4) !== version) throw new GenerationCorruptError(`unsupported file version (want ${version})`); + const stored = buf.readUInt32LE(buf.length - 4); + const calc = crc32(buf.subarray(0, buf.length - 4)); + if (stored !== calc) throw new GenerationCorruptError('generation file crc mismatch'); + return { payload: new ByteReader(buf.subarray(8, buf.length - 4)), bytes: buf.length, crc32: stored }; +} + +/** Read + verify a generation file AND cross-check it against the manifest's + * integrity record — a file swapped in from another generation (or a + * manifest from another build) is caught here. */ +export async function readGenerationFileChecked( + path: string, + magic: string, + version: number, + expected: { bytes: number; crc32: number }, +): Promise { + const f = await readGenerationFile(path, magic, version); + if (f.bytes !== expected.bytes || f.crc32 !== expected.crc32) { + throw new GenerationCorruptError('generation file does not match manifest record'); + } + return f.payload; +} + +/** Verify a raw file against the manifest's integrity record by streaming + * its bytes (bounded memory — used for the postings files, which have no + * envelope of their own and are otherwise only verified lazily, per-record, + * at search time). One sequential read: cheap insurance that a corrupt base + * is discarded and rebuilt at OPEN, not discovered mid-query. */ +export function verifyFileIntegritySync(path: string, expected: { bytes: number; crc32: number }): void { + const fd = fsSync.openSync(path, 'r'); + try { + const st = fsSync.fstatSync(fd); + if (st.size !== expected.bytes) throw new GenerationCorruptError('file size does not match manifest record'); + let crc = 0; + const buf = Buffer.allocUnsafe(1 << 16); + let pos = 0; + while (pos < st.size) { + const n = fsSync.readSync(fd, buf, 0, Math.min(buf.length, st.size - pos), pos); + if (n === 0) throw new GenerationCorruptError('file shrank during integrity check'); + crc = crc32(buf.subarray(0, n), crc); + pos += n; + } + if ((crc >>> 0) !== expected.crc32) throw new GenerationCorruptError('file crc does not match manifest record'); + } finally { + fsSync.closeSync(fd); + } +} + +// ---- store image ------------------------------------------------------------ + +const STORE_MAGIC = 'MDGS'; +// v4: the dt header fields are u32 (column count, metaLen, column-name +// length) — the v3 u8/u16 widths threw a RangeError mid-build on extreme +// shapes (many/wide dt columns), and every later build would fail the same +// deterministic way. v3: binary dt (no JSON per record). v2: `{"dt":...}` +// meta JSON. Older files are rejected as a whole (unsupported version), +// never silently misread. +export const STORE_VERSION = 4; + +/** One record of the store image: the exact data needed to rebuild a + * StoreRecord. `ref` is a memory ref (inline value bytes) or a disk ref into + * the generation's snapshot / the anchored WAL. `metaBytes` is the record's + * Store-side dt accounting value (byte length of the `{"dt":...}` meta JSON, + * 0 for none) — precomputed at build time so the load never re-stringifies. */ +export interface StoreImageRecord { + kstr: string; + ref: ValueRef; + expireAt: number; + dt: Record | null; + /** Read side: the record's Store-side dt accounting value (0 for none). + * Ignored on write (recomputed from `dt`). */ + metaBytes?: number; +} + +const TAG_INLINE = 0; +const TAG_SNAPSHOT_LOC = 1; +const TAG_WAL_LOC = 2; + +/** The Store's dt accounting value for one record (mirrors Store.metaBytes: + * byte length of the canonical `{"dt":...}` meta JSON, 0 for none). */ +function dtMetaBytes(dt: Record | null): number { + return dt ? Buffer.byteLength(JSON.stringify({ dt }), 'utf8') : 0; +} + +/** Stream the store image to `path`. `records` must yield live records in + * ascending canonical-key order (the load path bulk-builds the ordered index + * from file order). Returns the manifest file info + the record count. */ +export async function writeStoreImage( + path: string, + records: Iterable, +): Promise<{ bytes: number; crc32: number; count: number }> { + const w = await GenFileWriter.open(path, STORE_MAGIC, STORE_VERSION); + let count = 0; + try { + for (const r of records) { + await w.writeRecord((b) => { + b.key(r.kstr); + b.i64(r.expireAt); + const cols = r.dt ? Object.entries(r.dt) : []; + b.u32(dtMetaBytes(r.dt)); + b.u32(cols.length); + for (const [name, ms] of cols) { + const nb = Buffer.from(name, 'utf8'); + b.u32(nb.length); + b.bytes(nb); + b.f64(ms); + } + if (r.ref.kind === 'memory') { + b.u8(TAG_INLINE); + b.u32(r.ref.value.length); + b.bytes(r.ref.value); + } else { + b.u8(r.ref.loc.file === 'snapshot' ? TAG_SNAPSHOT_LOC : TAG_WAL_LOC); + b.u64(r.ref.loc.off); + b.u32(r.ref.loc.len); + } + }); + count++; + } + const info = await w.finish(); + return { ...info, count }; + } catch (e) { + await w.abort(); + throw e; + } +} + +/** Parse a store image payload, yielding records in file (sorted) order. + * Values are COPIED out of the shared file buffer (the store must own its + * memory refs) and metaBytes carries the exact Store accounting hint. */ +export function* readStoreImage(r: ByteReader): Generator { + while (!r.done) { + const kstr = r.key(); + const expireAt = r.i64(); + const metaBytes = r.u32(); + const colCount = r.u32(); + let dt: Record | null = null; + if (colCount > 0) { + dt = {}; + for (let i = 0; i < colCount; i++) { + const nameLen = r.u32(); + const name = r.bytes(nameLen).toString('utf8'); + dt[name] = r.f64(); + } + } + const tag = r.u8(); + let ref: ValueRef; + if (tag === TAG_INLINE) { + const len = r.u32(); + ref = { kind: 'memory', value: Buffer.from(r.bytes(len)) }; + } else if (tag === TAG_SNAPSHOT_LOC || tag === TAG_WAL_LOC) { + const off = r.u64(); + const len = r.u32(); + ref = { kind: 'disk', loc: { file: tag === TAG_SNAPSHOT_LOC ? 'snapshot' : 'wal', off, len } }; + } else { + throw new GenerationCorruptError(`store image: unknown value tag ${tag}`); + } + yield { kstr, ref, expireAt, dt, metaBytes }; + } +} + +// ---- dt index image --------------------------------------------------------- + +const DT_MAGIC = 'MDGD'; +const DT_VERSION = 1; + +export interface DtImageColumn { + name: string; + /** (ms, key) pairs sorted ascending — the skiplist's natural order. */ + entries: { ms: number; key: string }[]; +} + +export async function writeDtIndexImage(path: string, cols: DtImageColumn[]): Promise<{ bytes: number; crc32: number }> { + const w = await GenFileWriter.open(path, DT_MAGIC, DT_VERSION); + try { + await w.writeRecord((b) => b.u32(cols.length)); + for (const c of cols) { + await w.writeRecord((b) => { + b.text(c.name); + b.u64(c.entries.length); + }); + for (const e of c.entries) { + await w.writeRecord((b) => { + b.f64(e.ms); + b.key(e.key); + }); + } + } + return await w.finish(); + } catch (e) { + await w.abort(); + throw e; + } +} + +export function readDtIndexImage(r: ByteReader): DtImageColumn[] { + const colCount = r.u32(); + const cols: DtImageColumn[] = []; + for (let i = 0; i < colCount; i++) { + const name = r.text(); + const n = r.u64(); + const entries: DtImageColumn['entries'] = []; + for (let j = 0; j < n; j++) entries.push({ ms: r.f64(), key: r.key() }); + cols.push({ name, entries }); + } + if (!r.done) throw new GenerationCorruptError('dt index image: trailing bytes'); + return cols; +} + +// ---- secondary index image -------------------------------------------------- + +const SECONDARY_MAGIC = 'MDSI'; +const SECONDARY_VERSION = 1; + +export interface SecondaryImageIndex { + name: string; + field: string; + type: 'equality' | 'range'; + unique: boolean; + sparse: boolean; + /** Equality state: (scalarKey -> pks). Null for range indexes. */ + equality: { scalarKey: string; pks: string[] }[] | null; + /** Range state: (value, pk) pairs sorted ascending. Null for equality. */ + range: { value: number; pk: string }[] | null; +} + +export async function writeSecondaryIndexImage( + path: string, + indexes: SecondaryImageIndex[], +): Promise<{ bytes: number; crc32: number }> { + const w = await GenFileWriter.open(path, SECONDARY_MAGIC, SECONDARY_VERSION); + try { + await w.writeRecord((b) => b.u32(indexes.length)); + for (const idx of indexes) { + await w.writeRecord((b) => { + b.text(idx.name); + b.text(idx.field); + b.u8(idx.type === 'range' ? 2 : 1); + b.u8((idx.unique ? 1 : 0) | (idx.sparse ? 2 : 0)); + }); + if (idx.type === 'equality') { + const values = idx.equality ?? []; + await w.writeRecord((b) => b.u64(values.length)); + for (const v of values) { + await w.writeRecord((b) => { + b.text(v.scalarKey); + b.u64(v.pks.length); + }); + for (const pk of v.pks) await w.writeRecord((b) => b.key(pk)); + } + } else { + const entries = idx.range ?? []; + await w.writeRecord((b) => b.u64(entries.length)); + for (const e of entries) { + await w.writeRecord((b) => { + b.f64(e.value); + b.key(e.pk); + }); + } + } + } + return await w.finish(); + } catch (e) { + await w.abort(); + throw e; + } +} + +export function readSecondaryIndexImage(r: ByteReader): SecondaryImageIndex[] { + const count = r.u32(); + const out: SecondaryImageIndex[] = []; + for (let i = 0; i < count; i++) { + const name = r.text(); + const field = r.text(); + const typeTag = r.u8(); + const flags = r.u8(); + const type = typeTag === 2 ? 'range' : typeTag === 1 ? 'equality' : null; + if (type === null) throw new GenerationCorruptError(`secondary image: unknown index type ${typeTag}`); + let equality: SecondaryImageIndex['equality'] = null; + let range: SecondaryImageIndex['range'] = null; + if (type === 'equality') { + equality = []; + const valueCount = r.u64(); + for (let v = 0; v < valueCount; v++) { + const scalarKey = r.text(); + const pkCount = r.u64(); + const pks: string[] = []; + for (let p = 0; p < pkCount; p++) pks.push(r.key()); + equality.push({ scalarKey, pks }); + } + } else { + range = []; + const n = r.u64(); + for (let j = 0; j < n; j++) range.push({ value: r.f64(), pk: r.key() }); + } + out.push({ name, field, type, unique: (flags & 1) !== 0, sparse: (flags & 2) !== 0, equality, range }); + } + if (!r.done) throw new GenerationCorruptError('secondary index image: trailing bytes'); + return out; +} + +// ---- compound index image --------------------------------------------------- + +const COMPOUND_MAGIC = 'MDCI'; +const COMPOUND_VERSION = 1; + +export type CompoundImageGroupValue = number | string | boolean | null; + +export interface CompoundImageIndex { + name: string; + groupBy: string; + orderBy: string; + orderType: 'number' | 'string'; + groups: { group: CompoundImageGroupValue; entries: { order: number | string; pk: string }[] }[]; +} + +const GTAG_NUMBER = 1; +const GTAG_STRING = 2; +const GTAG_FALSE = 3; +const GTAG_TRUE = 4; +const GTAG_NULL = 5; + +function writeGroupValue(b: ByteWriter, v: CompoundImageGroupValue): void { + if (v === null) { + b.u8(GTAG_NULL); + } else if (typeof v === 'number') { + b.u8(GTAG_NUMBER); + b.f64(v); + } else if (typeof v === 'string') { + b.u8(GTAG_STRING); + b.text(v); + } else if (v === false) { + b.u8(GTAG_FALSE); + } else { + b.u8(GTAG_TRUE); + } +} + +function readGroupValue(r: ByteReader): CompoundImageGroupValue { + const tag = r.u8(); + if (tag === GTAG_NUMBER) return r.f64(); + if (tag === GTAG_STRING) return r.text(); + if (tag === GTAG_FALSE) return false; + if (tag === GTAG_TRUE) return true; + if (tag === GTAG_NULL) return null; + throw new GenerationCorruptError(`compound image: unknown group tag ${tag}`); +} + +export async function writeCompoundIndexImage( + path: string, + indexes: CompoundImageIndex[], +): Promise<{ bytes: number; crc32: number }> { + const w = await GenFileWriter.open(path, COMPOUND_MAGIC, COMPOUND_VERSION); + try { + await w.writeRecord((b) => b.u32(indexes.length)); + for (const idx of indexes) { + await w.writeRecord((b) => { + b.text(idx.name); + b.text(idx.groupBy); + b.text(idx.orderBy); + b.u8(idx.orderType === 'string' ? 2 : 1); + b.u64(idx.groups.length); + }); + for (const g of idx.groups) { + await w.writeRecord((b) => { + writeGroupValue(b, g.group); + b.u64(g.entries.length); + }); + for (const e of g.entries) { + await w.writeRecord((b) => { + if (idx.orderType === 'string') b.text(String(e.order)); + else b.f64(Number(e.order)); + b.key(e.pk); + }); + } + } + } + return await w.finish(); + } catch (e) { + await w.abort(); + throw e; + } +} + +export function readCompoundIndexImage(r: ByteReader): CompoundImageIndex[] { + const count = r.u32(); + const out: CompoundImageIndex[] = []; + for (let i = 0; i < count; i++) { + const name = r.text(); + const groupBy = r.text(); + const orderBy = r.text(); + const ot = r.u8(); + const orderType = ot === 2 ? 'string' : ot === 1 ? 'number' : null; + if (orderType === null) throw new GenerationCorruptError(`compound image: unknown order type ${ot}`); + const groupCount = r.u64(); + const groups: CompoundImageIndex['groups'] = []; + for (let g = 0; g < groupCount; g++) { + const group = readGroupValue(r); + const n = r.u64(); + const entries: { order: number | string; pk: string }[] = []; + for (let j = 0; j < n; j++) { + const order = orderType === 'string' ? r.text() : r.f64(); + entries.push({ order, pk: r.key() }); + } + groups.push({ group, entries }); + } + out.push({ name, groupBy, orderBy, orderType, groups }); + } + if (!r.done) throw new GenerationCorruptError('compound index image: trailing bytes'); + return out; +} + +// ---- text index images ------------------------------------------------------ + +const TEXT_DICT_MAGIC = 'MDTD'; +const TEXT_DICT_VERSION = 1; +const TEXT_DOCS_MAGIC = 'MDTC'; +const TEXT_DOCS_VERSION = 1; + +export interface TextDictImageEntry { + term: string; + off: number; + len: number; + df: number; +} + +export async function writeTextDictionaryImage( + path: string, + entries: Iterable, +): Promise<{ bytes: number; crc32: number }> { + const w = await GenFileWriter.open(path, TEXT_DICT_MAGIC, TEXT_DICT_VERSION); + try { + for (const e of entries) { + await w.writeRecord((b) => { + b.term(e.term); + b.u64(e.off); + b.u32(e.len); + b.u32(e.df); + }); + } + return await w.finish(); + } catch (e) { + await w.abort(); + throw e; + } +} + +export function readTextDictionaryImage(r: ByteReader): TextDictImageEntry[] { + const out: TextDictImageEntry[] = []; + while (!r.done) out.push({ term: r.term(), off: r.u64(), len: r.u32(), df: r.u32() }); + return out; +} + +/** The per-doc table plus the write-buffer state of a text index: docID -> + * key (undefined = hole), docID -> token count, the live-doc count N, the + * tombstoned docIDs, and the in-memory delta (term -> (docID -> freq)). + * Serializing the delta + tombstones makes the loaded index EXACTLY equal to + * the live one at seal time — including writes that landed while the + * generation was being built. */ +export interface TextDocsImage { + keys: (string | undefined)[]; + docLens: (number | undefined)[]; + liveCount: number; + removed: number[]; + delta: { term: string; docs: { docID: number; freq: number }[] }[]; +} + +export async function writeTextDocsImage(path: string, image: TextDocsImage): Promise<{ bytes: number; crc32: number }> { + const w = await GenFileWriter.open(path, TEXT_DOCS_MAGIC, TEXT_DOCS_VERSION); + try { + await w.writeRecord((b) => { + b.u64(image.keys.length); + b.u64(image.liveCount); + b.u64(image.removed.length); + b.u64(image.delta.length); + }); + for (let i = 0; i < image.keys.length; i++) { + const k = image.keys[i]; + await w.writeRecord((b) => { + if (k === undefined) { + b.u8(0); + } else { + b.u8(1); + b.key(k); + } + b.u32(image.docLens[i] ?? 0); + }); + } + for (const id of image.removed) await w.writeRecord((b) => b.u32(id)); + for (const d of image.delta) { + await w.writeRecord((b) => { + b.term(d.term); + b.u64(d.docs.length); + }); + for (const doc of d.docs) { + await w.writeRecord((b) => { + b.u32(doc.docID); + b.u32(doc.freq); + }); + } + } + return await w.finish(); + } catch (e) { + await w.abort(); + throw e; + } +} + +export function readTextDocsImage(r: ByteReader): TextDocsImage { + const docCount = r.u64(); + const liveCount = r.u64(); + const removedCount = r.u64(); + const deltaCount = r.u64(); + const keys: (string | undefined)[] = []; + const docLens: (number | undefined)[] = []; + for (let i = 0; i < docCount; i++) { + const present = r.u8(); + if (present === 1) { + keys.push(r.key()); + docLens.push(r.u32()); + } else if (present === 0) { + keys.push(undefined); + const len = r.u32(); + docLens.push(len === 0 ? undefined : len); + } else { + throw new GenerationCorruptError(`text docs image: unknown presence tag ${present}`); + } + } + const removed: number[] = []; + for (let i = 0; i < removedCount; i++) removed.push(r.u32()); + const delta: TextDocsImage['delta'] = []; + for (let i = 0; i < deltaCount; i++) { + const term = r.term(); + const n = r.u64(); + const docs: { docID: number; freq: number }[] = []; + for (let j = 0; j < n; j++) docs.push({ docID: r.u32(), freq: r.u32() }); + delta.push({ term, docs }); + } + if (!r.done) throw new GenerationCorruptError('text docs image: trailing bytes'); + return { keys, docLens, liveCount, removed, delta }; +} diff --git a/packages/minidb/src/generation-files.ts b/packages/minidb/src/generation-files.ts new file mode 100644 index 00000000000..eb5c1fdcf50 --- /dev/null +++ b/packages/minidb/src/generation-files.ts @@ -0,0 +1,190 @@ +// src/generation-files.ts +// +// The pure file-level protocol of persistent index generations (stage 5): +// reading/writing CURRENT and the manifest, the atomic publish rename +// sequence, and the retention sweeps. No MiniDb state lives here — the +// builder (generation-build in index.ts) and the loader (generation-load in +// index.ts) drive these primitives. +// +// Publish order is the crash-safety contract (see generation.ts' header): +// every generation file is written + fsynced inside g-N.tmp-*, the manifest +// goes LAST, the tmp dir is fsynced, renamed to g-N, generations/ is fsynced, +// and only then is CURRENT atomically replaced (tmp + rename + db-dir fsync). +// A crash at any earlier point leaves CURRENT pointing at the previous +// complete generation; the stranded tmp dir is swept by the next writer. + +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { fsyncDir } from './compaction.js'; +import { + CURRENT_FILE, + GENERATIONS_DIR, + GENERATION_FORMAT_VERSION, + GEN_TMP_PATTERN, + MANIFEST_FILE, + parseGenerationId, +} from './generation.js'; +import type { GenerationManifest } from './generation.js'; +import { GenerationCorruptError } from './gen-codec.js'; +import { renameReplace } from './rename-replace.js'; + +export function generationsDir(dir: string): string { + return path.join(dir, GENERATIONS_DIR); +} + +export function generationDir(dir: string, id: string): string { + return path.join(dir, GENERATIONS_DIR, id); +} + +/** The published generation id (one line), or null when no generation has + * ever been published (legacy database) or CURRENT is unreadable junk — + * both mean "use the legacy full recovery". Never throws on missing/corrupt + * content: CURRENT is a hint, the manifest validation is the gate. */ +export async function readCurrent(dir: string): Promise { + try { + const raw = await fs.readFile(path.join(dir, CURRENT_FILE), 'utf8'); + const id = raw.trim(); + return parseGenerationId(id) === null ? null : id; + } catch { + return null; + } +} + +/** List generation directories (both published and stray tmp dirs), newest + * first by numeric id. */ +export async function listGenerations(dir: string): Promise<{ id: string; n: number; tmp: boolean }[]> { + let names: string[]; + try { + names = await fs.readdir(generationsDir(dir)); + } catch (e) { + if ((e as NodeJS.ErrnoException).code === 'ENOENT') return []; + throw e; + } + const out: { id: string; n: number; tmp: boolean }[] = []; + for (const name of names) { + if (GEN_TMP_PATTERN.test(name)) { + const n = parseGenerationId(name.split('.tmp-')[0]!); + if (n !== null) out.push({ id: name, n, tmp: true }); + continue; + } + const n = parseGenerationId(name); + if (n !== null) out.push({ id: name, n, tmp: false }); + } + out.sort((a, b) => b.n - a.n); + return out; +} + +/** Read + validate a generation's manifest. Throws GenerationCorruptError on + * any structural violation — INCLUDING an unknown (newer) format version: + * the caller must fall back WITHOUT deleting anything, so a newer binary's + * generations survive an older binary's open. */ +export async function readManifest(dir: string, id: string): Promise { + let parsed: GenerationManifest; + try { + const raw = await fs.readFile(path.join(generationDir(dir, id), MANIFEST_FILE), 'utf8'); + parsed = JSON.parse(raw) as GenerationManifest; + } catch (e) { + if ((e as NodeJS.ErrnoException).code === 'ENOENT') { + throw new GenerationCorruptError(`generation ${id}: manifest missing`); + } + throw new GenerationCorruptError(`generation ${id}: manifest unreadable: ${(e as Error).message}`); + } + if (typeof parsed !== 'object' || parsed === null) throw new GenerationCorruptError(`generation ${id}: manifest not an object`); + if (parsed.format !== GENERATION_FORMAT_VERSION) { + throw new GenerationCorruptError(`generation ${id}: unknown format version ${String(parsed.format)}`); + } + if (parsed.id !== id) throw new GenerationCorruptError(`generation ${id}: manifest id mismatch (${String(parsed.id)})`); + const cp = parsed.checkpoint; + if ( + !cp || + typeof cp.walOffset !== 'number' || + typeof cp.walDev !== 'number' || + typeof cp.walIno !== 'number' || + typeof cp.walSize !== 'number' || + cp.walOffset < 0 || + cp.walSize < cp.walOffset + ) { + throw new GenerationCorruptError(`generation ${id}: manifest checkpoint invalid`); + } + if (parsed.valueMode !== 'memory' && parsed.valueMode !== 'disk') { + throw new GenerationCorruptError(`generation ${id}: unknown value mode`); + } + if (typeof parsed.files !== 'object' || parsed.files === null) { + throw new GenerationCorruptError(`generation ${id}: manifest files invalid`); + } + return parsed; +} + +/** Write the manifest LAST inside the tmp generation dir and fsync it (every + * payload file is already durable, so a visible manifest implies a complete + * generation). */ +export async function writeManifest(tmpDir: string, manifest: GenerationManifest): Promise { + const p = path.join(tmpDir, MANIFEST_FILE); + const h = await fs.open(p, 'w'); + try { + await h.writeFile(JSON.stringify(manifest, null, 1), 'utf8'); + await h.sync(); + } finally { + await h.close().catch(() => {}); + } +} + +/** The publish sequence: rename the fully-written tmp dir to its final + * generation name, fsync generations/, then atomically replace CURRENT and + * fsync the db dir. After this resolves, openers can only see either the + * previous CURRENT or a complete generation — never a partial one. */ +export async function publishGeneration( + dir: string, + tmpName: string, + id: string, + opts: { stats?: { dirFsyncUnsupported?: boolean } } = {}, +): Promise { + const gens = generationsDir(dir); + await renameReplace(path.join(gens, tmpName), path.join(gens, id)); + await fsyncDir(gens, { strict: true, stats: opts.stats }); + // CURRENT: unique tmp + fsync + rename + strict db-dir fsync (the same + // discipline as the sidecar atomic writes). + const currentTmp = path.join(dir, `${CURRENT_FILE}.tmp-${process.pid}-${Date.now()}`); + try { + const h = await fs.open(currentTmp, 'w'); + try { + await h.writeFile(`${id}\n`, 'utf8'); + await h.sync(); + } finally { + await h.close().catch(() => {}); + } + await renameReplace(currentTmp, path.join(dir, CURRENT_FILE)); + } finally { + await fs.rm(currentTmp, { force: true }).catch(() => {}); + } + await fsyncDir(dir, { strict: true, stats: opts.stats }); +} + +/** Remove every generation directory that is neither in `keep`, nor the + * CURRENT-published one (re-read HERE, so concurrent cleanups with stale + * keep-sets can never delete the live generation — a cleanup racing a later + * publish must not remove what CURRENT now names), nor a live tmp build. + * Best-effort: failures are counted, never thrown (a stray directory wastes + * disk but can never corrupt the CURRENT-pointed state). */ +export async function cleanupGenerations(dir: string, keep: ReadonlySet): Promise { + const keepAll = new Set(keep); + const current = await readCurrent(dir); + if (current) keepAll.add(current); + let errors = 0; + for (const g of await listGenerations(dir)) { + if (keepAll.has(g.id)) continue; + try { + await fs.rm(path.join(generationsDir(dir), g.id), { recursive: true, force: true }); + } catch { + errors++; + } + } + return errors; +} + +/** Open-time sweep (writer only): remove stranded build tmp dirs. */ +export async function sweepGenerationTemps(dir: string): Promise { + for (const g of await listGenerations(dir)) { + if (g.tmp) await fs.rm(path.join(generationsDir(dir), g.id), { recursive: true, force: true }).catch(() => {}); + } +} diff --git a/packages/minidb/src/generation.ts b/packages/minidb/src/generation.ts new file mode 100644 index 00000000000..cf78ecaa998 --- /dev/null +++ b/packages/minidb/src/generation.ts @@ -0,0 +1,260 @@ +// src/generation.ts +// +// Persistent index generations (stage 5): the on-disk layout, the manifest +// codec, and the authoritative inventory of MiniDb's persistent files. +// +// A generation is a self-contained, atomically published checkpoint of every +// piece of DERIVED state MiniDb otherwise rebuilds from scratch on open: +// the store image (key -> value/refs + expiry + dt), the dt / secondary / +// compound index states, and each text index's dictionary + postings + doc +// table. Loading a published generation and replaying only the WAL delta past +// its checkpoint replaces the open-time full rebuild (decode every value, +// tokenize the whole corpus, rewrite every postings file). +// +// Layout (under the db directory): +// +// db.snapshot authoritative data (unchanged) +// db.wal authoritative log (unchanged) +// db.indexes.json index-definition sidecars (unchanged, +// db.compound-indexes.json still the source of truth for definitions) +// db.textindexes.json +// generations/ +// g-000001/ a published generation (immutable) +// store store image (inline values or disk refs) +// dt.index DtIndex columns +// secondary.index IndexManager indexes +// compound.index CompoundIndexManager indexes +// text-.dictionary term -> { off, len, df } into the postings file +// text-.postings postings records (native PostingsFile format) +// text-.docs docID <-> key table, docLen, delta, tombstones +// snapshot hard link to (or copy of) the db.snapshot the +// generation's disk refs point into +// manifest.json written LAST inside the dir (dir contents are +// only meaningful with a parseable manifest) +// g-000002.tmp-/ an in-flight build (never referenced by CURRENT) +// CURRENT one line: the published generation id +// +// Crash protocol (publish): build into g-N.tmp-*, write + fsync every file, +// fsync the tmp dir, rename to g-N, fsync generations/, then atomically +// replace CURRENT (tmp + rename) and fsync the db dir. CURRENT therefore only +// ever points at a fully fsynced generation; a crash anywhere earlier strands +// a tmp dir the next writer open sweeps. Old generations are removed lazily, +// keeping the current and previous one (the previous shares the WAL anchor +// when no compaction intervened, so it is a real fallback). +// +// Load protocol: read CURRENT -> parse + validate the manifest (unknown +// format version is a structured fallback, never a deletion) -> verify the +// WAL anchor (dev/ino + size >= checkpoint) and, for disk valueMode, the +// snapshot anchor -> load the store image + every index image whose recorded +// definition hash still matches the live sidecar definitions -> replay WAL +// frames past the checkpoint with the normal per-frame op interpretation. +// ANY validation or I/O failure falls back to the legacy full recovery +// (snapshot + whole WAL + full index rebuild); the fallback never mutates or +// deletes authoritative data. See generation-load.ts / generation-build.ts. +// +// This module absorbs stage 9's transitional persistent-files.ts: it owns the +// name/pattern knowledge for every persistent path. It performs no I/O beyond +// tiny manifest/CURRENT reads and writes. + +import { crc32 } from './crc32.js'; + +// ---- authoritative root file names (absorbed from persistent-files.ts) ---- + +/** The primary data pair recovery pairs up: the snapshot, then the WAL. */ +export const SNAPSHOT_FILE = 'db.snapshot'; +export const WAL_FILE = 'db.wal'; + +/** Index-definition sidecars, rewritten atomically (tmp + rename) on every + * definition change. */ +export const SECONDARY_INDEXES_FILE = 'db.indexes.json'; +export const COMPOUND_INDEXES_FILE = 'db.compound-indexes.json'; +export const TEXT_INDEXES_FILE = 'db.textindexes.json'; +export const SIDECAR_FILES = [SECONDARY_INDEXES_FILE, COMPOUND_INDEXES_FILE, TEXT_INDEXES_FILE] as const; + +/** Per-text-index postings files at the ROOT are the legacy (pre-generation) + * location: read-only in-memory-base instances and the generations-disabled + * fallback still use them, and a writer deletes a root postings file once a + * published generation covers that index. */ +export const POSTINGS_PATTERN = /^db\.text-.*\.postings$/; + +/** On-disk postings file name for a text index (root location). */ +export function rootPostingsFile(name: string): string { + return `db.text-${sanitizeIndexName(name)}.postings`; +} + +// ---- generation layout ----------------------------------------------------- + +export const GENERATIONS_DIR = 'generations'; +export const CURRENT_FILE = 'CURRENT'; +export const MANIFEST_FILE = 'manifest.json'; +export const STORE_IMAGE_FILE = 'store'; +export const DT_INDEX_FILE = 'dt.index'; +export const SECONDARY_INDEX_FILE = 'secondary.index'; +export const COMPOUND_INDEX_FILE = 'compound.index'; +export const GEN_SNAPSHOT_FILE = 'snapshot'; + +/** Text-index artifact file names inside a generation directory. */ +export function textDictionaryFile(name: string): string { + return `text-${sanitizeIndexName(name)}.dictionary`; +} +export function textPostingsFile(name: string): string { + return `text-${sanitizeIndexName(name)}.postings`; +} +export function textDocsFile(name: string): string { + return `text-${sanitizeIndexName(name)}.docs`; +} + +/** Index names land in file names; keep the same sanitization the legacy + * root postings path used so both locations agree. */ +export function sanitizeIndexName(name: string): string { + return name.replace(/[^a-zA-Z0-9_.-]/g, '_'); +} + +/** Generation directory id: monotonically increasing, zero-padded so + * lexicographic order equals numeric order. */ +export function generationId(n: number): string { + return `g-${String(n).padStart(6, '0')}`; +} + +const GEN_ID_PATTERN = /^g-(\d+)$/; + +/** Parse a generation directory name into its numeric id, or null. */ +export function parseGenerationId(name: string): number | null { + const m = GEN_ID_PATTERN.exec(name); + return m ? Number(m[1]) : null; +} + +/** In-flight generation build directories (crash-stranded ones are swept by + * the next writer open; a live build's tmp is never matched for another + * process because only the lock holder builds). */ +export const GEN_TMP_PATTERN = /^g-\d+\.tmp-.*$/; + +/** Manifest format version. Version 1 is the first persisted layout; an + * opener that reads a HIGHER version must not touch the files (a newer + * binary wrote them) and falls back to the legacy full recovery. */ +export const GENERATION_FORMAT_VERSION = 1; + +// ---- manifest -------------------------------------------------------------- + +/** Per-file integrity record: byte length and crc32 of the whole file. */ +export interface ManifestFileInfo { + bytes: number; + crc32: number; +} + +/** The WAL position the generation's images cover: frames at/after + * `walOffset` are NOT included and must be replayed on top. The anchor + * (dev/ino) pins the offset to one specific WAL inode; `walSize` is the + * size the WAL had when the checkpoint was sealed (>= walOffset). */ +export interface GenerationCheckpoint { + walOffset: number; + walDev: number; + walIno: number; + walSize: number; + /** Identity of the db.snapshot the generation's disk refs (and its own + * `snapshot` member) point into. `linked` records whether the generation's + * snapshot is a hard link to that inode (false = a full copy; disk-mode + * loads then cannot serve refs through the live db.snapshot path and the + * generation is only usable for memory-mode loads). */ + snapshotBytes: number; + snapshotDev: number; + snapshotIno: number; + snapshotLinked: boolean; +} + +/** Compatibility + definition-hash metadata. A generation is only loadable + * when the codec and value mode match the open options; per-index definition + * hashes decide which individual index images are still valid (a definition + * change invalidates only that index — it is rebuilt from the loaded store). */ +export interface GenerationManifest { + format: number; + id: string; + createdAt: number; + valueCodec: string; + /** The payload mode of the store image: 'memory' inlines every value, + * 'disk' stores { file, off, len } refs (with inline values allowed for + * records that were RAM-resident at build time). */ + valueMode: 'memory' | 'disk'; + checkpoint: GenerationCheckpoint; + /** Definition hash per index name, per index family. */ + indexDefs: { + secondary: Record; + compound: Record; + text: Record; + }; + /** Integrity record for every file the build wrote (everything except the + * manifest itself and the `snapshot` link, which is anchored by dev/ino + * and carries per-frame CRCs of its own). */ + files: Record; + counts: { + records: number; + dtColumns: number; + secondaryIndexes: number; + compoundIndexes: number; + textIndexes: number; + }; +} + +/** Canonical definition hash: crc32 of the JSON of the definition with + * sorted keys, hex-encoded. Both sides (build and load) derive it from the + * SAME persisted definition shape (the sidecar entries), so a sidecar + * round-trip never changes it. */ +export function indexDefHash(def: unknown): string { + return crc32(Buffer.from(stableJson(def), 'utf8')).toString(16).padStart(8, '0'); +} + +function stableJson(v: unknown): string { + if (v === null || typeof v !== 'object') return JSON.stringify(v) ?? 'null'; + if (Array.isArray(v)) return `[${v.map(stableJson).join(',')}]`; + const keys = Object.keys(v as Record).sort(); + return `{${keys.map((k) => `${JSON.stringify(k)}:${stableJson((v as Record)[k])}`).join(',')}}`; +} + +// ---- persistent file inventory (absorbed from persistent-files.ts) --------- + +/** The files the cluster reader fingerprint MUST track: a change to any of + * them means a cached read-only instance can no longer serve without a + * refresh. The WAL comes first — the lock pool's "WAL-only append" fast path + * compares every OTHER entry by position (see shardFingerprint). CURRENT is + * tracked so a generation switch (compaction publish) refreshes readers even + * though the snapshot entry already covers the rotation; both are kept + * because a compaction with generation builds disabled rotates the snapshot + * without touching CURRENT. */ +export const FINGERPRINT_FILES = [WAL_FILE, SNAPSHOT_FILE, CURRENT_FILE, ...SIDECAR_FILES] as const; + +/** Is `name` one of MiniDb's persistent top-level entries (a primary data + * file, an index-definition sidecar, a legacy postings file, CURRENT, or the + * generations directory)? backup/restore filter on this. */ +export function isPersistentFile(name: string): boolean { + return ( + name === SNAPSHOT_FILE || + name === WAL_FILE || + name === CURRENT_FILE || + name === GENERATIONS_DIR || + (SIDECAR_FILES as readonly string[]).includes(name) || + POSTINGS_PATTERN.test(name) + ); +} + +/** Atomic-write temp siblings a crashed previous run may have left behind: + * a compaction's snapshot/WAL temps (fixed names), plus sidecar-definition + * temps from before sidecar writes gained unique suffixes. Current sidecar + * writes use `.tmp--` names, matched by isStaleTmpFile + * instead. Only the sole writer may delete them at open — a read-only + * opener must never touch a live writer's in-flight temps. */ +export const STALE_TMP_FILES: readonly string[] = [SNAPSHOT_FILE, WAL_FILE, ...SIDECAR_FILES].map((f) => `${f}.tmp`); + +/** Is `name` a unique-suffixed atomic-write temp (`.tmp--`) + * of one of the primary/sidecar/CURRENT files, orphaned by a crash between + * the tmp write and the rename? Whitelisted per known file so a LockFile's + * `db.lock.tmp-*` — possibly in flight in ANOTHER process right now — is + * never matched. Same deletion discipline as STALE_TMP_FILES: only the sole + * writer at open. */ +export function isStaleTmpFile(name: string): boolean { + return [SNAPSHOT_FILE, WAL_FILE, CURRENT_FILE, ...SIDECAR_FILES].some((f) => name.startsWith(`${f}.tmp-`)); +} + +/** A failed postings rebuild orphans `db.text-*.postings.tmp` (its atomic + * rename never ran). Postings are pure derived state, so such temps are + * always safe for the writer to delete, for any index name. */ +export const STALE_POSTINGS_TMP_PATTERN = /^db\.text-.*\.postings\.tmp$/; diff --git a/packages/minidb/src/index-manager.ts b/packages/minidb/src/index-manager.ts index 2c950b13bca..3f179b9fbe1 100644 --- a/packages/minidb/src/index-manager.ts +++ b/packages/minidb/src/index-manager.ts @@ -1,11 +1,13 @@ // src/index-manager.ts // -// Secondary indexes over JSON documents. Indexes are pure in-memory derived -// state (the WAL/store is the source of truth); they are rebuilt from the store -// on startup. +// Secondary indexes over JSON documents. Indexes are derived state (the +// WAL/store is the source of truth): on open they are restored from the +// published index generation (stage 5) or, as the fallback, rebuilt from the +// store wholesale; individual indexes are rebuilt on definition changes. import { SkipList, cmpNumber, cmpString } from './skiplist.js'; import type { RangeOptions } from './skiplist.js'; +import type { SecondaryImageIndex } from './gen-codec.js'; export type IndexType = 'equality' | 'range'; @@ -473,4 +475,71 @@ export class IndexManager { }, }; } + + /** Stage-5 generation: export every LIVE index's full state (equality maps + * and range lists in ascending order) for image serialization. */ + exportImage(): SecondaryImageIndex[] { + const out: SecondaryImageIndex[] = []; + for (const idx of this.indexes.values()) { + if (idx.type === 'range') { + out.push({ + name: idx.name, + field: idx.field, + type: 'range', + unique: idx.unique, + sparse: idx.sparse, + equality: null, + range: idx.list.toArray().map((n) => ({ value: n.key, pk: n.val })), + }); + } else { + out.push({ + name: idx.name, + field: idx.field, + type: 'equality', + unique: idx.unique, + sparse: idx.sparse, + equality: [...idx.map.entries()].map(([scalarKey, set]) => ({ scalarKey, pks: [...set] })), + range: null, + }); + } + } + return out; + } + + /** Replace ONE live index's state from a loaded generation image (the + * caller already matched the definition hash). Range lists are bulk-built + * in O(N); byPk reverse maps are derived from the forward state. */ + loadImage(image: SecondaryImageIndex): void { + const idx = this.indexes.get(image.name); + if (!idx) throw new Error(`no such index: ${image.name}`); + if (idx.type !== image.type) throw new Error(`index "${image.name}" image type mismatch`); + if (idx.type === 'range' && image.range) { + idx.list = SkipList.bulkLoad( + image.range.map((e) => ({ key: e.value, val: e.pk })), + { compareKey: cmpNumber, compareVal: cmpString }, + ); + const byPk = new Map(); + for (const e of image.range) { + const arr = byPk.get(e.pk); + if (arr) arr.push(e.value); + else byPk.set(e.pk, [e.value]); + } + idx.byPk = byPk; + } else if (idx.type === 'equality' && image.equality) { + const map = new Map>(); + const byPk = new Map(); + for (const v of image.equality) { + map.set(v.scalarKey, new Set(v.pks)); + for (const pk of v.pks) { + const arr = byPk.get(pk); + if (arr) arr.push(v.scalarKey); + else byPk.set(pk, [v.scalarKey]); + } + } + idx.map = map; + idx.byPk = byPk; + } else { + throw new Error(`index "${image.name}" image payload missing`); + } + } } diff --git a/packages/minidb/src/index.ts b/packages/minidb/src/index.ts index da6eb5cfcce..362fd2075d3 100644 --- a/packages/minidb/src/index.ts +++ b/packages/minidb/src/index.ts @@ -8,9 +8,10 @@ // { key: string(<=128), value: , dt1..dtN: } import fs from 'node:fs/promises'; +import fsSync from 'node:fs'; import path from 'node:path'; import { Store } from './store.js'; -import type { StoreRecord, ValueLoc } from './store.js'; +import type { StoreRecord, ValueLoc, ValueRef } from './store.js'; import { WAL } from './wal.js'; import type { WalPoison } from './wal.js'; import { ValueReader } from './value-reader.js'; @@ -26,9 +27,52 @@ import { SIDECAR_FILES, STALE_TMP_FILES, STALE_POSTINGS_TMP_PATTERN, + GENERATION_FORMAT_VERSION, + STORE_IMAGE_FILE, + DT_INDEX_FILE, + SECONDARY_INDEX_FILE, + COMPOUND_INDEX_FILE, + GEN_SNAPSHOT_FILE, + generationId, + indexDefHash, isStaleTmpFile, isPersistentFile, -} from './persistent-files.js'; + rootPostingsFile, + textDictionaryFile, + textPostingsFile, + textDocsFile, +} from './generation.js'; +import type { GenerationManifest } from './generation.js'; +import { + cleanupGenerations, + generationDir, + generationsDir, + listGenerations, + publishGeneration, + readCurrent, + readManifest, + sweepGenerationTemps, + writeManifest, +} from './generation-files.js'; +import { + GenerationCorruptError, + STORE_VERSION, + readGenerationFileChecked, + readStoreImage, + readDtIndexImage, + readSecondaryIndexImage, + readCompoundIndexImage, + readTextDictionaryImage, + readTextDocsImage, + verifyFileIntegritySync, + writeStoreImage, + writeDtIndexImage, + writeSecondaryIndexImage, + writeCompoundIndexImage, + writeTextDictionaryImage, + writeTextDocsImage, +} from './gen-codec.js'; +import type { StoreImageRecord, TextDocsImage } from './gen-codec.js'; import { IndexManager, UniqueViolationError } from './index-manager.js'; import { DtIndex } from './dt-index.js'; import { TextIndex, type TextIndexOptions, type TextIndexBuild } from './text-index.js'; @@ -37,7 +81,7 @@ import { CompoundIndexManager } from './compound-index.js'; import { getPath, match, project } from './query.js'; import { LockFile, LockError } from './lockfile.js'; import { createSerializer } from './serialize.js'; -import { encodeFrame, encodeBatchOps, scanBatchOpRefs, HEADER_SIZE, TYPE_SET, TYPE_DEL, TYPE_BATCH } from './codec.js'; +import { encodeFrame, encodeBatchOps, scanBatchOpRefs, scanFrameRefsFd, HEADER_SIZE, TYPE_SET, TYPE_DEL, TYPE_BATCH } from './codec.js'; import type { BatchOp as EncodedBatchOp, FrameRef } from './codec.js'; import type { FsyncPolicy } from './wal.js'; import type { RecoveryMode, RecoveryInfo, ValueMode, RecoveredOp } from './recovery.js'; @@ -205,6 +249,13 @@ export interface OpenOptions { maxMemoryBytes?: number; /** What to do when a write would exceed maxMemoryBytes. */ maxMemoryPolicy?: 'reject' | 'evict-lru'; + /** Persistent index generations (stage 5), default true. With generations + * enabled, a writer publishes derived-state checkpoints under + * `generations/` and open loads them instead of rebuilding every index + * from a full store scan; the legacy full recovery remains the automatic + * fallback. Set false to force the pre-generation behavior everywhere + * (full open-time rebuild, root postings rebuilds after compaction). */ + indexGenerations?: boolean; } export interface RestoreOptions extends Omit { @@ -281,6 +332,44 @@ interface WalGroup { rolledBack: boolean; } +/** One write op captured by an in-flight generation build (stage 5). The + * build walks the live store and then drains this queue onto its detached + * states, so the image equals replaying snapshot + WAL up to the sealed + * checkpoint exactly. `storeOnly` marks expire()'s TTL-only rewrite: the + * value is unchanged, so value-derived index states need no re-feed. */ +interface GenBuildOp { + type: number; // TYPE_SET | TYPE_DEL + pk: string; + value: Buffer | null; + expireAt: number; + dtNorm: Record | null; + canonical: unknown; + storeOnly?: boolean; +} + +/** Internal control-flow exception: the generation build noticed a rotation, + * a WAL rollback, a closing instance, or a queue overflow and discarded + * itself. Aborts are expected under churn (never counted as errors). */ +class GenerationBuildAborted extends Error { + constructor(message: string) { + super(message); + this.name = 'GenerationBuildAborted'; + } +} + +/** Soft caps on the generation build's mutation queue: a write storm outrun- + * ning the build's drain aborts the build instead of buffering unboundedly — + * bounded both by op count and by accumulated value bytes (each queued op + * pins its value buffer). */ +const GEN_BUILD_QUEUE_CAP = 1_000_000; +const GEN_BUILD_QUEUE_BYTES_CAP = 512 * 1024 * 1024; + +/** Trigger-(b) thresholds for the open-time background build: a generation + * whose WAL delta replay exceeded either is refreshed in the background so + * the next open is cheap (the per-op replay path is for small deltas only). */ +const GEN_BUILD_WAL_DELTA_OPS = 4096; +const GEN_BUILD_WAL_DELTA_BYTES = 4 * 1024 * 1024; + /** Persisted shape of one entry in `db.textindexes.json`. `tokenizer` is * absent in definitions written before n-gram support existed, which means * 'default'; it is also omitted for new default indexes so their definitions @@ -408,6 +497,27 @@ export class MiniDb { * overlap (see op-tracker.ts). Same promise-chain pattern as * serializeUniqueWrites. */ private readonly serializeBackups = createSerializer(); + /** Persistent index generations enabled (OpenOptions.indexGenerations, + * default true). When false the instance behaves exactly as before stage + * 5: full open-time rebuild + root postings rebuilds after compaction. */ + private indexGenerationsEnabled = true; + /** The in-flight generation build's mutation queue registration (stage 5): + * while non-null, applyOp (and expire()'s TTL rewrite) push every applied + * op here so the build's detached states converge on the exact checkpoint. + * `wal` pins the WAL identity the build measured — a compaction rotation + * replaces it and aborts the build (its disk refs would point into rotated + * files). `aborted` is set by the rollback path (restoreGroupKey), which + * mutates the store outside applyOp and therefore outside the queue. */ + private genBuild: { queue: GenBuildOp[]; bytes: number; wal: WAL; aborted: boolean } | null = null; + /** Single-flight guard for generation builds (open-time background builds + * dedupe onto it; a compaction-triggered build awaits an in-flight one — + * which the rotation just aborted — before starting fresh). close() drains + * it before releasing resources. */ + private genBuildPromise: Promise | null = null; + /** The generation this instance loaded at open or last published (null when + * running on the legacy recovery path). Stable status surface — see + * getIndexGeneration(). */ + private generationInfo: { id: string; createdAt: number; walCheckpoint: number; records: number } | null = null; /** Set when in-place WAL recovery's truncate fails (persistent I/O error): * from then on every write op throws a WAL_WRITE_DISABLED error * immediately; reads and close() keep working. The value is the truncate @@ -485,18 +595,49 @@ export class MiniDb { queryCandidates: 0, queryDecoded: 0, querySortedRows: 0, + // ---- persistent index generations (stage 5) ---- + /** Successful generation builds (published under generations/ + CURRENT). */ + generationBuilds: 0, + /** Builds that failed with a real error (I/O, corruption). */ + generationBuildErrors: 0, + /** Builds discarded because the ground shifted under them (rotation, WAL + * rollback, queue overflow, close) — expected churn, not an error. */ + generationBuildAborts: 0, + generationBuildDurationMs: 0, + /** Opens served by a published generation (no full index rebuild). */ + generationLoads: 0, + /** Opens that fell back to the legacy full recovery (no/invalid + * generation); the sticky reason is in lastGenerationFallback. */ + generationLoadFallbacks: 0, + lastGenerationFallback: null as string | null, + generationLoadDurationMs: 0, + /** Individual index images rejected at generation load (definition hash + * mismatch, corrupt file) and rebuilt from the loaded store. */ + generationIndexRebuilds: 0, }; /** Hook called by compaction after the store snapshot + WAL are rotated, so - * derived on-disk state (text postings) can be rewritten against the new - * live set. Structural part of the CompactionTarget interface; the - * compaction awaits it, so it may be sync or async. */ + * derived on-disk state can be rewritten against the new live set. + * Structural part of the CompactionTarget interface; the compaction awaits + * it, so it may be sync or async. + * + * Stage 5: with index generations enabled this is ONE publish transaction + * — the snapshot rotation and the derived-state checkpoint (store image, + * dt/secondary/compound images, text postings) land as a single new + * generation, and the live text indexes rebase onto it. The synchronous + * rebuildTextPostings() tail no longer runs. With generations disabled the + * legacy behavior is kept exactly. */ onCompacted: () => void | Promise = async (): Promise => { const t0 = performance.now(); - await this.rebuildTextPostings(); - const ms = performance.now() - t0; - this.stats.compactionPostingsDurationMs += ms; - this.stats.textRebuildDurationMs += ms; + if (!this.indexGenerationsEnabled) { + await this.rebuildTextPostings(); + const ms = performance.now() - t0; + this.stats.compactionPostingsDurationMs += ms; + this.stats.textRebuildDurationMs += ms; + return; + } + await this.buildGeneration('compact'); + this.stats.compactionPostingsDurationMs += performance.now() - t0; }; static async open(opts: OpenOptions): Promise> { @@ -519,6 +660,7 @@ export class MiniDb { db.autoCompact = opts.autoCompact ?? true; db.maxMemoryBytes = opts.maxMemoryBytes ?? null; db.maxMemoryPolicy = opts.maxMemoryPolicy ?? 'reject'; + db.indexGenerationsEnabled = opts.indexGenerations ?? true; if (db.maxMemoryBytes !== null && (!Number.isFinite(db.maxMemoryBytes) || db.maxMemoryBytes <= 0)) { throw new RangeError('maxMemoryBytes must be a positive finite number'); } @@ -568,6 +710,9 @@ export class MiniDb { // delete, for any index name. if (STALE_POSTINGS_TMP_PATTERN.test(f)) await fs.rm(path.join(db.dir, f), { force: true }); } + // Stranded generation build tmp dirs (a crashed build never published): + // only the sole writer may delete them. + await sweepGenerationTemps(db.dir); } db.store = new Store({ @@ -586,60 +731,74 @@ export class MiniDb { // WAL's size stays 0, so shouldCompact never fires for it. if (!db.readOnly) await db.wal.open(); - const recT0 = performance.now(); - db.recoveryInfo = await recover({ - dir: db.dir, - store: db.store, - mode: opts.recovery ?? 'resync', - truncate: !db.readOnly, - valueMode: db.valueMode, - // Disk-backed values need the positioned reader attached to the SAME - // inodes recovery scanned; recovery's generation pairing re-verifies - // the attach and retries the whole pass when a rotation landed in - // between (see the pairing note in recovery.ts). 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). - attachValueReader: - db.valueMode === 'disk' - ? (anchors) => { - const reader = new ValueReader(db.dir); - // open() can throw after attaching only one side (e.g. EMFILE - // on the WAL with the snapshot already open). This reader is - // never published to db.valueReader, so the open() failure - // cleanup cannot reach it — close it here or leak the fd. - let ids: ReturnType; - try { - ids = reader.open(); - } catch (e) { - reader.close(); - throw e; - } - const sameInode = (a: { dev: number; ino: number } | null, i: { dev: number; ino: number } | null): boolean => - a === null ? i === null : i !== null && i.dev === a.dev && i.ino === a.ino; - if (sameInode(anchors.snapshot, ids.snapshot) && sameInode(anchors.wal, ids.wal)) { - db.valueReader = reader; - return true; - } - reader.close(); - return false; - } - : undefined, - }); - db.stats.recoveryDurationMs += performance.now() - recT0; - db.stats.recoveryBytes += db.recoveryInfo.snapshotBytes + db.recoveryInfo.walBytes; - db.stats.recoveryFrames += db.recoveryInfo.snapshotFrames + db.recoveryInfo.walFrames; - // 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(); - db.seedAccessFromStore(); - + // Index definitions BEFORE recovery: the generation load path matches + // the live registries (and the TextIndex instances) against the + // manifest's definition hashes, so they must exist first. The loaders + // only read sidecars and construct empty indexes — order-independent + // with respect to the store. await db.loadIndexDefinitions(); await db.loadCompoundIndexDefinitions(); await db.loadTextIndexDefinitions(); - await db.rebuildAllIndexes(); + + // Stage 5: a published generation serves the open (store image + + // derived-index images + WAL delta replay) and skips the full rebuild + // below. Any validation failure falls back to the legacy full recovery + // inside tryLoadGeneration — never a deletion of authoritative data. + let generationLoaded = false; + if (db.indexGenerationsEnabled) generationLoaded = await db.tryLoadGeneration(opts.recovery ?? 'resync'); + + if (!generationLoaded) { + const recT0 = performance.now(); + db.recoveryInfo = await recover({ + dir: db.dir, + store: db.store, + mode: opts.recovery ?? 'resync', + truncate: !db.readOnly, + valueMode: db.valueMode, + // Disk-backed values need the positioned reader attached to the SAME + // inodes recovery scanned; recovery's generation pairing re-verifies + // the attach and retries the whole pass when a rotation landed in + // between (see the pairing note in recovery.ts). 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). + attachValueReader: + db.valueMode === 'disk' + ? (anchors) => { + const reader = new ValueReader(db.dir); + // open() can throw after attaching only one side (e.g. EMFILE + // on the WAL with the snapshot already open). This reader is + // never published to db.valueReader, so the open() failure + // cleanup cannot reach it — close it here or leak the fd. + let ids: ReturnType; + try { + ids = reader.open(); + } catch (e) { + reader.close(); + throw e; + } + const sameInode = (a: { dev: number; ino: number } | null, i: { dev: number; ino: number } | null): boolean => + a === null ? i === null : i !== null && i.dev === a.dev && i.ino === a.ino; + if (sameInode(anchors.snapshot, ids.snapshot) && sameInode(anchors.wal, ids.wal)) { + db.valueReader = reader; + return true; + } + reader.close(); + return false; + } + : undefined, + }); + db.stats.recoveryDurationMs += performance.now() - recT0; + db.stats.recoveryBytes += db.recoveryInfo.snapshotBytes + db.recoveryInfo.walBytes; + db.stats.recoveryFrames += db.recoveryInfo.snapshotFrames + db.recoveryInfo.walFrames; + // 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(); + db.seedAccessFromStore(); + await db.rebuildAllIndexes(); + } // A read-only instance never compacts: rotation would rename the live // writer's snapshot/WAL out from under it and lose its acknowledged data. @@ -649,13 +808,33 @@ export class MiniDb { // compaction here blocked open() on the whole snapshot rewrite + text // postings rebuild — tens of seconds of stalled startup on a large db. if (!db.readOnly && db.autoCompact && shouldCompact(db)) compact(db).catch(() => {}); + // Background generation build (fire-and-forget, like the compaction + // kick). Two triggers: (a) the legacy path served the open and there is + // data worth checkpointing — no (usable) generation exists; (b) a + // generation served the open but its checkpoint is far behind (the WAL + // delta replay was the dominant cost) — refresh it so the NEXT open is + // cheap again. An empty store is never worth a build (an empty + // generation would just force every later open to replay the whole WAL + // through the per-op path before anything refreshes it). + if (!db.readOnly && db.indexGenerationsEnabled) { + const gen = db.recoveryInfo?.indexGeneration; + const deltaOps = db.recoveryInfo?.walDeltaAppliedOps ?? 0; + const deltaBytes = gen ? db.recoveryInfo!.walScanEnd - gen.walCheckpoint : 0; + const stale = gen !== undefined && (deltaOps > GEN_BUILD_WAL_DELTA_OPS || deltaBytes > GEN_BUILD_WAL_DELTA_BYTES); + if ((!generationLoaded && db.size > 0) || stale) { + void db.buildGeneration('open').catch(() => {}); + } + } } catch (err) { // A background open-time compaction may still be in flight: settle it // before tearing down the WAL/store/handles it touches. if (db.compacting && db._compactDone) await db._compactDone.catch(() => {}); // 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 / - // active-expire timers running. + // active-expire timers running. Text indexes are closed too: a + // generation load may have attached postings handles before a later + // step failed (rebuildAllIndexes' builds likewise). + for (const ti of db.text.values()) ti.close(); if (db.wal) await db.wal.close().catch(() => {}); db.valueReader?.close(); db.store?.close(); @@ -749,19 +928,20 @@ export class MiniDb { } } - /** On-disk postings file path for a text index (name sanitized for the fs). */ + /** On-disk postings file path for a text index (root location — the legacy + * pre-generation home; the name sanitization lives in generation.ts). */ private textPostingsPath(name: string): string { - const safe = name.replace(/[^a-zA-Z0-9_.-]/g, '_'); - return path.join(this.dir, `db.text-${safe}.postings`); + return path.join(this.dir, rootPostingsFile(name)); } - /** Rebuild every dirty text index's on-disk postings from the live Store. - * Drops the in-memory delta + tombstones and reclaims orphaned postings - * records. Invoked after compaction (postings are pure derived state, so - * this is only for space/latency, never for correctness). Indexes with an - * empty write buffer are skipped: the open-time build just produced a - * fresh base, so a compaction landing right after open must not redo the - * exact same (expensive) pass. */ + /** LEGACY postings maintenance (indexGenerations: false): rebuild every + * dirty text index's on-disk postings from the live Store. With + * generations enabled this whole job is superseded by the generation + * build (the staged text builds + clean re-publish), so it only runs on + * the legacy onCompacted path. Drops the in-memory delta + tombstones and + * reclaims orphaned postings records — postings are pure derived state, + * so this is only for space/latency, never for correctness. Indexes with + * an empty write buffer are skipped: a fresh base must not be redone. */ private async rebuildTextPostings(): Promise { for (const [name, ti] of this.text) { // Skip indexes staged for drop (see textDrops): their postings are @@ -833,6 +1013,785 @@ export class MiniDb { } } + // ---- persistent index generations (stage 5) ------------------------------ + // + // The writer periodically checkpoints every piece of derived state into an + // atomically published generation (see generation.ts for the layout and the + // crash protocol). Build triggers: after each compaction rotation (the + // onCompacted hook — the rotation and the generation are one transaction), + // in the background after an open that found no usable generation, and the + // explicit rebuildGeneration() maintenance call. The build walks the live + // store into DETACHED index states (fresh IndexManager / DtIndex / + // CompoundIndexManager instances, plus staged TextIndex builds whose commit + // also rebases the live index) while applyOp feeds every concurrent write + // into a queue; a final synchronous drain + WAL watermark capture seals the + // exact checkpoint. The load path (tryLoadGeneration) validates the + // manifest, loads the images whose definition hashes still match, rebuilds + // only the affected indexes for mismatches, and replays just the WAL delta + // — open cost follows the WAL delta + index metadata, not the full corpus. + + /** The canonical definition shape a text index's manifest hash is computed + * from (both sides use it, so a legacy definition without `tokenizer` + * hashes identically to an explicit 'default'). */ + private static canonicalTextDef(d: TextIndexDef): { name: string; fields: readonly string[] | null; tokenizer: string } { + return { name: d.name, fields: d.fields, tokenizer: d.tokenizer ?? 'default' }; + } + + /** Read the store image / index images of one published generation and + * replay the WAL past its checkpoint. Throws GenerationCorruptError for + * every validation/consistency failure (the caller falls back); genuine + * system errors propagate. On success the instance is fully recovered — + * store, every derived index, recoveryInfo, value reader. */ + private async loadOneGeneration(id: string, mode: RecoveryMode): Promise { + const genDir = generationDir(this.dir, id); + const manifest = await readManifest(this.dir, id); + if (manifest.valueCodec !== this.codecName) { + throw new GenerationCorruptError(`codec mismatch (${manifest.valueCodec} != ${this.codecName})`); + } + if (manifest.valueMode !== this.valueMode) { + throw new GenerationCorruptError(`value mode mismatch (${manifest.valueMode} != ${this.valueMode})`); + } + const cp = manifest.checkpoint; + // WAL anchor: the checkpoint offset only has meaning on the exact inode + // the build measured, and the file must still reach it. + const walSt = await fs.stat(this.walPath).catch((e: NodeJS.ErrnoException) => { + if (e.code === 'ENOENT') return null; + throw e; + }); + if (!walSt || walSt.dev !== cp.walDev || walSt.ino !== cp.walIno || walSt.size < cp.walOffset) { + throw new GenerationCorruptError('WAL anchor mismatch (rotated or truncated since the build)'); + } + // Disk mode: image refs point into the generation's snapshot, which the + // live db.snapshot still aliases (hard link) — verify the identity. + if (this.valueMode === 'disk' && cp.snapshotIno !== 0) { + if (!cp.snapshotLinked) throw new GenerationCorruptError('snapshot not hard-linked; disk refs unservable'); + const snapSt = await fs.stat(path.join(this.dir, SNAPSHOT_FILE)).catch((e: NodeJS.ErrnoException) => { + if (e.code === 'ENOENT') return null; + throw e; + }); + if (!snapSt || snapSt.dev !== cp.snapshotDev || snapSt.ino !== cp.snapshotIno) { + throw new GenerationCorruptError('snapshot anchor mismatch (rotated since the build)'); + } + } + // Disk mode: attach the positioned reader NOW, before anything reads a + // value back — the image's refs and the WAL-delta replay both resolve + // through it (mirrors the legacy recovery's attach check). + if (this.valueMode === 'disk') { + const reader = new ValueReader(this.dir); + let ids: ReturnType; + try { + ids = reader.open(); + } catch (e) { + reader.close(); + throw e; + } + const walOk = ids.wal !== null && ids.wal.dev === cp.walDev && ids.wal.ino === cp.walIno; + const snapOk = + cp.snapshotIno === 0 + ? true // the build had no snapshot; the image can carry no snapshot refs + : ids.snapshot !== null && ids.snapshot.dev === cp.snapshotDev && ids.snapshot.ino === cp.snapshotIno; + if (!walOk || !snapOk) { + reader.close(); + throw new GenerationCorruptError('value reader attach raced a rotation'); + } + this.valueReader = reader; + } + + // Store image. Records expire-past at load time are dropped here AND + // noted, so their loaded index entries can be reconciled below (the + // image legitimately contains records whose TTL elapsed after the build). + const storeInfo = manifest.files[STORE_IMAGE_FILE]; + if (!storeInfo) throw new GenerationCorruptError('store image missing from manifest'); + const storePayload = await readGenerationFileChecked(path.join(genDir, STORE_IMAGE_FILE), 'MDGS', STORE_VERSION, storeInfo); + const now = Date.now(); + const droppedExpired: string[] = []; + const records: StoreImageRecord[] = []; + let imageCount = 0; + for (const rec of readStoreImage(storePayload)) { + imageCount++; + if (rec.expireAt && rec.expireAt <= now) { + droppedExpired.push(rec.kstr); + continue; + } + if (this.valueMode === 'memory' && rec.ref.kind !== 'memory') { + throw new GenerationCorruptError('store image carries disk refs for a memory-mode open'); + } + records.push(rec); + } + this.store.bulkLoadRefs(records); + if (manifest.counts && typeof manifest.counts.records === 'number' && manifest.counts.records !== imageCount) { + throw new GenerationCorruptError(`store image record count mismatch (${imageCount} != ${manifest.counts.records})`); + } + + // Derived-index images. Every failure here is LOCAL: a corrupt or missing + // image rebuilds exactly the affected index(es) from the loaded store. + await this.loadDtImage(genDir, manifest); + await this.loadSecondaryImages(genDir, manifest); + await this.loadCompoundImages(genDir, manifest); + await this.loadTextImages(genDir, manifest); + + // Reconcile the expired-at-load drops out of the loaded index states. + for (const k of droppedExpired) { + this.dt.del(k); + this.indexes.remove(k, undefined); + this.compound.remove(k); + for (const ti of this.text.values()) ti.remove(k); + } + + // Replay the WAL delta past the checkpoint with the exact same per-frame + // interpretation the legacy recovery uses (frameToOps), maintaining every + // derived index incrementally (applyRecoveredOp). + const replay = await this.replayWalDelta(cp.walOffset, mode); + // A rotation racing the load invalidates the coordinate system the + // recoveryInfo below is anchored to (and, in disk mode, the value reader + // attached above) — reject the candidate. + const walAfter = await fs.stat(this.walPath).catch((e: NodeJS.ErrnoException) => { + if (e.code === 'ENOENT') return null; + throw e; + }); + if (!walAfter || walAfter.dev !== cp.walDev || walAfter.ino !== cp.walIno) { + throw new GenerationCorruptError('WAL rotated during generation load'); + } + + this.recoveryInfo = { + snapshotFrames: records.length, + walFrames: replay.walFrames, + snapshotBytes: storeInfo.bytes, + walBytes: walSt.size, + truncatedWal: replay.truncatedWal, + corruptRanges: replay.corruptRanges, + snapshotCorruptRanges: [], + lostBytes: replay.corruptRanges.reduce((a, [s, e]) => a + (e - s), 0), + walScanEnd: replay.walScanEnd, + walDev: cp.walDev, + walIno: cp.walIno, + snapshotDev: cp.snapshotDev, + snapshotIno: cp.snapshotIno, + corruptBatches: replay.corruptBatches, + generationRetries: 0, + indexGeneration: { id, walCheckpoint: cp.walOffset, records: records.length }, + walDeltaAppliedOps: replay.appliedOps, + }; + this.generationInfo = { id, createdAt: manifest.createdAt, walCheckpoint: cp.walOffset, records: records.length }; + this.seedAccessFromStore(); + } + + /** Undo any partial state a failed generation-load candidate left behind, + * so the next candidate (or the legacy full recovery) starts clean: the + * store must be empty (recovery replays into it), the value reader + * detached, and any postings handles the candidate attached closed (the + * next path re-attaches or rebuilds as needed). */ + private resetAfterFailedGenerationLoad(): void { + for (const k of this.store.map.keys()) this.store.del(k); + this.valueReader?.close(); + this.valueReader = undefined; + for (const ti of this.text.values()) ti.close(); + } + + /** The generation-load entry point from open(): try CURRENT's generation + * first, then the previous ones (their WAL anchor survives whenever no + * compaction intervened). Corruption-class failures try the next + * candidate; genuine system errors propagate. Returns false when no + * candidate loaded (the caller runs the legacy full recovery). */ + private async tryLoadGeneration(mode: RecoveryMode): Promise { + const t0 = performance.now(); + const candidates: string[] = []; + try { + const current = await readCurrent(this.dir); + if (current) candidates.push(current); + for (const g of await listGenerations(this.dir)) { + if (!g.tmp && g.id !== current && candidates.length < 3) candidates.push(g.id); + } + } catch (e) { + this.stats.generationLoadFallbacks++; + this.stats.lastGenerationFallback = `list: ${(e as Error).message}`; + return false; + } + for (const id of candidates) { + try { + await this.loadOneGeneration(id, mode); + this.stats.generationLoads++; + this.stats.generationLoadDurationMs += performance.now() - t0; + return true; + } catch (e) { + if (!(e instanceof GenerationCorruptError) && (e as NodeJS.ErrnoException).code !== 'ENOENT') throw e; + this.stats.generationLoadFallbacks++; + this.stats.lastGenerationFallback = `${id}: ${(e as Error).message}`; + this.resetAfterFailedGenerationLoad(); + } + } + return false; + } + + /** Load the dt image; rebuild the (cheap, metadata-only) dt index from the + * loaded store when the image is absent/corrupt. */ + private async loadDtImage(genDir: string, manifest: GenerationManifest): Promise { + const info = manifest.files[DT_INDEX_FILE]; + if (info) { + try { + const payload = await readGenerationFileChecked(path.join(genDir, DT_INDEX_FILE), 'MDGD', 1, info); + this.dt.loadImage(readDtIndexImage(payload)); + return; + } catch (e) { + if (!(e instanceof GenerationCorruptError)) throw e; + } + } + this.stats.generationIndexRebuilds++; + const store = this.store; + this.dt.rebuild( + (function* (): Generator<{ key: string; dt: Record | null }> { + for (const rec of store.rawRecords()) yield { key: rec.kstr, dt: rec.dt }; + })(), + ); + } + + /** Load secondary-index images for definitions whose hash still matches; + * rebuild exactly the affected indexes otherwise (plan: only the affected + * index is rebuilt, never the whole registry). */ + private async loadSecondaryImages(genDir: string, manifest: GenerationManifest): Promise { + const live = this.indexes.list(); + if (live.length === 0) return; + let images: Map[number]> | null = null; + const info = manifest.files[SECONDARY_INDEX_FILE]; + if (info) { + try { + const payload = await readGenerationFileChecked(path.join(genDir, SECONDARY_INDEX_FILE), 'MDSI', 1, info); + images = new Map(readSecondaryIndexImage(payload).map((i) => [i.name, i])); + } catch (e) { + if (!(e instanceof GenerationCorruptError)) throw e; + } + } + for (const def of live) { + const image = images?.get(def.name); + if (image && manifest.indexDefs.secondary[def.name] === indexDefHash(def)) { + try { + this.indexes.loadImage(image); + continue; + } catch { + /* shape mismatch: rebuild below */ + } + } + this.stats.generationIndexRebuilds++; + this.rebuildOneSecondaryIndex(def); + } + } + + private rebuildOneSecondaryIndex(def: IndexInfo): void { + const fresh = new IndexManager(); + fresh.create(def.name, def); + for (const { key, value } of this._liveRecordsRaw()) { + if (this.indexable(value)) fresh.add(this.pk(key), value); + } + this.indexes.indexes.set(def.name, fresh.indexes.get(def.name)!); + } + + /** Load compound-index images (same per-index discipline as secondary). */ + private async loadCompoundImages(genDir: string, manifest: GenerationManifest): Promise { + const live = this.compound.list(); + if (live.length === 0) return; + let images: Map[number]> | null = null; + const info = manifest.files[COMPOUND_INDEX_FILE]; + if (info) { + try { + const payload = await readGenerationFileChecked(path.join(genDir, COMPOUND_INDEX_FILE), 'MDCI', 1, info); + images = new Map(readCompoundIndexImage(payload).map((i) => [i.name, i])); + } catch (e) { + if (!(e instanceof GenerationCorruptError)) throw e; + } + } + for (const def of live) { + const image = images?.get(def.name); + if (image && manifest.indexDefs.compound[def.name] === indexDefHash(def)) { + try { + this.compound.loadImage(image); + continue; + } catch { + /* shape mismatch: rebuild below */ + } + } + this.stats.generationIndexRebuilds++; + this.rebuildOneCompoundIndex(def); + } + } + + private rebuildOneCompoundIndex(def: CompoundIndexInfo): void { + const fresh = new CompoundIndexManager(); + fresh.create(def.name, { groupBy: def.groupBy, orderBy: def.orderBy, orderType: def.orderType }); + for (const { key, value, dt } of this.liveRecords()) { + fresh.add(this.pk(key), value, dt); + } + this.compound.indexes.set(def.name, fresh.indexes.get(def.name)!); + } + + /** Load text-index images (dictionary + docs + postings attachment) for + * definitions whose hash still matches; rebuild exactly the affected + * indexes otherwise — a rebuild is the full corpus tokenization for that + * one index, the cost stage 5 exists to avoid on the happy path. */ + private async loadTextImages(genDir: string, manifest: GenerationManifest): Promise { + for (const def of this.textDefs) { + const ti = this.text.get(def.name); + if (!ti) continue; + const dictInfo = manifest.files[textDictionaryFile(def.name)]; + const docsInfo = manifest.files[textDocsFile(def.name)]; + const postingsInfo = manifest.files[textPostingsFile(def.name)]; + let attached = false; + if (dictInfo && docsInfo && postingsInfo && manifest.indexDefs.text[def.name] === indexDefHash(MiniDb.canonicalTextDef(def))) { + try { + const dictPayload = await readGenerationFileChecked(path.join(genDir, textDictionaryFile(def.name)), 'MDTD', 1, dictInfo); + const docsPayload = await readGenerationFileChecked(path.join(genDir, textDocsFile(def.name)), 'MDTC', 1, docsInfo); + // The postings file carries the base every search reads: verify it + // wholesale against the manifest NOW (one streaming crc pass), so a + // corrupt base is rebuilt at open instead of failing a query later + // (its per-record CRCs would only trip on the first read). + const postingsPath = path.join(genDir, textPostingsFile(def.name)); + verifyFileIntegritySync(postingsPath, postingsInfo); + const dict = new Map(readTextDictionaryImage(dictPayload).map((e) => [e.term, { off: e.off, len: e.len, df: e.df }])); + const docs = readTextDocsImage(docsPayload); + const docLens = new Map(); + for (let i = 0; i < docs.docLens.length; i++) { + const len = docs.docLens[i]; + if (len !== undefined) docLens.set(i, len); + } + ti.attachImage({ + postingsPath, + dict, + keys: docs.keys, + docLens, + liveCount: docs.liveCount, + removed: new Set(docs.removed), + delta: new Map(docs.delta.map((d) => [d.term, new Map(d.docs.map((x) => [x.docID, x.freq] as [number, number]))])), + }); + // Carry the integrity record forward: a later CLEAN fast-path build + // re-publishes this unchanged file without re-reading it. + ti.postingsFileInfo = { bytes: postingsInfo.bytes, crc32: postingsInfo.crc32 }; + attached = true; + } catch (e) { + if (!(e instanceof GenerationCorruptError) && (e as NodeJS.ErrnoException).code !== 'ENOENT') throw e; + } + } + if (!attached) { + this.stats.generationIndexRebuilds++; + await ti.build(this.textRecords()); + } + } + } + + /** Replay WAL frames at/after `startOffset` onto the loaded store (and + * every derived index), with the legacy recovery's torn-tail handling: + * a corrupt tail is truncated by the writer, left alone read-only. */ + private async replayWalDelta( + startOffset: number, + mode: RecoveryMode, + ): Promise<{ + walFrames: number; + walScanEnd: number; + corruptRanges: [number, number][]; + truncatedWal: boolean; + corruptBatches: number; + appliedOps: number; + }> { + const fd = fsSync.openSync(this.walPath, 'r'); + try { + const st = fsSync.fstatSync(fd); + const r = scanFrameRefsFd(fd, { onCorrupt: mode, startOffset }); + let corruptBatches = 0; + let appliedOps = 0; + for (const f of r.frames) { + for (const op of frameToOps(f, 'wal', fd, this.valueMode, () => corruptBatches++)) { + this.applyRecoveredOp(op); + appliedOps++; + } + } + let truncatedWal = false; + const last = r.corruptRanges[r.corruptRanges.length - 1]; + if (last && last[1] === st.size && !this.readOnly) { + await fs.truncate(this.walPath, last[0]); + truncatedWal = true; + await this.wal.refreshSize(); + } + return { walFrames: r.frames.length, walScanEnd: r.eofOffset, corruptRanges: r.corruptRanges, truncatedWal, corruptBatches, appliedOps }; + } finally { + fsSync.closeSync(fd); + } + } + + /** Single-flight generation build entry point. 'open' dedupes onto an + * in-flight build; 'compact'/'manual' await the in-flight one (a rotation + * or their own trigger just made it abort) and then build fresh. */ + private async buildGeneration(trigger: 'open' | 'compact' | 'manual'): Promise { + if (this.readOnly || !this.indexGenerationsEnabled) return; + if (this.state !== 'open') return; + if (this.genBuildPromise) { + if (trigger === 'open') return this.genBuildPromise; + await this.genBuildPromise.catch(() => {}); + } + const run = this.runGenerationBuild(); + this.genBuildPromise = run; + try { + await run; + } finally { + if (this.genBuildPromise === run) this.genBuildPromise = null; + } + } + + /** The build itself: detached-state walk + mutation queue + seal + file + * writes + atomic publish, then retention cleanup. See the section header. */ + private async runGenerationBuild(): Promise { + const t0 = performance.now(); + const gens = generationsDir(this.dir); + const prevCurrent = await readCurrent(this.dir); + const existing = await listGenerations(this.dir); + const nextN = Math.max(prevCurrent ? (existing.find((g) => g.id === prevCurrent)?.n ?? 0) : 0, existing[0]?.n ?? 0) + 1; + const id = generationId(nextN); + const tmpName = `${id}.tmp-${process.pid}`; + const tmpDir = path.join(gens, tmpName); + + const gb = { queue: [] as GenBuildOp[], bytes: 0, wal: this.wal, aborted: false }; + // Detached derived states (never touched by the live write paths). + const dtB = new DtIndex(); + const secB = new IndexManager(); + for (const d of this.indexes.list()) secB.create(d.name, d); + const cmpB = new CompoundIndexManager(); + for (const d of this.compound.list()) cmpB.create(d.name, { groupBy: d.groupBy, orderBy: d.orderBy, orderType: d.orderType }); + const imageRecords = new Map | null }>(); + const textBuilds = new Map(); + /** Clean text indexes (empty write buffer): no staged rebuild — the + * current base is re-published wholesale (hard link + live-state + * serialization), the generation-era form of the old needsRebuild skip. + * A compaction over a static corpus therefore never re-tokenizes it. */ + const textClean = new Map(); + + const drainQueue = (): void => { + if (gb.queue.length === 0) return; + const ops = gb.queue.splice(0, gb.queue.length); + gb.bytes = 0; + for (const op of ops) { + if (op.type === TYPE_SET) { + imageRecords.set(op.pk, { ref: { kind: 'memory', value: op.value! }, expireAt: op.expireAt, dt: op.dtNorm }); + dtB.set(op.pk, op.dtNorm); + if (!op.storeOnly) { + secB.remove(op.pk, undefined); + if (this.indexable(op.canonical)) secB.add(op.pk, op.canonical); + cmpB.remove(op.pk); + cmpB.add(op.pk, op.canonical, op.dtNorm); + } + } else { + imageRecords.delete(op.pk); + dtB.del(op.pk); + secB.remove(op.pk, undefined); + cmpB.remove(op.pk); + } + } + }; + + const checkAlive = (): void => { + if (gb.aborted) throw new GenerationBuildAborted('store rewound by a WAL rollback'); + if (this.wal !== gb.wal) throw new GenerationBuildAborted('compaction rotation replaced the WAL'); + if (this.state !== 'open') throw new GenerationBuildAborted('instance is closing'); + if (gb.queue.length > GEN_BUILD_QUEUE_CAP || gb.bytes > GEN_BUILD_QUEUE_BYTES_CAP) { + throw new GenerationBuildAborted('write storm outran the build'); + } + }; + + const files: Record = {}; + let sealedOffset = 0; + try { + await fs.mkdir(tmpDir, { recursive: true }); + // Staged text builds register their build queues FIRST, so every write + // in the window is captured for the swap-time replay (existing + // TextIndex machinery). An index that cannot start a build (one already + // in flight, e.g. a concurrent createTextIndex) is excluded from the + // image — the loader rebuilds it. A CLEAN index (empty delta, no + // tombstones) skips the staged rebuild entirely: its unchanged base is + // re-published by link below. + for (const [name, ti] of this.text) { + try { + if (!ti.needsRebuild()) { + textClean.set(name, ti); + continue; + } + textBuilds.set(name, { ti, b: ti.beginBuild({ postingsPath: path.join(tmpDir, textPostingsFile(name)) }) }); + } catch { + /* excluded from this generation */ + } + } + // Register the mutation queue only AFTER the staged builds exist, so + // queued ops and staged text builds cover the same window. + this.genBuild = gb; + + // Phase 1: walk the live store into the detached states. Sorted keys + // (the store's ordered index), so the store image is written in + // bulk-load order without a later sort. + let docsSinceYield = 0; + let tokensSinceYield = 0; + const needValues = secB.indexes.size > 0 || cmpB.indexes.size > 0 || textBuilds.size > 0; + for (const kstr of this.store.rawKeys()) { + const rec = this.store.map.get(kstr); + if (!rec) continue; + imageRecords.set(kstr, { ref: rec.ref, expireAt: rec.expireAt, dt: rec.dt }); + dtB.set(kstr, rec.dt); + if (needValues) { + const buf = rec.ref.kind === 'memory' ? rec.ref.value : this.valueReader!.read(rec.ref.loc); + const doc = this.decode(buf); + if (this.indexable(doc)) { + secB.add(kstr, doc); + for (const { b } of textBuilds.values()) tokensSinceYield += b.add(kstr, doc); + } + cmpB.add(kstr, doc, rec.dt); + } + if (++docsSinceYield >= REBUILD_YIELD_DOCS || tokensSinceYield >= 500_000) { + docsSinceYield = 0; + tokensSinceYield = 0; + drainQueue(); + checkAlive(); + await yieldToLoop(); + } + } + + // Seal: the final drain, the liveness check, the queue cutoff, and the + // WAL watermark read form ONE synchronous segment — no op can interleave, + // so the image equals replaying every frame below the checkpoint exactly. + drainQueue(); + checkAlive(); + this.genBuild = null; + sealedOffset = gb.wal.appendOffset; + + // Phase 2: commit the staged text builds — each writes its postings file + // into the tmp dir, swaps the LIVE base onto it (the compaction-time + // rebase that replaces rebuildTextPostings), and replays its queue. + const textStates = new Map>(); + for (const [name, tb] of textBuilds) { + await tb.b.commit(); + textStates.set(name, tb.ti.exportImageState()); + checkAlive(); + } + // Clean indexes: serialize the live state as-is and re-publish the + // unchanged postings file by hard link (copy fallback). The manifest + // reuses the integrity record from the build that WROTE the file (it is + // immutable until replaced, so the record is still exact) — no + // re-tokenization, no re-read. + const cleanPostings = new Map(); + for (const [name, ti] of textClean) { + const src = ti.currentPostingsPath; + const info = ti.postingsFileInfo; + if (src && info) { + cleanPostings.set(name, { src, info }); + textStates.set(name, ti.exportImageState()); + } + // else: cannot re-publish safely (memory base / unknown integrity) — + // omit from the image; the loader rebuilds that index. + } + + // Phase 3: write every image file (fsynced individually by the writers). + // The store image is written in ascending key order (the load path + // bulk-builds the ordered index from file order): the walk's keys were + // already sorted, but queue-applied keys appended out of order. + const sortedImageKeys = [...imageRecords.keys()].sort(); + const storeRes = await writeStoreImage( + path.join(tmpDir, STORE_IMAGE_FILE), + (function* (): Generator { + for (const kstr of sortedImageKeys) { + const r = imageRecords.get(kstr)!; + yield { kstr, ref: r.ref, expireAt: r.expireAt, dt: r.dt }; + } + })(), + ); + files[STORE_IMAGE_FILE] = { bytes: storeRes.bytes, crc32: storeRes.crc32 }; + files[DT_INDEX_FILE] = await writeDtIndexImage(path.join(tmpDir, DT_INDEX_FILE), dtB.exportImage()); + const secImages = secB.exportImage(); + files[SECONDARY_INDEX_FILE] = await writeSecondaryIndexImage(path.join(tmpDir, SECONDARY_INDEX_FILE), secImages); + const cmpExport = cmpB.exportImage(); + files[COMPOUND_INDEX_FILE] = await writeCompoundIndexImage(path.join(tmpDir, COMPOUND_INDEX_FILE), cmpExport.images); + for (const [name, state] of textStates) { + files[textDictionaryFile(name)] = await writeTextDictionaryImage( + path.join(tmpDir, textDictionaryFile(name)), + (function* (): Generator<{ term: string; off: number; len: number; df: number }> { + for (const [term, e] of state.dict) yield { term, off: e.off, len: e.len, df: e.df }; + })(), + ); + const docsImage: TextDocsImage = { + keys: state.keys, + docLens: (() => { + const out: (number | undefined)[] = []; + for (let i = 0; i < state.keys.length; i++) out.push(state.docLens.get(i)); + return out; + })(), + liveCount: state.liveCount, + removed: [...state.removed], + delta: [...state.delta].map(([term, m]) => ({ + term, + docs: [...m].map(([docID, freq]) => ({ docID, freq })), + })), + }; + files[textDocsFile(name)] = await writeTextDocsImage(path.join(tmpDir, textDocsFile(name)), docsImage); + const clean = cleanPostings.get(name); + if (clean) { + // Re-publish the unchanged base: hard link (same inode, zero copy), + // copy fallback — carrying the original integrity record. + const dst = path.join(tmpDir, textPostingsFile(name)); + try { + await fs.link(clean.src, dst); + } catch { + await fs.copyFile(clean.src, dst); + } + files[textPostingsFile(name)] = clean.info; + } else { + const postInfo = textBuilds.get(name)?.ti.postingsFileInfo; + if (!postInfo) throw new GenerationBuildAborted(`text index "${name}" produced no postings file info`); + files[textPostingsFile(name)] = postInfo; + } + } + + // The generation's own snapshot reference: a hard link to the live + // db.snapshot (same inode, zero copy — later rotations rename the path + // away and the generation keeps the inode), falling back to a full copy + // on filesystems without links (manifest records which; disk-mode loads + // require the link). + const snapSrc = path.join(this.dir, SNAPSHOT_FILE); + let snapSt: fsSync.Stats | null = null; + let snapshotLinked = false; + try { + snapSt = await fs.stat(snapSrc); + } catch (e) { + if ((e as NodeJS.ErrnoException).code !== 'ENOENT') throw e; + } + if (snapSt) { + try { + await fs.link(snapSrc, path.join(tmpDir, GEN_SNAPSHOT_FILE)); + snapshotLinked = true; + } catch { + await fs.copyFile(snapSrc, path.join(tmpDir, GEN_SNAPSHOT_FILE)); + const h = await fs.open(path.join(tmpDir, GEN_SNAPSHOT_FILE), 'r'); + try { + await h.sync(); + } finally { + await h.close().catch(() => {}); + } + } + } + const walSt = await fs.stat(this.walPath); + + // The manifest hashes exactly the indexes this image carries (a + // definition created/dropped mid-build is simply absent — the loader + // rebuilds or ignores it). + const manifest: GenerationManifest = { + format: GENERATION_FORMAT_VERSION, + id, + createdAt: Date.now(), + valueCodec: this.codecName, + valueMode: this.valueMode, + checkpoint: { + walOffset: sealedOffset, + walDev: walSt.dev, + walIno: walSt.ino, + walSize: sealedOffset, + snapshotBytes: snapSt?.size ?? 0, + snapshotDev: snapSt?.dev ?? 0, + snapshotIno: snapSt?.ino ?? 0, + snapshotLinked, + }, + indexDefs: { + secondary: Object.fromEntries( + secImages.map((i) => [ + i.name, + indexDefHash({ name: i.name, field: i.field, type: i.type, unique: i.unique, sparse: i.sparse }), + ]), + ), + compound: Object.fromEntries( + cmpExport.images.map((i) => [i.name, indexDefHash({ name: i.name, groupBy: i.groupBy, orderBy: i.orderBy, orderType: i.orderType })]), + ), + text: Object.fromEntries( + [...textStates.keys()].map((name) => { + const def = this.textDefs.find((d) => d.name === name); + return [name, def ? indexDefHash(MiniDb.canonicalTextDef(def)) : '']; + }), + ), + }, + files, + counts: { + records: imageRecords.size, + dtColumns: dtB.columns().length, + secondaryIndexes: secImages.length, + compoundIndexes: cmpExport.images.length, + textIndexes: textStates.size, + }, + }; + checkAlive(); + await writeManifest(tmpDir, manifest); + await fsyncDir(tmpDir, { strict: true, stats: this.stats }); + // Windows cannot rename a directory with open files inside: the + // committed bases' live handles sit in the tmp dir, so close them first + // (repointPostings reopens at the final path below). POSIX keeps the + // handles valid across the rename — no close needed there. + if (process.platform === 'win32') { + for (const [, tb] of textBuilds) tb.ti.close(); + } + await publishGeneration(this.dir, tmpName, id, { stats: this.stats }); + // Repoint EVERY live base this build (re)published into the CURRENT + // generation: staged commits still read the (now renamed) tmp path, and + // clean re-publishes still read their OLD location (the root file — or + // a previous generation's — both about to be reclaimed below). Without + // this the next clean fast path links from a deleted path and fails + // ENOENT forever. The invariant after publish: every live text base + // reads from inside the CURRENT generation. POSIX: same inode (the + // hard link), just update the path string; win32: close + reopen there. + for (const [name, tb] of textBuilds) { + tb.ti.repointPostings(path.join(generationDir(this.dir, id), textPostingsFile(name))); + } + for (const [name, ti] of textClean) { + if (cleanPostings.has(name)) ti.repointPostings(path.join(generationDir(this.dir, id), textPostingsFile(name))); + } + + this.generationInfo = { id, createdAt: manifest.createdAt, walCheckpoint: sealedOffset, records: imageRecords.size }; + this.stats.generationBuilds++; + this.stats.generationBuildDurationMs += performance.now() - t0; + + // Retention: keep the new and the previously-published generation; sweep + // everything else (stray tmp dirs included). Best-effort, async. + const keep = new Set(prevCurrent ? [id, prevCurrent] : [id]); + void cleanupGenerations(this.dir, keep).catch(() => {}); + // The live text bases now live inside the new generation: the legacy + // root postings files are superseded derived state — reclaim them. + for (const name of textStates.keys()) { + await fs.rm(this.textPostingsPath(name), { force: true }).catch(() => {}); + } + } catch (e) { + if (this.genBuild === gb) this.genBuild = null; + // Uncommitted staged builds only disarm their queues (the live indexes + // stay authoritative); committed ones keep their new base — its + // postings file stays readable through the open fd even though the + // stranded tmp dir is swept at the next open (POSIX; on Windows the + // sweep fails best-effort until the handle closes). + for (const [, tb] of textBuilds) tb.b.abort(); + if (e instanceof GenerationBuildAborted) { + this.stats.generationBuildAborts++; + return; + } + this.stats.generationBuildErrors++; + throw e; + } finally { + if (this.genBuild === gb) this.genBuild = null; + void sealedOffset; + } + } + + /** Explicit maintenance (stage 5): build + publish a fresh index generation + * now. Writer only. The load path is automatic; this exists for operators + * who want to force a checkpoint after a large burst of writes instead of + * waiting for the next compaction. */ + async rebuildGeneration(): Promise { + this.ensureOpen(); + this.ensureWritable(); + if (!this.indexGenerationsEnabled) throw new Error('index generations are disabled (OpenOptions.indexGenerations: false)'); + await this.buildGeneration('manual'); + } + + /** Stable generation status: the generation this instance loaded at open or + * last published (null when running on the legacy recovery path). */ + getIndexGeneration(): { id: string; createdAt: number; walCheckpoint: number; records: number } | null { + return this.generationInfo ? { ...this.generationInfo } : null; + } + private async loadIndexDefinitions(): Promise { try { const raw = await fs.readFile(this.indexPath, 'utf8'); @@ -1661,6 +2620,22 @@ export class MiniDb { for (const ti of this.text.values()) ti.remove(op.pk); } } + // Stage 5: feed the in-flight generation build (if any) so its detached + // states converge on the exact sealed checkpoint — see genBuild. Infallible + // (a bare array push + counter), preserving this method's must-not-throw + // contract. + const gb = this.genBuild; + if (gb) { + gb.queue.push({ + type: op.type, + pk: op.pk, + value: op.value, + expireAt: op.expireAt, + dtNorm: op.dtNorm, + canonical: op.canonical, + }); + gb.bytes += (op.value ? op.value.length : 0) + 64; + } if (op.type === TYPE_SET) this.touchAccess(op.pk); } @@ -1685,6 +2660,10 @@ export class MiniDb { * rollback: put the key back to `prev` across the store and every derived * index (TTL/access/dt/secondary/compound/text). */ private restoreGroupKey(pk: string, prev: StoreRecord | undefined): void { + // A rollback rewinds the store OUTSIDE applyOp's op stream, so an + // in-flight generation build can no longer prove its image equals the + // checkpoint replay: abort it (expected churn, never an error). + if (this.genBuild) this.genBuild.aborted = true; if (this.indexes.size) this.indexes.remove(pk, undefined); for (const ti of this.text.values()) ti.remove(pk); this.dt.del(pk); @@ -1802,6 +2781,22 @@ export class MiniDb { let seq: number | undefined; try { this.store.set(k, curValue, expireAt, cur.dt); + // Stage 5: expire() rewrites the TTL without going through applyOp, + // so the generation build's queue needs this store-only entry — the + // value is unchanged and value-derived indexes need no re-feed. + const gb = this.genBuild; + if (gb) { + gb.queue.push({ + type: TYPE_SET, + pk: k, + value: curValue, + expireAt, + dtNorm: cur.dt, + canonical: undefined, + storeOnly: true, + }); + gb.bytes += curValue.length + 64; + } seq = this.store.map.get(k)?.seq; } catch (err) { // The in-memory mutation failed: an enqueued frame poisons the WAL @@ -2392,7 +3387,13 @@ export class MiniDb { private async copyIfExists(name: string, destDir: string): Promise { try { - await fs.copyFile(path.join(this.dir, name), path.join(destDir, name)); + const src = path.join(this.dir, name); + const st = await fs.stat(src); + // The generations/ tree is a directory: copy it recursively (backup + // includes published generations; a restore's inode change safely + // invalidates their WAL anchors, so they fall back to a rebuild). + if (st.isDirectory()) await fs.cp(src, path.join(destDir, name), { recursive: true }); + else await fs.copyFile(src, path.join(destDir, name)); return true; } catch (e) { if ((e as NodeJS.ErrnoException).code === 'ENOENT') return false; @@ -2548,7 +3549,10 @@ export class MiniDb { const names = await fs.readdir(srcDir); for (const name of names) { if (isPersistentFile(name) || name === 'backup.manifest.json') { - await fs.copyFile(path.join(srcDir, name), path.join(destDir, name)); + const src = path.join(srcDir, name); + const st = await fs.stat(src); + if (st.isDirectory()) await fs.cp(src, path.join(destDir, name), { recursive: true }); + else await fs.copyFile(src, path.join(destDir, name)); } } return MiniDb.open({ ...openOpts, dir: destDir }); @@ -2626,6 +3630,10 @@ export class MiniDb { // it escape here would skip the whole cleanup pass (the caller would have // to close() twice to actually release the lock). if (this.compacting) await this._compactDone?.catch(() => {}); + // Settle an in-flight generation build (its liveness check aborts it once + // the state flips to 'closing') before its file handles/posts are torn + // down. Failures are already accounted in the generation stats. + if (this.genBuildPromise) await this.genBuildPromise.catch(() => {}); // Let in-flight WAL failures and their kicked recoveries settle before // and after closing the WAL: a poisoned/failing close would otherwise // leave an un-acked tail in db.wal that a reopen replays as ghost writes. diff --git a/packages/minidb/src/persistent-files.ts b/packages/minidb/src/persistent-files.ts deleted file mode 100644 index 7e74c3b8ce2..00000000000 --- a/packages/minidb/src/persistent-files.ts +++ /dev/null @@ -1,77 +0,0 @@ -// src/persistent-files.ts -// -// The authoritative inventory of MiniDb's on-disk persistent files — the -// single source of truth that every file-set enumerator derives from: the -// cluster reader fingerprint (cluster/lock-pool.ts), backup/restore -// (index.ts), and the open-time stale-temp cleanup (index.ts). Before this -// module existed the set was hand-enumerated in at least four places and the -// lists had already drifted apart (db.compound-indexes.json was invisible to -// the fingerprint — review #17). Adding a persisted file now means adding it -// HERE, and no consumer can silently miss it. -// -// MiniDb's disk state is a compound document: the primary data pair -// (db.snapshot + db.wal), the index-definition sidecars, and the per-text- -// index postings files. This module holds name/pattern knowledge only; it -// performs no I/O. -// -// Internal to the package — NOT re-exported from the root entry point. -// -// TRANSITIONAL: stage 5's generations/ manifest absorbs this module (the -// manifest codec becomes the authority on the file set). Until then, never -// re-enumerate these names elsewhere. - -/** The primary data pair recovery pairs up: the snapshot, then the WAL. */ -export const SNAPSHOT_FILE = 'db.snapshot'; -export const WAL_FILE = 'db.wal'; - -/** Index-definition sidecars, rewritten atomically (tmp + rename) on every - * definition change. */ -export const SECONDARY_INDEXES_FILE = 'db.indexes.json'; -export const COMPOUND_INDEXES_FILE = 'db.compound-indexes.json'; -export const TEXT_INDEXES_FILE = 'db.textindexes.json'; -export const SIDECAR_FILES = [SECONDARY_INDEXES_FILE, COMPOUND_INDEXES_FILE, TEXT_INDEXES_FILE] as const; - -/** Per-text-index postings files (derived state, rebuilt on open and after - * each compaction) share one naming pattern with the index name embedded. */ -export const POSTINGS_PATTERN = /^db\.text-.*\.postings$/; - -/** The files the cluster reader fingerprint MUST track: a change to any of - * them means a cached read-only instance can no longer serve without a - * refresh. The WAL comes first — the lock pool's "WAL-only append" fast path - * compares every OTHER entry by position (see shardFingerprint). */ -export const FINGERPRINT_FILES = [WAL_FILE, SNAPSHOT_FILE, ...SIDECAR_FILES] as const; - -/** Is `name` one of MiniDb's persistent files (a primary data file, an - * index-definition sidecar, or a postings file)? backup/restore filter on - * this. */ -export function isPersistentFile(name: string): boolean { - return ( - name === SNAPSHOT_FILE || - name === WAL_FILE || - (SIDECAR_FILES as readonly string[]).includes(name) || - POSTINGS_PATTERN.test(name) - ); -} - -/** Atomic-write temp siblings a crashed previous run may have left behind: - * a compaction's snapshot/WAL temps (fixed names), plus sidecar-definition - * temps from before sidecar writes gained unique suffixes. Current sidecar - * writes use `.tmp--` names, matched by isStaleTmpFile - * instead. Only the sole writer may delete them at open — a read-only - * opener must never touch a live writer's in-flight temps. */ -export const STALE_TMP_FILES: readonly string[] = [SNAPSHOT_FILE, WAL_FILE, ...SIDECAR_FILES].map((f) => `${f}.tmp`); - -/** Is `name` a unique-suffixed atomic-write temp (`.tmp--`) - * of one of the primary/sidecar files, orphaned by a crash between the tmp - * write and the rename? Whitelisted per known file so a LockFile's - * `db.lock.tmp-*` — possibly in flight in ANOTHER process right now — is - * never matched. Same deletion discipline as STALE_TMP_FILES: only the sole - * writer at open. */ -export function isStaleTmpFile(name: string): boolean { - return [SNAPSHOT_FILE, WAL_FILE, ...SIDECAR_FILES].some((f) => name.startsWith(`${f}.tmp-`)); -} - -/** A failed postings rebuild orphans `db.text-*.postings.tmp` (its atomic - * rename never ran). Postings are pure derived state, so such temps are - * always safe for the writer to delete, for any index name. */ -export const STALE_POSTINGS_TMP_PATTERN = /^db\.text-.*\.postings\.tmp$/; diff --git a/packages/minidb/src/recovery.ts b/packages/minidb/src/recovery.ts index a17afd89097..75bbda29db3 100644 --- a/packages/minidb/src/recovery.ts +++ b/packages/minidb/src/recovery.ts @@ -8,33 +8,36 @@ // meta) lives in frameToOps so that open-time recovery and read-replica WAL // catch-up (catchUpWal) can never drift apart. // -// GENERATION PAIRING (stat-pairing — a TRANSITIONAL implementation; stage -// 5's generations/ manifest replaces it with a generation-id comparison, -// with the replacement confined to recoverPass/sameGeneration and the -// attachValueReader hook below). MiniDb's disk state is a compound document: -// a compaction rotation swaps db.snapshot and db.wal in two renames, and a -// read-only opener that scans the two files unpaired can combine the OLD -// snapshot with the NEW truncated WAL — silently losing the data the -// snapshot had absorbed, and (in disk mode) reading values back through -// offsets that point into the wrong inode (review #15). recover() therefore -// runs bounded passes: each pass fingerprints both files (dev/ino/size of -// the opened fd BEFORE scanning it, a path re-stat AFTER the last read), and -// any generation switch — an inode change, a size shrink, a file appearing -// or disappearing mid-pass — discards the pass's whole result and retries -// with exponential backoff. A WAL that merely GREW on the same inode is safe -// (append-only; the extra frames are a natural staleness window that -// catch-up covers). Exhausting the retries throws +// GENERATION PAIRING (stat-pairing — the LEGACY fallback path). MiniDb's +// disk state is a compound document: a compaction rotation swaps db.snapshot +// and db.wal in two renames, and a read-only opener that scans the two files +// unpaired can combine the OLD snapshot with the NEW truncated WAL — silently +// losing the data the snapshot had absorbed, and (in disk mode) reading +// values back through offsets that point into the wrong inode (review #15). +// recover() therefore runs bounded passes: each pass fingerprints both files +// (dev/ino/size of the opened fd BEFORE scanning it, a path re-stat AFTER the +// last read), and any generation switch — an inode change, a size shrink, a +// file appearing or disappearing mid-pass — discards the pass's whole result +// and retries with exponential backoff. A WAL that merely GREW on the same +// inode is safe (append-only; the extra frames are a natural staleness window +// that catch-up covers). Exhausting the retries throws // RecoveryGenerationChurnError. The writer's own open walks the same code // path but is naturally stable (it holds the write lock, and compaction only // starts after recovery completes), so it costs two extra stat calls and // changes zero behavior. +// +// Stage 5 supersedes this for the common case: when a published persistent +// index generation exists (generations/ + CURRENT, see generation.ts), open +// loads it and replays only the WAL delta past its checkpoint, and this full +// scan runs only as the fallback — for legacy databases, a missing/invalid +// generation, or a rotated-away WAL anchor. 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, MAGIC } from './codec.js'; import type { FrameRef } from './codec.js'; -import { SNAPSHOT_FILE, WAL_FILE } from './persistent-files.js'; +import { SNAPSHOT_FILE, WAL_FILE } from './generation.js'; import type { Store, ValueLoc, ValueRef } from './store.js'; export type RecoveryMode = 'resync' | 'strict'; @@ -68,6 +71,16 @@ export interface RecoveryInfo { /** Generation-churn retries recovery needed before it paired a consistent * snapshot/WAL set (0 on a stable directory — see the file header). */ generationRetries: number; + /** Stage 5: set when this recovery was served by a persistent index + * generation instead of the full snapshot/WAL scan + index rebuild. The + * generation id, the WAL checkpoint it covered (frames at/after it were + * replayed on top), and the store-image record count. */ + indexGeneration?: { id: string; walCheckpoint: number; records: number }; + /** Stage 5, generation loads only: how many primitive ops the WAL-delta + * replay applied on top of the loaded generation (drives the open-time + * background-refresh decision — a large delta means the checkpoint is + * stale and worth rebuilding in the background). */ + walDeltaAppliedOps?: number; } function readAtSync(fd: number, off: number, len: number): Buffer { diff --git a/packages/minidb/src/skiplist.ts b/packages/minidb/src/skiplist.ts index 9feaac4b1fd..42a11b497c3 100644 --- a/packages/minidb/src/skiplist.ts +++ b/packages/minidb/src/skiplist.ts @@ -70,6 +70,59 @@ export class SkipList { this.header = new SkipNode(undefined as unknown as K, undefined as unknown as V, MAX_LEVEL); } + /** Deterministic O(N) construction from entries already sorted by (key, val) + * ascending — the load path for a persisted index image (stage 5), where + * inserting one node at a time would cost O(N log N) with random levels. + * Levels are assigned as a balanced 4-ary tower (node at 0-based index i + * rises past level l when (i+1) % 4^l === 0) and every span is computed + * directly, so the result satisfies the exact same forward/span/backward + * invariants insert()/delete() maintain; later mutations re-randomize + * locally through the normal paths. Duplicate (key, val) pairs are skipped + * (insert() would never create them either). */ + static bulkLoad(entries: readonly RangeEntry[], opts: SkipListOptions = {}): SkipList { + const list = new SkipList(opts); + const n = entries.length; + if (n === 0) return list; + const nodes: SkipNode[] = []; + for (let i = 0; i < n; i++) { + const e = entries[i]!; + if (nodes.length > 0) { + const prev = nodes[nodes.length - 1]!; + if (list.cmpK(prev.key, e.key) === 0 && list.cmpV(prev.val, e.val) === 0) continue; + } + // Balanced tower: index i (0-based) reaches level 1 + v4(i+1), capped. + let lvl = 1; + for (let m = i + 1; m % 4 === 0 && lvl < MAX_LEVEL; m = m / 4) lvl++; + nodes.push(new SkipNode(e.key, e.val, lvl)); + } + const count = nodes.length; + list.level = 1; + for (const node of nodes) if (node.level.length > list.level) list.level = node.level.length; + // Link every level: forward pointers + spans (level-0 distance to the + // forward node; 0 for a tail's null forward, matching insert()). + const lastAt: { node: SkipNode; index: number }[] = []; + for (let l = 0; l < list.level; l++) lastAt.push({ node: list.header, index: -1 }); + for (let i = 0; i < count; i++) { + const node = nodes[i]!; + for (let l = 0; l < node.level.length; l++) { + const pred = lastAt[l]!; + pred.node.level[l]!.forward = node; + pred.node.level[l]!.span = i - pred.index; + lastAt[l] = { node, index: i }; + } + node.backward = i === 0 ? null : nodes[i - 1]!; + } + for (let l = 0; l < list.level; l++) { + const pred = lastAt[l]!; + // Header spans at unused levels keep the insert() convention (distance + // from the header's virtual index -1, i.e. count); real tail nodes get 0. + pred.node.level[l]!.span = pred.index === -1 ? count : 0; + } + list.tail = nodes[count - 1]!; + list.length = count; + return list; + } + private nodeLess(a: SkipNode, b: { key: K; val: V }): boolean { const c = this.cmpK(a.key, b.key); return c < 0 || (c === 0 && this.cmpV(a.val, b.val) < 0); diff --git a/packages/minidb/src/store.ts b/packages/minidb/src/store.ts index 745eb5ca4ff..80d14deacb8 100644 --- a/packages/minidb/src/store.ts +++ b/packages/minidb/src/store.ts @@ -117,7 +117,7 @@ export interface StoreOptions { export class Store { readonly map = new Map(); // kstr -> record - private readonly order = new SkipList({ compareKey: cmpString }); // kstr ordered + private order = new SkipList({ compareKey: cmpString }); // kstr ordered private readonly heap = new MinHeap(); private seq = 0; /** Approximate bytes held by live + expired-not-yet-reaped records. In @@ -349,6 +349,33 @@ export class Store { } } + /** Stage-5 generation load: populate the store wholesale from a recovered + * generation store image. `records` must be expiry-filtered by the caller + * (expired-past records dropped) and sorted by canonical key ascending (the + * image's write order), so the ordered index is bulk-built in O(N) instead + * of per-record inserts. + * + * OWNERSHIP: the records' refs are adopted as-is (no defensive clone) — + * the image parser produced fresh buffers for exactly this purpose. + * `metaBytes` is the precomputed dt accounting value (0 = none), so the + * load never re-stringifies per record. */ + bulkLoadRefs( + records: Iterable<{ kstr: string; ref: ValueRef; expireAt: number; dt: Record | null; metaBytes?: number }>, + ): void { + const orderEntries: RangeEntry[] = []; + for (const { kstr, ref, expireAt, dt, metaBytes } of records) { + const seq = ++this.seq; + this.map.set(kstr, { ref, expireAt: expireAt || 0, seq, dt }); + this.bytes += Buffer.byteLength(kstr, 'binary') + this.refBytes(ref) + (metaBytes ?? 0); + if (expireAt) { + this.expiring++; + this.heap.push({ t: expireAt, k: kstr, seq }); + } + orderEntries.push({ key: kstr, val: kstr }); + } + this.order = SkipList.bulkLoad(orderEntries, { compareKey: cmpString }); + } + private activeExpire(): void { const now = Date.now(); // Normal ticks stay within the small budget; a tick that still finds a diff --git a/packages/minidb/src/text-index.ts b/packages/minidb/src/text-index.ts index ffb2eb55673..e18d09325ee 100644 --- a/packages/minidb/src/text-index.ts +++ b/packages/minidb/src/text-index.ts @@ -13,15 +13,19 @@ // LRU cache), merges the in-memory `delta`, drops tombstones, and scores // by TF-IDF. Synchronous by design so db.search()/db.query() keep their // synchronous API. -// - Builds: open and compaction rebuild the whole index from the Store. +// - Builds: a generation build stages a rebuild whose commit also rebases +// the live index (the compaction-time postings refresh); a load attaches +// the generation's persisted dictionary + postings + doc table; the +// legacy open path still rebuilds the whole index from the Store. // `build()` is async and yields to the event loop periodically, so a big // rebuild never hard-blocks the host process; writes landing mid-build // keep applying to the live view (searches stay correct) and are queued // for a synchronous replay onto the new base at swap time. -// - Durability: the postings file is a pure derived cache of the Store; it is -// rebuilt from the Store on open and on compaction. The Store (snapshot + +// - Durability: the postings file is a pure derived cache of the Store; +// it is checkpointed into each published generation and rebuilt from the +// Store whenever a generation is absent or invalid. The Store (snapshot + // WAL) is the source of truth, so a crash never loses postings — they are -// simply rebuilt. +// simply rebuilt or re-checkpointed. import { getPath } from './query.js'; import { PostingsFile } from './text-postings.js'; @@ -239,6 +243,20 @@ export class TextIndex { // Disk-base mode. private pf: PostingsFile | null = null; + /** Integrity record of the postings file the last successful commitBuild + * wrote (bytes + whole-file crc32). Stage 5's generation builder records + * it in the manifest for the generation's copy of the file; the loader + * restores it on attach, so a CLEAN index's fast path can re-publish the + * unchanged file without re-reading it. */ + postingsFileInfo: { bytes: number; crc32: number } | null = null; + + /** The path of the postings file the current base is read from (null for a + * memory base). Stage 5's clean-index fast path hard-links THIS file into + * the next generation instead of re-tokenizing the corpus. */ + get currentPostingsPath(): string | null { + return this.pf?.path ?? null; + } + // LRU cache of decoded base postings: term -> [docID, freq][] private readonly cache = new Map(); @@ -372,7 +390,7 @@ export class TextIndex { * it until the next successful build. abort() discards the staged state * (nothing is written before commit() runs). */ - beginBuild(): TextIndexBuild { + beginBuild(opts: { postingsPath?: string } = {}): TextIndexBuild { if (this.buildQueue !== null) throw new Error('text index build already in progress'); const queue: BuildOp[] = []; this.buildQueue = queue; @@ -412,7 +430,7 @@ export class TextIndex { if (done) throw new Error('text index build already finished'); done = true; try { - await this.commitBuild(queue, agg, newKeys, newKeyToId, newDocLen, n); + await this.commitBuild(queue, agg, newKeys, newKeyToId, newDocLen, n, opts.postingsPath); } catch (e) { // Staging never touched the live view, so the previous index is // intact; the queued ops were already applied to it — just disarm. @@ -431,7 +449,11 @@ export class TextIndex { }; } - /** Swap a fully staged build into the live index (see beginBuild). */ + /** Swap a fully staged build into the live index (see beginBuild). + * `postingsPathOverride` redirects the new postings file away from + * this.path (stage 5: a generation build writes the file INTO the + * generation's tmp directory and the live index attaches to it there; + * this.path stays the rebuild target for non-generation builds). */ private async commitBuild( queue: BuildOp[], agg: Map>, @@ -439,8 +461,10 @@ export class TextIndex { newKeyToId: Map, newDocLen: Map, n: number, + postingsPathOverride?: string, ): Promise { - if (this.path) { + const targetPath = postingsPathOverride ?? this.path; + if (targetPath) { // Disk mode: write the new postings file (tmp + fsync + atomic rename // in PostingsFile.rebuild). The old read handle is closed only at the // rename — and only on Windows, where an open fd would block it (POSIX @@ -450,7 +474,7 @@ export class TextIndex { const oldPf = this.pf; let dict: Map; try { - dict = await PostingsFile.rebuild(this.path, aggToSorted(agg), { + const res = await PostingsFile.rebuild(targetPath, aggToSorted(agg), { beforeRename: process.platform === 'win32' && oldPf !== null ? () => { @@ -459,10 +483,12 @@ export class TextIndex { } : undefined, }); + dict = res.dict; + this.postingsFileInfo = { bytes: res.bytes, crc32: res.crc32 }; } catch (e) { if (oldPf !== null && !oldPf.open) { try { - this.pf = PostingsFile.open(this.path); + this.pf = PostingsFile.open(targetPath); } catch { /* old handle unrecoverable; the next successful build fixes it */ } @@ -474,7 +500,7 @@ export class TextIndex { // is not special-cased: readBase treats a null pf as an empty base, so // reads degrade to delta-only until the next build instead of reading // through a stale dictionary. - const newPf = PostingsFile.open(this.path); + const newPf = PostingsFile.open(targetPath); this.postings.clear(); for (const [t, e] of dict) this.postings.set(t, e); oldPf?.close(); @@ -727,6 +753,98 @@ export class TextIndex { return { hits: top.sorted(), visits, truncated }; } + /** Stage-5 generation build: a synchronous deep-enough snapshot of the live + * state for image serialization. The maps/arrays are copied so later + * mutations of the live index never reach the serialized image. Must run + * while no build is in flight (a committed build's state is what a + * generation serializes). */ + exportImageState(): { + dict: Map; + keys: (string | undefined)[]; + docLens: Map; + liveCount: number; + removed: Set; + delta: Map>; + } { + return { + dict: new Map(this.postings), + keys: [...this.keys], + docLens: new Map(this.docLen), + liveCount: this.N, + removed: new Set(this.removed), + delta: new Map([...this.delta].map(([t, m]) => [t, new Map(m)])), + }; + } + + /** Stage-5 generation load: attach a persisted base + write-buffer state, + * making the index exactly equal to the one the generation sealed — + * dictionary, doc table, tombstones and delta included. Any previous state + * is replaced; a memory-base instance switches to disk-base on the + * generation's postings file (read-only opens attach the same way — the + * file is only ever read). */ + attachImage(args: { + postingsPath: string; + dict: Map; + keys: (string | undefined)[]; + docLens: Map; + liveCount: number; + removed: Set; + delta: Map>; + }): void { + this.close(); // release any previous postings handle + this.memBase = null; + this.postings.clear(); + for (const [t, e] of args.dict) this.postings.set(t, e); + this.pf = PostingsFile.open(args.postingsPath); + this.docLen.clear(); + for (const [id, len] of args.docLens) this.docLen.set(id, len); + this.keys.length = 0; + for (const k of args.keys) this.keys.push(k); + this.keyToId.clear(); + for (let i = 0; i < this.keys.length; i++) { + const k = this.keys[i]; + if (k !== undefined) this.keyToId.set(k, i); + } + this.delta.clear(); + this.deltaDocs.clear(); + this.deltaCount = 0; + for (const [t, m] of args.delta) { + this.delta.set(t, m); + for (const [id] of m) { + this.deltaCount++; + let s = this.deltaDocs.get(id); + if (!s) this.deltaDocs.set(id, (s = new Set())); + s.add(t); + } + } + this.removed.clear(); + for (const id of args.removed) this.removed.add(id); + this.cache.clear(); + this.N = args.liveCount; + } + + /** Stage-5 generation build: after the atomic publish rename, repoint the + * live base handle from the build's tmp directory to the published + * generation directory (same file, final name). On Windows an open handle + * would have blocked the directory rename, so the caller closes before the + * rename and reopens here; POSIX just updates the path (the fd stays valid + * across the rename). A reopen failure degrades reads to delta-only until + * the next build, exactly like commitBuild's reopen failure. */ + repointPostings(newPath: string): void { + if (!this.pf) return; + if (process.platform === 'win32') { + this.pf.close(); + this.pf = null; + try { + this.pf = PostingsFile.open(newPath); + } catch { + /* degrade to delta-only reads; the next successful build fixes it */ + } + return; + } + this.pf.path = newPath; + } + /** Close the underlying postings file. */ close(): void { if (this.pf) { @@ -736,13 +854,14 @@ export class TextIndex { } } -/** Yield `{ term, entries }` with entries sorted by docID ascending (they are - * already in insertion order, which equals ascending docID during build). */ +/** Yield `{ term, entries }` with entries sorted by docID ascending: the agg + * maps' insertion order already IS ascending docID (docIDs increase + * monotonically during a build), so the Map itself is yielded — never a + * per-term spread, which was an OOM vector on million-entry lists. */ function* aggToSorted( agg: Map>, -): Generator<{ term: string; entries: readonly (readonly [number, number])[] }> { +): Generator<{ term: string; entries: ReadonlyMap }> { for (const [term, m] of agg) { - const entries = [...m]; - yield { term, entries }; + yield { term, entries: m }; } } diff --git a/packages/minidb/src/text-postings.ts b/packages/minidb/src/text-postings.ts index 91a21a889ed..25e0947579d 100644 --- a/packages/minidb/src/text-postings.ts +++ b/packages/minidb/src/text-postings.ts @@ -58,12 +58,16 @@ function decodeVarint(buf: Buffer, cur: { i: number }): number { // ---- posting list codec --------------------------------------------------- -/** Encode a sorted (by docID asc) list of [docID, freq] pairs. */ -export function encodePostingList(entries: readonly (readonly [number, number])[]): Buffer { +/** Encode a sorted (by docID asc) list of [docID, freq] pairs. Accepts any + * sized iterable (an array or a Map's entries view) so a large build never + * materializes a per-term copy — a hot term's list can have millions of + * entries and spreading it was an OOM vector. */ +export function encodePostingList(entries: ReadonlyMap | readonly (readonly [number, number])[]): Buffer { + const count = Array.isArray(entries) ? (entries as readonly unknown[]).length : (entries as ReadonlyMap).size; const bytes: number[] = []; - encodeVarintInto(entries.length, bytes); + encodeVarintInto(count, bytes); let prev = 0; - for (const [docID, freq] of entries) { + for (const [docID, freq] of entries as Iterable) { encodeVarintInto(docID - prev, bytes); encodeVarintInto(freq, bytes); prev = docID; @@ -163,7 +167,15 @@ export interface PostingEntry { export class PostingsFile { private fd: number | null = null; - private constructor(readonly path: string) {} + /** The path this handle reads. Mutable for exactly one caller: stage 5's + * generation builder repoints a freshly committed base from the build's + * tmp directory to the published generation directory after the atomic + * rename (same file, new name — POSIX keeps the fd valid throughout). */ + path: string; + + private constructor(filePath: string) { + this.path = filePath; + } /** * Open an existing postings file for positioned reads. Throws if the file is @@ -207,9 +219,11 @@ export class PostingsFile { /** * Build a fresh postings file from an iterator of `{ term, entries }` * (entries must be sorted by docID asc). Writes to `.tmp`, fsyncs, and - * atomically renames over ``. Returns the new term dictionary. The old - * file (if any) is replaced only after the new one is fully durable, so a - * crash mid-build leaves the previous file intact. + * atomically renames over ``. Returns the new term dictionary plus the + * file's byte length and whole-file crc32 (stage 5's generation manifest + * records them; the crc streams along with the write batches, so it costs no + * extra read). The old file (if any) is replaced only after the new one is + * fully durable, so a crash mid-build leaves the previous file intact. * * Async so a large rebuild does not starve the event loop: record writes are * coalesced into ~1 MiB writev batches (each batch await is a yield point). @@ -219,12 +233,13 @@ export class PostingsFile { */ static async rebuild( filePath: string, - iter: Iterable<{ term: string; entries: readonly (readonly [number, number])[] }>, + iter: Iterable<{ term: string; entries: ReadonlyMap | readonly (readonly [number, number])[] }>, hooks: { beforeRename?: () => void } = {}, - ): Promise> { + ): Promise<{ dict: Map; bytes: number; crc32: number }> { const tmp = filePath + '.tmp'; const dict = new Map(); let off = 0; + let crc = 0; let batch: Buffer[] = []; let batchBytes = 0; const fh = await fsp.open(tmp, 'w'); @@ -233,6 +248,7 @@ export class PostingsFile { const buf = Buffer.concat(batch); batch = []; batchBytes = 0; + crc = crc32(buf, crc); let written = 0; while (written < buf.length) { const { bytesWritten } = await fh.write(buf, written); @@ -242,10 +258,11 @@ export class PostingsFile { }; try { for (const { term, entries } of iter) { - if (entries.length === 0) continue; + const count = Array.isArray(entries) ? (entries as readonly unknown[]).length : (entries as ReadonlyMap).size; + if (count === 0) continue; const payload = encodePostingList(entries); - const rec = encodeRecord(term, entries.length, payload); - dict.set(term, { off, len: rec.length, df: entries.length }); + const rec = encodeRecord(term, count, payload); + dict.set(term, { off, len: rec.length, df: count }); batch.push(rec); batchBytes += rec.length; off += rec.length; @@ -269,6 +286,6 @@ export class PostingsFile { } catch { /* some platforms disallow fsync on a directory */ } - return dict; + return { dict, bytes: off, crc32: crc >>> 0 }; } } diff --git a/packages/minidb/src/wal.ts b/packages/minidb/src/wal.ts index f3dbecf0b42..a9ade9f5f09 100644 --- a/packages/minidb/src/wal.ts +++ b/packages/minidb/src/wal.ts @@ -317,6 +317,15 @@ export class WAL { return this.poisoned; } + /** The logical next append offset, including queued-but-unflushed frames: + * every frame already accepted sits strictly below it, and any later frame + * starts at/above it. Stage 5's generation build seals its checkpoint at + * this watermark (every op applied so far has its frame below it, because + * a commit body appends before it applies, in the same tick). */ + get appendOffset(): number { + return this.nextOffset; + } + /** Clear the poison after the owner truncated the file to failedAtOffset * and re-synced size bookkeeping via refreshSize(): the write path resumes. */ clearPoison(): void { diff --git a/packages/minidb/test/compaction-fault.test.ts b/packages/minidb/test/compaction-fault.test.ts index c38b78e23eb..52cdc50d5e2 100644 --- a/packages/minidb/test/compaction-fault.test.ts +++ b/packages/minidb/test/compaction-fault.test.ts @@ -246,7 +246,7 @@ test('rotation: a WAL close() failure leaves the db writable and compact() retri const { MiniDb } = await import('../src/index.js'); const dir = await tmpDir(); try { - let db = await MiniDb.open({ dir, valueCodec: 'string', fsyncPolicy: 'no', compactThresholdBytes: 1 << 30 }); + let db = await MiniDb.open({ dir, valueCodec: 'string', fsyncPolicy: 'no', compactThresholdBytes: 1 << 30, indexGenerations: false }); const N = 200; for (let i = 0; i < N; i++) await db.set(`k${i}`, `v${i}`); @@ -309,7 +309,7 @@ test('rotation: a WAL rename failure (new snapshot already in place) leaves the const { MiniDb } = await import('../src/index.js'); const dir = await tmpDir(); try { - let db = await MiniDb.open({ dir, valueCodec: 'string', fsyncPolicy: 'no', compactThresholdBytes: 1 << 30 }); + let db = await MiniDb.open({ dir, valueCodec: 'string', fsyncPolicy: 'no', compactThresholdBytes: 1 << 30, indexGenerations: false }); const N = 200; for (let i = 0; i < N; i++) await db.set(`k${i}`, `v${i}`); @@ -407,7 +407,7 @@ test('rotation: the first directory fsync failure aborts the rotation; rollback mockFsWithDirSyncFault(dir, new Set([1])); const { MiniDb } = await import('../src/index.js'); try { - let db = await MiniDb.open({ dir, valueCodec: 'string', fsyncPolicy: 'no', compactThresholdBytes: 1 << 30 }); + let db = await MiniDb.open({ dir, valueCodec: 'string', fsyncPolicy: 'no', compactThresholdBytes: 1 << 30, indexGenerations: false }); const N = 200; for (let i = 0; i < N; i++) await db.set(`k${i}`, `v${i}`); @@ -456,7 +456,7 @@ test('rotation: the second directory fsync failure aborts after both renames; ro mockFsWithDirSyncFault(dir, new Set([2])); const { MiniDb } = await import('../src/index.js'); try { - let db = await MiniDb.open({ dir, valueCodec: 'string', fsyncPolicy: 'no', compactThresholdBytes: 1 << 30 }); + let db = await MiniDb.open({ dir, valueCodec: 'string', fsyncPolicy: 'no', compactThresholdBytes: 1 << 30, indexGenerations: false }); const N = 200; for (let i = 0; i < N; i++) await db.set(`k${i}`, `v${i}`); @@ -562,7 +562,7 @@ test('a WAL poison during the snapshot phase aborts this compaction; the next co const { MiniDb } = await import('../src/index.js'); const dir = await tmpDir(); try { - let db = await MiniDb.open({ dir, valueCodec: 'string', fsyncPolicy: 'no', compactThresholdBytes: 1 << 30 }); + let db = await MiniDb.open({ dir, valueCodec: 'string', fsyncPolicy: 'no', compactThresholdBytes: 1 << 30, indexGenerations: false }); const N = 50; for (let i = 0; i < N; i++) await db.set(`k${i}`, `v${i}`); diff --git a/packages/minidb/test/e2e/recovery-matrix.test.ts b/packages/minidb/test/e2e/recovery-matrix.test.ts index 62965b75a2e..7f09d48a88a 100644 --- a/packages/minidb/test/e2e/recovery-matrix.test.ts +++ b/packages/minidb/test/e2e/recovery-matrix.test.ts @@ -15,7 +15,9 @@ import { tmpDir, rmrf } from './helpers/tmp.js'; const FRAME = HEADER_SIZE + 2 + 2 + 0 + CRC_SIZE; async function writeTen(dir) { - const db = await MiniDb.open({ dir, valueCodec: 'string', fsyncPolicy: 'always', autoCompact: false }); + // Legacy recovery semantics (indexGenerations: false): a published + // generation's checkpoint would absorb the very frames these tests corrupt. + const db = await MiniDb.open({ dir, valueCodec: 'string', fsyncPolicy: 'always', autoCompact: false, indexGenerations: false }); for (let i = 0; i < 10; i++) await db.set('k' + i, 'v' + i); await db.close(); } @@ -36,7 +38,7 @@ for (const mode of ['resync', 'strict']) { try { await writeTen(dir); await corruptWalFrame(dir, idx); - const db = await MiniDb.open({ dir, valueCodec: 'string', recovery: mode }); + const db = await MiniDb.open({ dir, valueCodec: 'string', recovery: mode, indexGenerations: false }); const present = new Set(Array.from({ length: 10 }, (_, i) => 'k' + i).filter((k) => db.get(k) !== undefined)); if (where === 'tail') { @@ -68,7 +70,7 @@ test('recovery-matrix: clean WAL recovers everything (both modes)', async () => const dir = await tmpDir(); try { await writeTen(dir); - const db = await MiniDb.open({ dir, valueCodec: 'string', recovery: mode }); + const db = await MiniDb.open({ dir, valueCodec: 'string', recovery: mode, indexGenerations: false }); assert.equal(db.size, 10); assert.equal(db.recoveryInfo.lostBytes, 0); await db.close(); diff --git a/packages/minidb/test/generation.test.ts b/packages/minidb/test/generation.test.ts new file mode 100644 index 00000000000..679e29deb6f --- /dev/null +++ b/packages/minidb/test/generation.test.ts @@ -0,0 +1,841 @@ +// test/generation.test.ts +// +// Persistent index generations (stage 5): unit tests for the codecs and the +// skiplist bulk loader, integration tests for the build → publish → load +// cycle, and the crash/corruption fault matrix. The fault-injection tests +// patch the shared node:fs/promises default export (all source modules +// consume it through a default import, so the patch lands everywhere) with +// path-predicated wrappers, plus the barrier facility from helpers.ts for +// deterministic mid-build crash points. + +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import fsSync from 'node:fs'; +import path from 'node:path'; +import { afterEach, describe, expect, test } from 'vitest'; +import { MiniDb } from '../src/index.js'; +import { SkipList, cmpNumber, cmpString } from '../src/skiplist.js'; +import { + GenerationCorruptError, + parseGenerationBuffer, + readStoreImage, + readDtIndexImage, + readSecondaryIndexImage, + readCompoundIndexImage, + readTextDocsImage, + writeStoreImage, + writeDtIndexImage, + writeSecondaryIndexImage, + writeCompoundIndexImage, + writeTextDocsImage, +} from '../src/gen-codec.js'; +import type { StoreImageRecord } from '../src/gen-codec.js'; +import { readManifest, listGenerations } from '../src/generation-files.js'; +import { tmpDir, rmrf, waitFor, deferred } from './helpers.js'; + +const cleanups: (() => Promise | void)[] = []; +afterEach(async () => { + while (cleanups.length) await cleanups.pop()!(); +}); + +async function openTmp(name: string): Promise { + const dir = await tmpDir(`minidb-gen-${name}-`); + cleanups.push(() => rmrf(dir)); + return dir; +} + +type AnyDb = MiniDb; + +async function closeAll(...dbs: (AnyDb | undefined)[]): Promise { + for (const db of dbs) await db?.close().catch(() => {}); +} + +/** Patch fs.open so handles for paths matching `pred` get their writev/sync + * replaced by `fail` (once). Returns a restore function. */ +function failFileWrites(pred: (p: string) => boolean, fail: 'writev' | 'sync', error: unknown): () => void { + const original = fs.open; + let fired = false; + (fs as unknown as Record).open = async (p: fsSync.PathLike, flags?: string) => { + const handle = await original(p as string, flags as string); + const s = String(p); + if (!fired && pred(s)) { + fired = true; + (handle as unknown as Record)[fail] = () => Promise.reject(error); + } + return handle; + }; + return () => { + (fs as unknown as Record).open = original; + }; +} + +/** Patch fs.rename, failing calls whose (src,dst) pair matches. */ +function failRenames(pred: (src: string, dst: string) => boolean, error: unknown): () => void { + const original = fs.rename; + (fs as unknown as Record).rename = (src: string, dst: string) => + pred(String(src), String(dst)) ? Promise.reject(error) : original(src, dst); + return () => { + (fs as unknown as Record).rename = original; + }; +} + +/** Populate a db with one of every index family and a body of docs. */ +async function seedIndexedDb(db: MiniDb>, n = 2000): Promise { + await db.createIndex('byKind', { field: 'kind' }); + await db.createIndex('byScore', { field: 'score', type: 'range' }); + await db.createCompoundIndex('byKindTime', { groupBy: 'kind', orderBy: 'ts' }); + await db.createTextIndex('ft', { fields: ['text'] }); + await db.createTextIndex('tri', { fields: ['text'], tokenizer: 'ngram' }); + for (let i = 0; i < n; i++) { + await db.set( + `k${i}`, + { kind: `t${i % 7}`, score: i % 100, ts: 1700000000000 + i, text: `hello world doc ${i} 持久化 索引 ${i % 13}` }, + { dt: { ts: 1700000000000 + i } }, + ); + } +} + +/** The exact assertions a correctly-loaded seeded db must satisfy. `size` + * defaults to n but callers that added/removed keys after seeding pass the + * expected live count explicitly; `dtTail` likewise covers the dt-window + * assertion when extra keys carry newer dt values. */ +function assertSeededDb(db: MiniDb>, n = 2000, size = n, dtTail = 10, k0Present = true): void { + const eqT3 = [...Array(n)].filter((_, i) => i % 7 === 3).length; + expect(db.size).toBe(size); + if (k0Present) expect(db.get('k0')).toMatchObject({ kind: 't0', score: 0 }); + expect(db.get(`k${n - 1}`)).toMatchObject({ kind: `t${(n - 1) % 7}` }); + expect(db.findEq('byKind', 't3').length).toBe(eqT3); + expect(db.findRange('byScore', { min: 42, max: 42 }).length).toBe(Math.floor(n / 100) + (n % 100 > 42 ? 1 : 0)); + expect(db.compoundRange('byKindTime', 't2', { limit: 5 }).length).toBe(5); + expect(db.dtRange('ts', { gte: 1700000000000 + n - 10 }).length).toBe(dtTail); + expect(db.search('ft', '持久化').length).toBeGreaterThan(0); + expect(db.search('tri', 'hello world').length).toBeGreaterThan(0); +} + +// ---- unit: skiplist bulk load ---------------------------------------------- + +describe('skiplist bulkLoad', () => { + test('matches one-by-one inserts on content, rank, and range behavior', () => { + const entries: { key: number; val: string }[] = []; + for (let i = 0; i < 5000; i++) entries.push({ key: i * 2, val: `v${i}` }); + const bulk = SkipList.bulkLoad(entries, { compareKey: cmpNumber, compareVal: cmpString }); + const inc = new SkipList({ compareKey: cmpNumber, compareVal: cmpString }); + for (const e of entries) inc.insert(e.key, e.val); + expect(bulk.length).toBe(inc.length); + expect(bulk.toArray()).toEqual(inc.toArray()); + for (const probe of [0, 1, 42, 2499, 4999]) { + const e = entries[probe]!; + expect(bulk.getRank(e.key, e.val)).toBe(inc.getRank(e.key, e.val)); + } + expect(bulk.getByRank(0)).toEqual(inc.getByRank(0)); + expect(bulk.getByRank(4999)).toEqual(inc.getByRank(4999)); + expect(bulk.range({ gte: 100, lte: 200, count: 7 })).toEqual(inc.range({ gte: 100, lte: 200, count: 7 })); + expect([...bulk.iterate({ reverse: true, count: 5 })]).toEqual([...inc.iterate({ reverse: true, count: 5 })]); + // Mutations after the bulk load keep every invariant. + bulk.insert(3, 'v-new'); + bulk.delete(100, 'v50'); + inc.insert(3, 'v-new'); + inc.delete(100, 'v50'); + expect(bulk.toArray()).toEqual(inc.toArray()); + expect(bulk.getRank(3, 'v-new')).toBe(inc.getRank(3, 'v-new')); + expect(bulk.getByRank(1234)).toEqual(inc.getByRank(1234)); + }); + + test('dedupes exact (key, val) duplicates like insert would', () => { + const bulk = SkipList.bulkLoad( + [ + { key: 1, val: 'a' }, + { key: 1, val: 'a' }, + { key: 1, val: 'b' }, + ], + { compareKey: cmpNumber, compareVal: cmpString }, + ); + expect(bulk.length).toBe(2); + expect(bulk.toArray()).toEqual([ + { key: 1, val: 'a' }, + { key: 1, val: 'b' }, + ]); + }); +}); + +// ---- unit: gen-codec round-trips -------------------------------------------- + +describe('gen-codec', () => { + test('store image round-trips memory + disk refs, expiry and dt metadata', async () => { + const dir = await openTmp('codec-store'); + // Keys are canonical (binary) strings: each char code is one byte of the + // key's utf8 encoding — the form the store and every index uses. + const kstr = (s: string): string => Buffer.from(s, 'utf8').toString('binary'); + const records: StoreImageRecord[] = [ + { kstr: 'a', ref: { kind: 'memory', value: Buffer.from('v1') }, expireAt: 0, dt: null }, + { kstr: 'b', ref: { kind: 'disk', loc: { file: 'snapshot', off: 123, len: 45 } }, expireAt: 9999999999999, dt: { ts: 7 } }, + { kstr: 'c', ref: { kind: 'disk', loc: { file: 'wal', off: 9999999999, len: 1 } }, expireAt: 0, dt: null }, + { kstr: kstr('é-key-键'), ref: { kind: 'memory', value: Buffer.from('utf8 value ✓') }, expireAt: 0, dt: null }, + ]; + const info = await writeStoreImage(path.join(dir, 'store'), records); + const buf = await fs.readFile(path.join(dir, 'store')); + expect(info.bytes).toBe(buf.length); + const parsed = parseGenerationBuffer(buf, 'MDGS', 4); + expect(parsed.bytes).toBe(info.bytes); + expect(parsed.crc32).toBe(info.crc32); + const out = [...readStoreImage(parsed.payload)]; + expect(out.map((r) => r.kstr)).toEqual(records.map((r) => r.kstr)); + expect(out[0]!.ref).toEqual(records[0]!.ref); + expect(out[1]!.ref).toEqual(records[1]!.ref); + expect(out[1]!.expireAt).toBe(records[1]!.expireAt); + expect(out[1]!.dt).toEqual({ ts: 7 }); + expect(out[2]!.ref).toEqual(records[2]!.ref); + expect((out[3]!.ref as { value: Buffer }).value.toString('utf8')).toBe('utf8 value ✓'); + expect(Buffer.from(out[3]!.kstr, 'binary').toString('utf8')).toBe('é-key-键'); + }); + + test('a single-byte flip anywhere makes the crc check fail', async () => { + const dir = await openTmp('codec-corrupt'); + await writeStoreImage(path.join(dir, 'store'), [ + { kstr: 'a', ref: { kind: 'memory', value: Buffer.from('v1') }, expireAt: 0, dt: null }, + ]); + const buf = await fs.readFile(path.join(dir, 'store')); + for (const pos of [0, 5, buf.length - 5]) { + const bad = Buffer.from(buf); + bad[pos] = bad[pos]! ^ 0xff; + expect(() => parseGenerationBuffer(bad, 'MDGS', 4)).toThrow(GenerationCorruptError); + } + // Wrong magic / unsupported version are structured errors too. + expect(() => parseGenerationBuffer(buf, 'XXXX', 1)).toThrow(GenerationCorruptError); + expect(() => parseGenerationBuffer(buf, 'MDGS', 99)).toThrow(GenerationCorruptError); + }); + + test('dt / secondary / compound / text-docs images round-trip', async () => { + const dir = await openTmp('codec-indexes'); + await writeDtIndexImage(path.join(dir, 'dt'), [ + { name: 'ts', entries: [{ ms: 1, key: 'a' }, { ms: 2, key: 'b' }, { ms: 2, key: 'c' }] }, + { name: 'created', entries: [{ ms: 9, key: 'z' }] }, + ]); + const dt = readDtIndexImage(parseGenerationBuffer(await fs.readFile(path.join(dir, 'dt')), 'MDGD', 1).payload); + expect(dt).toEqual([ + { name: 'ts', entries: [{ ms: 1, key: 'a' }, { ms: 2, key: 'b' }, { ms: 2, key: 'c' }] }, + { name: 'created', entries: [{ ms: 9, key: 'z' }] }, + ]); + + await writeSecondaryIndexImage(path.join(dir, 'sec'), [ + { + name: 'byKind', + field: 'kind', + type: 'equality', + unique: true, + sparse: false, + equality: [{ scalarKey: 'string:t1', pks: ['a', 'b'] }], + range: null, + }, + { + name: 'byScore', + field: 'score', + type: 'range', + unique: false, + sparse: true, + equality: null, + range: [{ value: 1.5, pk: 'a' }, { value: 2, pk: 'b' }], + }, + ]); + const sec = readSecondaryIndexImage(parseGenerationBuffer(await fs.readFile(path.join(dir, 'sec')), 'MDSI', 1).payload); + expect(sec[0]).toMatchObject({ name: 'byKind', unique: true, sparse: false, equality: [{ scalarKey: 'string:t1', pks: ['a', 'b'] }] }); + expect(sec[1]).toMatchObject({ name: 'byScore', type: 'range', range: [{ value: 1.5, pk: 'a' }, { value: 2, pk: 'b' }] }); + + await writeCompoundIndexImage(path.join(dir, 'cmp'), [ + { + name: 'g', + groupBy: 'kind', + orderBy: 'ts', + orderType: 'number', + groups: [ + { group: 't1', entries: [{ order: 5, pk: 'a' }, { order: 6, pk: 'b' }] }, + { group: 42, entries: [{ order: 1, pk: 'z' }] }, + { group: null, entries: [] }, + { group: true, entries: [{ order: 2, pk: 'y' }] }, + ], + }, + { + name: 's', + groupBy: 'kind', + orderBy: 'name', + orderType: 'string', + groups: [{ group: 't1', entries: [{ order: 'abc', pk: 'a' }] }], + }, + ]); + const cmp = readCompoundIndexImage(parseGenerationBuffer(await fs.readFile(path.join(dir, 'cmp')), 'MDCI', 1).payload); + expect(cmp[0]!.groups[0]).toEqual({ group: 't1', entries: [{ order: 5, pk: 'a' }, { order: 6, pk: 'b' }] }); + expect(cmp[0]!.groups[1]).toEqual({ group: 42, entries: [{ order: 1, pk: 'z' }] }); + expect(cmp[0]!.groups[2]).toEqual({ group: null, entries: [] }); + expect(cmp[0]!.groups[3]).toEqual({ group: true, entries: [{ order: 2, pk: 'y' }] }); + expect(cmp[1]!.orderType).toBe('string'); + + await writeTextDocsImage(path.join(dir, 'docs'), { + keys: ['a', undefined, 'c'], + docLens: [10, undefined, 3], + liveCount: 2, + removed: [1], + delta: [{ term: 'hello', docs: [{ docID: 2, freq: 4 }] }], + }); + const docs = readTextDocsImage(parseGenerationBuffer(await fs.readFile(path.join(dir, 'docs')), 'MDTC', 1).payload); + expect(docs.keys).toEqual(['a', undefined, 'c']); + expect(docs.docLens).toEqual([10, undefined, 3]); + expect(docs.liveCount).toBe(2); + expect(docs.removed).toEqual([1]); + expect(docs.delta).toEqual([{ term: 'hello', docs: [{ docID: 2, freq: 4 }] }]); + }); +}); + +// ---- integration: build / publish / load ------------------------------------ + +describe('generation build + load', () => { + test('explicit build publishes; reopen loads the generation with zero rebuilds', async () => { + const dir = await openTmp('basic'); + let db = await MiniDb.open>({ dir, valueCodec: 'json' }); + await seedIndexedDb(db, 2000); + await db.rebuildGeneration(); + const gen = db.getIndexGeneration(); + expect(gen).not.toBeNull(); + expect(db.stats.generationBuilds).toBeGreaterThanOrEqual(1); + expect(db.stats.generationBuildErrors).toBe(0); + expect(fsSync.existsSync(path.join(dir, 'CURRENT'))).toBe(true); + expect(fsSync.existsSync(path.join(dir, 'generations', gen!.id, 'manifest.json'))).toBe(true); + // WAL delta after the checkpoint. + for (let i = 2000; i < 2100; i++) { + await db.set(`k${i}`, { kind: 't1', score: 1, ts: 1700000000000 + i, text: `delta ${i} 增量` }, { dt: { ts: 1700000000000 + i } }); + } + await db.del('k0'); + await db.close(); + + db = await MiniDb.open>({ dir, valueCodec: 'json' }); + expect(db.stats.generationLoads).toBe(1); + expect(db.stats.generationIndexRebuilds).toBe(0); + expect(db.stats.indexRebuildDecoded).toBe(0); // proves: no corpus re-decode + expect(db.stats.textRebuildDurationMs).toBe(0); // proves: no tokenization + expect(db.getIndexGeneration()?.id).toBe(gen!.id); + expect(db.recoveryInfo?.indexGeneration?.id).toBe(gen!.id); + assertSeededDb(db, 2000, 2099, 110, false); + // The WAL delta landed too. + expect(db.get('k0')).toBeUndefined(); + expect(db.get('k2099')).toMatchObject({ kind: 't1' }); + expect(db.search('ft', '增量').length).toBe(50); + await db.close(); + }); + + test('disk valueMode round-trips through a generation', async () => { + const dir = await openTmp('disk-mode'); + let db = await MiniDb.open>({ dir, valueCodec: 'json', valueMode: 'disk' }); + await seedIndexedDb(db, 500); + await db.rebuildGeneration(); + for (let i = 500; i < 550; i++) { + await db.set(`k${i}`, { kind: 't1', score: 2, ts: 1700000000000 + i, text: `delta disk ${i}` }, { dt: { ts: 1700000000000 + i } }); + } + await db.close(); + db = await MiniDb.open>({ dir, valueCodec: 'json', valueMode: 'disk' }); + expect(db.stats.generationLoads).toBe(1); + assertSeededDb(db, 500, 550, 60); + expect(db.size).toBe(550); + expect(db.get('k42')).toMatchObject({ score: 42 }); + expect(db.get('k549')).toMatchObject({ kind: 't1' }); + expect(db.search('ft', 'hello').length).toBe(50); + await db.close(); + }); + + test('legacy database (generations disabled) gets a background first generation on open', async () => { + const dir = await openTmp('legacy-first'); + let db = await MiniDb.open>({ dir, valueCodec: 'json', indexGenerations: false }); + await seedIndexedDb(db, 800); + await db.close(); + expect(fsSync.existsSync(path.join(dir, 'CURRENT'))).toBe(false); + + // First open with generations enabled: legacy recovery serves it, then a + // background build publishes the first generation. + db = await MiniDb.open>({ dir, valueCodec: 'json' }); + expect(db.stats.generationLoads).toBe(0); + assertSeededDb(db, 800); + await waitFor(() => fsSync.existsSync(path.join(dir, 'CURRENT')), 'background first generation'); + await waitFor(() => db.stats.generationBuilds >= 1, 'generationBuilds stat'); + await db.close(); + + db = await MiniDb.open>({ dir, valueCodec: 'json' }); + expect(db.stats.generationLoads).toBe(1); + expect(db.stats.indexRebuildDecoded).toBe(0); + assertSeededDb(db, 800); + await db.close(); + }); + + test('compaction publishes the generation transactionally (no sync postings tail)', async () => { + const dir = await openTmp('compact-publish'); + let db = await MiniDb.open>({ dir, valueCodec: 'json', compactThresholdBytes: 1 << 30 }); + await seedIndexedDb(db, 1000); + await db.close(); + db = await MiniDb.open>({ dir, valueCodec: 'json' }); + const before = db.getIndexGeneration(); + await db.compact(); + const after = db.getIndexGeneration(); + expect(after).not.toBeNull(); + expect(after!.id).not.toBe(before?.id); + // The live text base moved into the generation: legacy root postings files + // are reclaimed. + expect(fsSync.readdirSync(dir).filter((f) => /^db\.text-.*\.postings$/.test(f))).toEqual([]); + assertSeededDb(db, 1000); + await db.close(); + db = await MiniDb.open>({ dir, valueCodec: 'json' }); + expect(db.stats.generationLoads).toBe(1); + expect(db.getIndexGeneration()?.id).toBe(after!.id); + assertSeededDb(db, 1000); + await db.close(); + }); + + test('retention keeps the current and previous generation only', async () => { + const dir = await openTmp('retention'); + const db = await MiniDb.open>({ dir, valueCodec: 'json' }); + await seedIndexedDb(db, 100); + await db.rebuildGeneration(); + await db.rebuildGeneration(); + await db.rebuildGeneration(); + const current = db.getIndexGeneration()!.id; + await waitFor(() => { + try { + return fsSync.readdirSync(path.join(dir, 'generations')).filter((g) => !g.includes('.tmp-')).length <= 2; + } catch { + return false; + } + }, 'retention sweep'); + const left = (await listGenerations(dir)).filter((g) => !g.tmp).map((g) => g.id); + expect(left).toContain(current); + expect(left.length).toBeLessThanOrEqual(2); + await db.close(); + }); + + test('TTL records sealed into a generation expire correctly at load (indexes reconciled)', async () => { + const dir = await openTmp('ttl'); + let db = await MiniDb.open>({ dir, valueCodec: 'json' }); + await db.createIndex('byKind', { field: 'kind' }); + await db.createTextIndex('ft', { fields: ['text'] }); + await db.set('stay', { kind: 'a', text: 'permanent hello' }); + await db.set('gone', { kind: 'a', text: 'ephemeral hello' }, { ttl: 50 }); + await db.rebuildGeneration(); + await new Promise((r) => setTimeout(r, 120)); + await db.close(); + db = await MiniDb.open>({ dir, valueCodec: 'json' }); + expect(db.stats.generationLoads).toBe(1); + expect(db.get('gone')).toBeUndefined(); + expect(db.get('stay')).toBeDefined(); + expect(db.findEq('byKind', 'a').map((r) => r.key)).toEqual(['stay']); + expect(db.search('ft', 'hello').map((h) => h.key)).toEqual(['stay']); + await db.close(); + }); + + test('a definition change rebuilds only the affected index', async () => { + const dir = await openTmp('def-change'); + let db = await MiniDb.open>({ dir, valueCodec: 'json' }); + await seedIndexedDb(db, 1000); + await db.rebuildGeneration(); + await db.dropIndex('byScore'); + await db.createIndex('byScore', { field: 'score', type: 'range' }); // same shape, but drop+create rewrote the sidecar + await db.createIndex('byNew', { field: 'kind' }); + await db.close(); + + db = await MiniDb.open>({ dir, valueCodec: 'json' }); + expect(db.stats.generationLoads).toBe(1); + // byNew has no image (created after the build) — exactly one index rebuilt. + expect(db.stats.generationIndexRebuilds).toBe(1); + expect(db.findEq('byNew', 't3').length).toBe([...Array(1000)].filter((_, i) => i % 7 === 3).length); + expect(db.findRange('byScore', { min: 42, max: 42 }).length).toBe(10); + // Text + dt + compound came from the generation (no corpus re-decode for them). + expect(db.search('ft', '持久化').length).toBeGreaterThan(0); + await db.close(); + }); + + test('clean text index: background build then a second build re-links (no ENOENT)', async () => { + // Regression: the clean fast path hard-links the live base into the new + // generation, and the reclaim step deletes the root file — the publish + // must repoint the live handle into the CURRENT generation or the next + // build's link source is gone. + const dir = await openTmp('clean-relink'); + let db = await MiniDb.open>({ dir, valueCodec: 'json', indexGenerations: false }); + await db.createTextIndex('ft', { fields: ['text'] }); + for (let i = 0; i < 200; i++) await db.set(`k${i}`, { text: `hello world doc ${i}` }); + await db.close(); + + db = await MiniDb.open>({ dir, valueCodec: 'json' }); + await waitFor(() => db.stats.generationBuilds >= 1, 'background first generation'); + expect(db.search('ft', 'hello', { limit: 1000 }).length).toBe(200); + await db.rebuildGeneration(); // must not throw ENOENT + expect(db.stats.generationBuildErrors).toBe(0); + expect(db.search('ft', 'hello', { limit: 1000 }).length).toBe(200); + const gen = db.getIndexGeneration()!.id; + await db.close(); + + db = await MiniDb.open>({ dir, valueCodec: 'json' }); + expect(db.getIndexGeneration()?.id).toBe(gen); + expect(db.search('ft', 'hello', { limit: 1000 }).length).toBe(200); + await db.close(); + }); + + test('clean text index loaded from a generation survives consecutive builds', async () => { + const dir = await openTmp('clean-relink-2'); + let db = await MiniDb.open>({ dir, valueCodec: 'json' }); + await db.createTextIndex('ft', { fields: ['text'] }); + for (let i = 0; i < 100; i++) await db.set(`k${i}`, { text: `hello doc ${i}` }); + await db.rebuildGeneration(); // g1 (dirty -> staged) + await db.close(); + + db = await MiniDb.open>({ dir, valueCodec: 'json' }); + expect(db.stats.generationLoads).toBe(1); + for (let round = 0; round < 3; round++) { + await db.rebuildGeneration(); // clean re-publish each time + // Let the async retention cleanup settle between rounds. + await new Promise((r) => setTimeout(r, 150)); + } + expect(db.stats.generationBuildErrors).toBe(0); + expect(db.search('ft', 'hello').length).toBe(50); + await db.close(); + }); + + test('read-only reader keeps serving through a writer generation switch', async () => { + const dir = await openTmp('reader-switch'); + let writer = await MiniDb.open>({ dir, valueCodec: 'json' }); + await seedIndexedDb(writer, 500); + await writer.rebuildGeneration(); + + const reader = await MiniDb.open>({ dir, valueCodec: 'json', readOnly: true }); + expect(reader.stats.generationLoads).toBe(1); + assertSeededDb(reader, 500); + + // The writer publishes a new generation while the reader holds the old one. + for (let i = 500; i < 600; i++) { + await writer.set(`k${i}`, { kind: 't2', score: 3, ts: 1700000000000 + i, text: `second wave ${i}` }, { dt: { ts: 1700000000000 + i } }); + } + await writer.rebuildGeneration(); + // The reader's open handles keep its generation servable: queries keep + // answering the (consistent) old view without any full-rebuild stall. + assertSeededDb(reader, 500); + expect(reader.get('k250')).toMatchObject({ score: 50 }); + await reader.close(); + + const reader2 = await MiniDb.open>({ dir, valueCodec: 'json', readOnly: true }); + expect(reader2.stats.generationLoads).toBe(1); + expect(reader2.size).toBe(600); + expect(reader2.get('k550')).toMatchObject({ kind: 't2' }); + await closeAll(reader2, writer); + }); +}); + +// ---- fault matrix ------------------------------------------------------------ + +describe('generation fault matrix', () => { + test('unknown manifest format version falls back without deleting anything', async () => { + const dir = await openTmp('unknown-version'); + let db = await MiniDb.open>({ dir, valueCodec: 'json' }); + await seedIndexedDb(db, 300); + await db.rebuildGeneration(); + await db.close(); + // Simulate a newer binary's generations: bump the format version of EVERY + // published manifest, so no candidate can load. + const manifestPaths: string[] = []; + for (const g of await listGenerations(dir)) { + if (g.tmp) continue; + const p = path.join(dir, 'generations', g.id, 'manifest.json'); + const manifest = JSON.parse(await fs.readFile(p, 'utf8')) as { format: number }; + manifest.format = 99; + await fs.writeFile(p, JSON.stringify(manifest), 'utf8'); + manifestPaths.push(p); + } + expect(manifestPaths.length).toBeGreaterThanOrEqual(1); + + db = await MiniDb.open>({ dir, valueCodec: 'json' }); + expect(db.stats.generationLoads).toBe(0); + expect(db.stats.generationLoadFallbacks).toBeGreaterThanOrEqual(1); + expect(db.stats.lastGenerationFallback).toContain('unknown format version'); + assertSeededDb(db, 300); // legacy full recovery served it + // The foreign generations are still on disk, untouched. + for (const p of manifestPaths) { + expect(fsSync.existsSync(p)).toBe(true); + expect(JSON.parse(await fs.readFile(p, 'utf8')).format).toBe(99); + } + await db.close(); + }); + + test('corrupt store image discards the generation, never the snapshot/WAL', async () => { + const dir = await openTmp('corrupt-store'); + let db = await MiniDb.open>({ dir, valueCodec: 'json' }); + await seedIndexedDb(db, 300); + await db.rebuildGeneration(); + await db.close(); + // Corrupt the store image of EVERY candidate generation, so the load path + // has nowhere to go but the legacy full recovery. + for (const g of await listGenerations(dir)) { + if (g.tmp) continue; + const p = path.join(dir, 'generations', g.id, 'store'); + const buf = await fs.readFile(p); + buf[buf.length - 5] = buf[buf.length - 5]! ^ 0xff; // last payload byte before the crc + await fs.writeFile(p, buf); + } + + db = await MiniDb.open>({ dir, valueCodec: 'json' }); + expect(db.stats.generationLoads).toBe(0); + expect(db.stats.generationLoadFallbacks).toBeGreaterThanOrEqual(1); + assertSeededDb(db, 300); + // Authoritative data untouched — and even the corrupt generation is only + // abandoned, never deleted by the load path. + expect(fsSync.existsSync(path.join(dir, 'db.wal'))).toBe(true); + for (const g of await listGenerations(dir)) { + if (!g.tmp) expect(fsSync.existsSync(path.join(dir, 'generations', g.id, 'store'))).toBe(true); + } + await db.close(); + }); + + test.each(['dt.index', 'secondary.index', 'compound.index', 'text-ft.dictionary', 'text-ft.docs', 'text-ft.postings'])( + 'corrupt %s rebuilds only that index at load', + async (file) => { + const dir = await openTmp('corrupt-one'); + let db = await MiniDb.open>({ dir, valueCodec: 'json' }); + await seedIndexedDb(db, 400); + await db.rebuildGeneration(); + const genId = db.getIndexGeneration()!.id; + await db.close(); + const p = path.join(dir, 'generations', genId, file); + const buf = await fs.readFile(p); + buf[Math.floor(buf.length / 2)] = buf[Math.floor(buf.length / 2)]! ^ 0xff; + await fs.writeFile(p, buf); + + db = await MiniDb.open>({ dir, valueCodec: 'json' }); + expect(db.stats.generationLoads).toBe(1); + expect(db.stats.generationIndexRebuilds).toBeGreaterThanOrEqual(1); + assertSeededDb(db, 400); + await db.close(); + }, + ); + + test('interrupted store-image write: no publish, CURRENT keeps the previous generation, next build succeeds', async () => { + const dir = await openTmp('interrupt-write'); + const db = await MiniDb.open>({ dir, valueCodec: 'json' }); + await seedIndexedDb(db, 300); + await db.rebuildGeneration(); + const first = db.getIndexGeneration()!.id; + const restore = failFileWrites((p) => p.includes('.tmp-') && p.endsWith('store'), 'writev', new Error('injected write failure')); + await expect(db.rebuildGeneration()).rejects.toThrow('injected write failure'); + restore(); + expect(db.stats.generationBuildErrors).toBeGreaterThanOrEqual(1); + // CURRENT never moved off the last complete generation. + expect((await fs.readFile(path.join(dir, 'CURRENT'), 'utf8')).trim()).toBe(first); + // The db itself is fully healthy. + await db.set('after', { kind: 't1', score: 1, ts: 1, text: 'post failure' }); + expect(db.get('after')).toBeDefined(); + await db.rebuildGeneration(); + expect(db.getIndexGeneration()!.id).not.toBe(first); + await db.close(); + const db2 = await MiniDb.open>({ dir, valueCodec: 'json' }); + expect(db2.stats.generationLoads).toBe(1); + expect((await listGenerations(dir)).filter((g) => g.tmp)).toEqual([]); + assertSeededDb(db2, 300, 301); + expect(db2.get('after')).toBeDefined(); + await db2.close(); + }); + + test.each(['store', 'dt.index', 'secondary.index', 'compound.index', 'text-ft.dictionary', 'text-ft.docs'])( + 'interrupted %s write keeps the previous generation and all data', + async (file) => { + const dir = await openTmp('interrupt-each'); + const db = await MiniDb.open>({ dir, valueCodec: 'json' }); + await seedIndexedDb(db, 200); + await db.rebuildGeneration(); // g-1 published + const first = db.getIndexGeneration()!.id; + for (let i = 200; i < 260; i++) { + await db.set(`k${i}`, { kind: 't2', score: 5, ts: 1700000000000 + i, text: `more ${i}` }, { dt: { ts: 1700000000000 + i } }); + } + const restore = failFileWrites((p) => p.includes('.tmp-') && p.endsWith(file), 'writev', new Error(`injected ${file} failure`)); + await expect(db.rebuildGeneration()).rejects.toThrow(`injected ${file} failure`); + restore(); + // CURRENT still points at g-1; data is complete either way. + expect(db.getIndexGeneration()!.id).toBe(first); + await db.close(); + const db2 = await MiniDb.open>({ dir, valueCodec: 'json' }); + expect(db2.getIndexGeneration()?.id).toBe(first); + expect(db2.size).toBe(260); + expect(db2.get('k255')).toMatchObject({ kind: 't2' }); + await db2.close(); + }, + ); + + test('file fsync failure aborts the build without touching CURRENT', async () => { + const dir = await openTmp('fsync-fail'); + const db = await MiniDb.open>({ dir, valueCodec: 'json' }); + await seedIndexedDb(db, 200); + await db.rebuildGeneration(); + const first = db.getIndexGeneration()!.id; + const restore = failFileWrites((p) => p.includes('.tmp-') && p.endsWith('store'), 'sync', new Error('injected fsync failure')); + await expect(db.rebuildGeneration()).rejects.toThrow('injected fsync failure'); + restore(); + expect(db.stats.generationBuildErrors).toBeGreaterThanOrEqual(1); + expect((await fs.readFile(path.join(dir, 'CURRENT'), 'utf8')).trim()).toBe(first); + await db.rebuildGeneration(); + expect(db.getIndexGeneration()!.id).not.toBe(first); + await db.close(); + }); + + test('crash before the generation dir rename: CURRENT unchanged, tmp swept at next open', async () => { + const dir = await openTmp('crash-pre-rename'); + const db = await MiniDb.open>({ dir, valueCodec: 'json' }); + await seedIndexedDb(db, 200); + await db.rebuildGeneration(); + const first = db.getIndexGeneration()!.id; + const restore = failRenames((src) => src.includes('.tmp-'), new Error('injected crash before rename')); + await expect(db.rebuildGeneration()).rejects.toThrow('injected crash before rename'); + restore(); + expect((await fs.readFile(path.join(dir, 'CURRENT'), 'utf8')).trim()).toBe(first); + await db.close(); + // Next open sweeps the stranded tmp dir and the background build recovers. + const db2 = await MiniDb.open>({ dir, valueCodec: 'json' }); + expect(db2.getIndexGeneration()?.id).toBe(first); + assertSeededDb(db2, 200); + await db2.close(); + expect((await listGenerations(dir)).filter((g) => g.tmp)).toEqual([]); + }); + + test('crash after the dir rename but before CURRENT: old CURRENT wins, stray dir cleaned by the next publish', async () => { + const dir = await openTmp('crash-pre-current'); + const db = await MiniDb.open>({ dir, valueCodec: 'json' }); + await seedIndexedDb(db, 200); + await db.rebuildGeneration(); + const first = db.getIndexGeneration()!.id; + const before = (await listGenerations(dir)).filter((g) => !g.tmp).map((g) => g.id); + const restore = failRenames((src, dst) => src.includes('CURRENT.tmp-') || dst.endsWith('CURRENT'), new Error('injected crash before CURRENT')); + await expect(db.rebuildGeneration()).rejects.toThrow('injected crash before CURRENT'); + restore(); + // CURRENT still names the previous generation; a complete-but-unreferenced + // generation dir lingers next to it. + expect((await fs.readFile(path.join(dir, 'CURRENT'), 'utf8')).trim()).toBe(first); + const gens = (await listGenerations(dir)).filter((g) => !g.tmp).map((g) => g.id); + expect(gens.length).toBe(before.length + 1); + const stray = gens.find((g) => !before.includes(g))!; + expect(stray).toBeDefined(); + expect((await readManifest(dir, stray)).id).toBe(stray); + await db.close(); + const db2 = await MiniDb.open>({ dir, valueCodec: 'json' }); + expect(db2.getIndexGeneration()?.id).toBe(first); + assertSeededDb(db2, 200); + await db2.rebuildGeneration(); + await waitFor(() => { + try { + return fsSync.readdirSync(path.join(dir, 'generations')).filter((g) => !g.includes('.tmp-')).length <= 2; + } catch { + return false; + } + }, 'stray dir cleanup'); + await db2.close(); + }); + + test('crash after CURRENT replacement: the new generation loads', async () => { + const dir = await openTmp('crash-post-current'); + const writer = await MiniDb.open>({ dir, valueCodec: 'json' }); + await seedIndexedDb(writer, 200); + await writer.rebuildGeneration(); + const id = writer.getIndexGeneration()!.id; + // The writer stays OPEN (its lock held) — as after a crash, the files on + // disk must already tell the whole story: a read-only peer loads the + // freshly published generation. + const peer = await MiniDb.open>({ dir, valueCodec: 'json', onLockFail: 'readonly' }); + expect(peer.readOnly).toBe(true); + expect(peer.getIndexGeneration()?.id).toBe(id); + expect(peer.stats.generationLoads).toBe(1); + assertSeededDb(peer, 200); + await closeAll(peer, writer); + }); + + test('old-generation cleanup failure after publish is harmless', async () => { + const dir = await openTmp('cleanup-fail'); + const db = await MiniDb.open>({ dir, valueCodec: 'json' }); + await seedIndexedDb(db, 200); + await db.rebuildGeneration(); + const first = db.getIndexGeneration()!.id; + const original = fs.rm; + (fs as unknown as Record).rm = (p: string, opts?: unknown) => + String(p).includes('generations') && String(p).endsWith(first) + ? Promise.reject(new Error('injected cleanup failure')) + : original(p, opts as Parameters[1]); + cleanups.push(() => { + (fs as unknown as Record).rm = original; + }); + await db.rebuildGeneration(); + const second = db.getIndexGeneration()!.id; + expect(second).not.toBe(first); + await db.close(); + const db2 = await MiniDb.open>({ dir, valueCodec: 'json' }); + expect(db2.getIndexGeneration()?.id).toBe(second); + assertSeededDb(db2, 200); + await db2.close(); + }); + + test('WAL rollback during a build aborts it (expected churn, not an error)', async () => { + const dir = await openTmp('rollback-abort'); + const db = await MiniDb.open>({ dir, valueCodec: 'json' }); + await seedIndexedDb(db, 6000); // big enough that the walk spans several ticks + const build = db.rebuildGeneration(); + // Wait until the build has actually registered its mutation queue (the + // walk is underway), then drive a WAL write failure: the group rollback + // calls restoreGroupKey, which must abort the in-flight build. + await waitFor(() => (db as unknown as { genBuild: unknown }).genBuild !== null, 'generation build registered'); + const origAppend = db.wal.appendLoc.bind(db.wal); + (db.wal as unknown as { appendLoc: unknown }).appendLoc = () => ({ + offset: -1, + batchId: -1, + done: Promise.reject(Object.assign(new Error('injected WAL failure'), { code: 'WAL_POISONED' })), + }); + await expect(db.set('boom', { kind: 't1' })).rejects.toThrow(); + (db.wal as unknown as { appendLoc: unknown }).appendLoc = origAppend; + await build; // aborted builds resolve cleanly + expect(db.stats.generationBuildAborts).toBeGreaterThanOrEqual(1); + expect(db.stats.generationBuildErrors).toBe(0); + // The db is consistent and a fresh build succeeds. + await db.rebuildGeneration(); + expect(db.getIndexGeneration()).not.toBeNull(); + assertSeededDb(db, 6000); + await db.close(); + }); + + test('backup/restore carries the generation tree and safely falls back on inode change', async () => { + const dir = await openTmp('backup-src'); + const destDir = path.join(await openTmp('backup-dst'), 'b'); + const backupDir = path.join(await openTmp('backup-dir'), 'bak'); + const db = await MiniDb.open>({ dir, valueCodec: 'json' }); + await seedIndexedDb(db, 300); + await db.rebuildGeneration(); + await db.backup(backupDir, { compact: false }); + // The backup includes CURRENT and the generations tree. + expect(fsSync.existsSync(path.join(backupDir, 'CURRENT'))).toBe(true); + expect(fsSync.existsSync(path.join(backupDir, 'generations'))).toBe(true); + await db.close(); + + // Restore: the copied files sit on NEW inodes, so the generation's WAL + // anchor cannot validate — the open must fall back (legacy full recovery + // or a safe re-checkpoint), never serve a mismatched image, never lose + // data. + const restored = await MiniDb.restore>(backupDir, destDir, { valueCodec: 'json' }); + assertSeededDb(restored, 300); + await restored.close(); + // A reopened writer re-checkpoints in the background. + const db2 = await MiniDb.open>({ dir: destDir, valueCodec: 'json' }); + assertSeededDb(db2, 300); + await waitFor(() => db2.getIndexGeneration() !== null || db2.stats.generationBuilds >= 1, 'post-restore re-checkpoint'); + await db2.close(); + }); + + test('writer builds while a read-only reader queries (no interference)', async () => { + const dir = await openTmp('reader-during-build'); + const writer = await MiniDb.open>({ dir, valueCodec: 'json' }); + await seedIndexedDb(writer, 1000); + const reader = await MiniDb.open>({ dir, valueCodec: 'json', readOnly: true }); + assertSeededDb(reader, 1000); + const build = writer.rebuildGeneration(); + // The reader keeps answering (its view predates the build) while the build + // walks + publishes. + expect(reader.get('k500')).toMatchObject({ score: 0 }); + expect(reader.search('ft', 'hello').length).toBe(50); + await build; + expect(writer.getIndexGeneration()).not.toBeNull(); + assertSeededDb(reader, 1000); + await closeAll(reader, writer); + }); +}); diff --git a/packages/minidb/test/recovery.test.ts b/packages/minidb/test/recovery.test.ts index 66f94e6eada..023f9e3b9ba 100644 --- a/packages/minidb/test/recovery.test.ts +++ b/packages/minidb/test/recovery.test.ts @@ -247,7 +247,7 @@ function swapWalInodeSync(real: FsSyncModule, walPath: string): void { test('generation pairing: a rotation-like WAL inode swap at the post-scan forensics is retried to a consistent read-only open', async () => { const dir = await tmpDir(); try { - const writer = await MiniDb.open({ dir, valueCodec: 'string', fsyncPolicy: 'no', autoCompact: false }); + const writer = await MiniDb.open({ dir, valueCodec: 'string', fsyncPolicy: 'no', autoCompact: false, indexGenerations: false }); for (let i = 0; i < 50; i++) await writer.set(`k${i}`, `v${i}`); // The injection fires when recover takes its post-scan path stat of @@ -265,7 +265,7 @@ test('generation pairing: a rotation-like WAL inode swap at the post-scan forens }, }); const { MiniDb: MockedMiniDb } = await import('../src/index.js'); - const reader = await MockedMiniDb.open({ dir, valueCodec: 'string', readOnly: true }); + const reader = await MockedMiniDb.open({ dir, valueCodec: 'string', readOnly: true, indexGenerations: false }); assert.equal(swaps, 1); assert.equal(reader.recoveryInfo!.generationRetries, 1, 'the swapped inode forced exactly one retry'); assert.equal(reader.size, 50); @@ -281,7 +281,7 @@ test('generation pairing: a rotation-like WAL inode swap at the post-scan forens test('generation pairing: churn beyond the retry budget throws RECOVERY_GENERATION_CHURN and leaves no partial state', async () => { const dir = await tmpDir(); try { - const writer = await MiniDb.open({ dir, valueCodec: 'string', fsyncPolicy: 'no', autoCompact: false }); + const writer = await MiniDb.open({ dir, valueCodec: 'string', fsyncPolicy: 'no', autoCompact: false, indexGenerations: false }); for (let i = 0; i < 50; i++) await writer.set(`k${i}`, `v${i}`); await writer.close(); @@ -297,7 +297,7 @@ test('generation pairing: churn beyond the retry budget throws RECOVERY_GENERATI }); const { MiniDb: MockedMiniDb } = await import('../src/index.js'); await assert.rejects( - MockedMiniDb.open({ dir, valueCodec: 'string', readOnly: true }), + MockedMiniDb.open({ dir, valueCodec: 'string', readOnly: true, indexGenerations: false }), (e: unknown) => (e as { code?: string }).code === 'RECOVERY_GENERATION_CHURN' && (e as Error).name === 'RecoveryGenerationChurnError', ); @@ -318,7 +318,7 @@ test('generation pairing: churn beyond the retry budget throws RECOVERY_GENERATI test('generation pairing: append-only WAL growth between the forensic rounds does not trigger a retry', async () => { const dir = await tmpDir(); try { - const writer = await MiniDb.open({ dir, valueCodec: 'string', fsyncPolicy: 'no', autoCompact: false }); + const writer = await MiniDb.open({ dir, valueCodec: 'string', fsyncPolicy: 'no', autoCompact: false, indexGenerations: false }); for (let i = 0; i < 50; i++) await writer.set(`k${i}`, `v${i}`); await writer.close(); @@ -334,7 +334,7 @@ test('generation pairing: append-only WAL growth between the forensic rounds doe }, }); const { MiniDb: MockedMiniDb } = await import('../src/index.js'); - const reader = await MockedMiniDb.open({ dir, valueCodec: 'string', readOnly: true }); + const reader = await MockedMiniDb.open({ dir, valueCodec: 'string', readOnly: true, indexGenerations: false }); assert.equal(reader.recoveryInfo!.generationRetries, 0, 'append-only growth must not be retried'); assert.equal(reader.size, 50); // The late frame landed after the scan: outside the recovered view, to be @@ -369,7 +369,7 @@ test('generation pairing (disk mode): a ValueReader attach to the wrong inode re }, }); const { MiniDb: MockedMiniDb } = await import('../src/index.js'); - const reader = await MockedMiniDb.open({ dir, valueCodec: 'string', valueMode: 'disk', readOnly: true }); + const reader = await MockedMiniDb.open({ dir, valueCodec: 'string', valueMode: 'disk', readOnly: true, indexGenerations: false }); assert.equal(swaps, 1); assert.equal(reader.recoveryInfo!.generationRetries, 1, 'the mismatched attach forced exactly one retry'); // Every pointer reads back the right bytes through the correctly-attached @@ -412,7 +412,7 @@ test('generation pairing (disk mode): a failing ValueReader open() closes the pa }; try { await assert.rejects( - MockedMiniDb.open({ dir, valueCodec: 'string', valueMode: 'disk', readOnly: true }), + MockedMiniDb.open({ dir, valueCodec: 'string', valueMode: 'disk', readOnly: true, indexGenerations: false }), (e: unknown) => (e as { code?: string }).code === 'EMFILE', ); } finally { @@ -433,7 +433,7 @@ test('generation pairing (disk mode): a failing ValueReader open() closes the pa test('generation pairing: a stable writer costs a read-only open zero retries', async () => { const dir = await tmpDir(); try { - const writer = await MiniDb.open({ dir, valueCodec: 'string', fsyncPolicy: 'no', autoCompact: false }); + const writer = await MiniDb.open({ dir, valueCodec: 'string', fsyncPolicy: 'no', autoCompact: false, indexGenerations: false }); for (let i = 0; i < 20; i++) await writer.set(`k${i}`, `v${i}`); const reader = await MiniDb.open({ dir, valueCodec: 'string', readOnly: true }); @@ -471,7 +471,7 @@ test('a read-only open racing a compaction rotation always recovers one complete const { MiniDb: MockedMiniDb } = await import('../src/index.js'); const dir = await tmpDir(); try { - const writer = await MockedMiniDb.open({ dir, valueCodec: 'string', fsyncPolicy: 'no', autoCompact: false }); + const writer = await MockedMiniDb.open({ dir, valueCodec: 'string', fsyncPolicy: 'no', autoCompact: false, indexGenerations: false }); const N = 100; for (let i = 0; i < N; i++) await writer.set(`k${i}`, `v${i}`); @@ -481,7 +481,7 @@ test('a read-only open racing a compaction rotation always recovers one complete // The reader opens in the mid-rotation window: new snapshot + old full // WAL is a complete, consistent pairing (the replay is idempotent), and // the stable window costs no retry. - const midReader = await MockedMiniDb.open({ dir, valueCodec: 'string', readOnly: true }); + const midReader = await MockedMiniDb.open({ dir, valueCodec: 'string', readOnly: true, indexGenerations: false }); assert.equal(midReader.size, N); assert.equal(midReader.get('k0'), 'v0'); assert.equal(midReader.get(`k${N - 1}`), `v${N - 1}`); @@ -492,7 +492,7 @@ test('a read-only open racing a compaction rotation always recovers one complete await compactPromise; // After the rotation, a fresh open recovers the new generation, complete. - const postReader = await MockedMiniDb.open({ dir, valueCodec: 'string', readOnly: true }); + const postReader = await MockedMiniDb.open({ dir, valueCodec: 'string', readOnly: true, indexGenerations: false }); assert.equal(postReader.size, N); assert.equal(postReader.get('k0'), 'v0'); assert.equal(postReader.get(`k${N - 1}`), `v${N - 1}`); diff --git a/packages/minidb/test/stats.test.ts b/packages/minidb/test/stats.test.ts index 4c9fab622ba..97fb1415402 100644 --- a/packages/minidb/test/stats.test.ts +++ b/packages/minidb/test/stats.test.ts @@ -152,13 +152,15 @@ test('everysec background sync failure is observable in stats but does not chang test('recovery stats capture scanned bytes, frames and duration at open', async () => { const dir = await tmpDir(); - const db = await MiniDb.open({ dir, valueCodec: 'string', fsyncPolicy: 'no', autoCompact: false }); + // Legacy recovery path (indexGenerations: false): a generation load would + // report the store image + WAL delta instead of the full scan. + const db = await MiniDb.open({ dir, valueCodec: 'string', fsyncPolicy: 'no', autoCompact: false, indexGenerations: false }); const N = 50; for (let i = 0; i < N; i++) await db.set(`k${i}`, `v${i}`); await db.close(); const walBytes = (await fs.stat(path.join(dir, 'db.wal'))).size; - const reopened = await MiniDb.open({ dir, valueCodec: 'string', fsyncPolicy: 'no', autoCompact: false }); + const reopened = await MiniDb.open({ dir, valueCodec: 'string', fsyncPolicy: 'no', autoCompact: false, indexGenerations: false }); try { assert.equal(reopened.stats.recoveryFrames, N, 'one frame per set replayed'); assert.equal(reopened.stats.recoveryBytes, walBytes, 'WAL bytes accounted (no snapshot yet)'); @@ -232,10 +234,10 @@ test('index rebuild stats: values decoded once per record, 0 without value-deriv // No secondary/compound/text index: the open-time rebuild walk must be // metadata-only (dt comes from record metadata, values are never decoded). const dir = await tmpDir(); - let db = await MiniDb.open({ dir, valueCodec: 'json', fsyncPolicy: 'no', autoCompact: false }); + let db = await MiniDb.open({ dir, valueCodec: 'json', fsyncPolicy: 'no', autoCompact: false, indexGenerations: false }); for (let i = 0; i < 20; i++) await db.set(`k${i}`, { n: i }, { dt: { created: i } }); await db.close(); - db = await MiniDb.open({ dir, valueCodec: 'json', fsyncPolicy: 'no', autoCompact: false }); + db = await MiniDb.open({ dir, valueCodec: 'json', fsyncPolicy: 'no', autoCompact: false, indexGenerations: false }); try { assert.equal(db.stats.indexRebuildDecoded, 0, 'no decodes without value-derived indexes'); assert.equal(db.dtRange('created', { gte: 0 }).length, 20, 'dt index rebuilt from metadata alone'); @@ -247,14 +249,14 @@ test('index rebuild stats: values decoded once per record, 0 without value-deriv // With several value-derived indexes: exactly one decode per live record, // fanned out to every staged builder in the shared walk. const dir2 = await tmpDir(); - db = await MiniDb.open({ dir: dir2, valueCodec: 'json', fsyncPolicy: 'no', autoCompact: false }); + db = await MiniDb.open({ dir: dir2, valueCodec: 'json', fsyncPolicy: 'no', autoCompact: false, indexGenerations: false }); await db.createTextIndex('body', { fields: ['body'] }); await db.createTextIndex('title', { fields: ['title'] }); await db.createIndex('byN', { field: 'n' }); await db.createCompoundIndex('byGrpN', { groupBy: 'grp', orderBy: 'n' }); for (let i = 0; i < 20; i++) await db.set(`k${i}`, { n: i, grp: 'g', body: `b${i}`, title: `t${i}` }); await db.close(); - db = await MiniDb.open({ dir: dir2, valueCodec: 'json', fsyncPolicy: 'no', autoCompact: false }); + db = await MiniDb.open({ dir: dir2, valueCodec: 'json', fsyncPolicy: 'no', autoCompact: false, indexGenerations: false }); try { assert.equal(db.stats.indexRebuildDecoded, 20, 'one decode per record fanned out to all builders'); assert.equal(db.search('body', 'b1').length, 1); diff --git a/packages/minidb/test/text-index.test.ts b/packages/minidb/test/text-index.test.ts index 0126b1e11ce..9228acb10f7 100644 --- a/packages/minidb/test/text-index.test.ts +++ b/packages/minidb/test/text-index.test.ts @@ -70,7 +70,7 @@ test('PostingsFile: rebuild + positioned read', async () => { const dir = await tmpDir(); try { const p = path.join(dir, 'x.postings'); - const dict = await PostingsFile.rebuild(p, [ + const { dict } = await PostingsFile.rebuild(p, [ { term: 'hello', entries: [ @@ -104,7 +104,7 @@ test('PostingsFile: rebuild + positioned read', async () => { pf.close(); // rebuild is atomic: a second rebuild replaces the file and dict. - const dict2 = await PostingsFile.rebuild(p, [{ term: 'only', entries: [[7, 1]] }]); + const { dict: dict2 } = await PostingsFile.rebuild(p, [{ term: 'only', entries: [[7, 1]] }]); assert.equal(dict2.size, 1); const pf2 = PostingsFile.open(p); assert.deepEqual(pf2.read(dict2.get('only')!), [[7, 1]]); @@ -118,7 +118,7 @@ test('PostingsFile: corrupt record throws on read', async () => { const dir = await tmpDir(); try { const p = path.join(dir, 'x.postings'); - const dict = await PostingsFile.rebuild(p, [{ term: 'a', entries: [[1, 1]] }]); + const { dict } = await PostingsFile.rebuild(p, [{ term: 'a', entries: [[1, 1]] }]); // flip a byte in the file payload const e = dict.get('a')!; const fd = fssync.openSync(p, 'r+'); @@ -291,30 +291,49 @@ test('MiniDb: text postings written to disk, search survives reopen', async () = } }); -test('MiniDb: compaction rebuilds postings (file reclaimed)', async () => { - const dir = await tmpDir(); - try { - const db = await MiniDb.open({ dir, valueCodec: 'json', autoCompact: false }); - await db.createTextIndex('bio', { fields: ['bio'] }); - for (let i = 0; i < 50; i++) await db.set('k' + i, { bio: 'hello world ' + i }); - const p = path.join(dir, 'db.text-bio.postings'); - assert.ok(fssync.existsSync(p)); - // overwrite everything to create tombstones, then add more (delta grows) - for (let i = 0; i < 50; i++) await db.set('k' + i, { bio: 'goodbye world ' + i }); - for (let i = 50; i < 80; i++) await db.set('k' + i, { bio: 'hello again ' + i }); - - await db.compact(); // should rebuild postings from the live store - - // after compaction the postings reflect the latest values only - assert.equal(db.search('bio', 'hello').length, 30); // k50..k79 - assert.equal(db.search('bio', 'goodbye').length, 50); // k0..k49 - await db.close(); - } finally { - await fs.rm(dir, { recursive: true, force: true }); - } -}); +for (const indexGenerations of [false, true]) { + test(`MiniDb: compaction rebuilds postings (file reclaimed) [indexGenerations: ${indexGenerations}]`, async () => { + const dir = await tmpDir(); + try { + const db = await MiniDb.open({ dir, valueCodec: 'json', autoCompact: false, indexGenerations }); + await db.createTextIndex('bio', { fields: ['bio'] }); + for (let i = 0; i < 50; i++) await db.set('k' + i, { bio: 'hello world ' + i }); + const p = path.join(dir, 'db.text-bio.postings'); + if (indexGenerations) { + // The background generation build may already have re-published the + // base (root file reclaimed) — either location proves persistence. + const inGen = fssync.existsSync(path.join(dir, 'CURRENT')); + assert.ok(fssync.existsSync(p) || inGen, 'postings persisted (root or generation)'); + } else { + assert.ok(fssync.existsSync(p)); + } + // overwrite everything to create tombstones, then add more (delta grows) + for (let i = 0; i < 50; i++) await db.set('k' + i, { bio: 'goodbye world ' + i }); + for (let i = 50; i < 80; i++) await db.set('k' + i, { bio: 'hello again ' + i }); + + await db.compact(); // should rebuild postings from the live store + + // after compaction the postings reflect the latest values only + assert.equal(db.search('bio', 'hello').length, 30); // k50..k79 + assert.equal(db.search('bio', 'goodbye').length, 50); // k0..k49 + if (indexGenerations) { + // The base moved into the published generation: the legacy root file + // is reclaimed and CURRENT's generation carries the fresh postings. + assert.ok(!fssync.existsSync(p), 'root postings reclaimed after generation publish'); + const gen = db.getIndexGeneration()!; + assert.ok(gen, 'compaction published a generation'); + assert.ok(fssync.existsSync(path.join(dir, 'generations', gen.id, 'text-bio.postings')), 'postings live in the generation'); + } else { + assert.ok(fssync.existsSync(p), 'legacy path keeps the root postings file'); + } + await db.close(); + } finally { + await fs.rm(dir, { recursive: true, force: true }); + } + }); +} -test('MiniDb: compaction skips the postings rebuild when the index is clean', async () => { +test('MiniDb: compaction skips the postings rebuild when the index is clean [legacy]', async () => { const dir = await tmpDir(); // Count TextIndex.build calls to prove which compactions rebuilt postings. const orig = TextIndex.prototype.build; @@ -324,7 +343,7 @@ test('MiniDb: compaction skips the postings rebuild when the index is clean', as return orig.apply(this, args); } as typeof orig; try { - const db = await MiniDb.open({ dir, valueCodec: 'json', autoCompact: false }); + const db = await MiniDb.open({ dir, valueCodec: 'json', autoCompact: false, indexGenerations: false }); await db.createTextIndex('bio', { fields: ['bio'] }); // build #1 await db.set('a', { bio: 'hello world' }); await db.compact(); // delta dirty -> rebuild #2 @@ -338,50 +357,77 @@ test('MiniDb: compaction skips the postings rebuild when the index is clean', as } }); -test('MiniDb: writes during a compaction postings rebuild stay consistent', async () => { +test('MiniDb: compaction skips the postings rebuild when the index is clean [generation]', async () => { const dir = await tmpDir(); - // Deterministic barrier instead of the old "3000 docs keep the compaction - // busy long enough" timing inference (review #28): the compaction's - // postings rebuild (TextIndex.build) is parked on a deferred, so the writes - // PROVABLY land while the rebuild is in flight, and each write's promise is - // explicitly settled instead of fire-and-forget. The barrier arms AFTER - // createTextIndex so its call 1 is the compaction rebuild, not the create. - let gate!: ReturnType; + // Same skip, generation style: the clean index is re-published by hard + // link, so PostingsFile.rebuild (the actual postings rewrite) runs only + // for the create and the one DIRTY compaction. + const orig = PostingsFile.rebuild; + let rebuilds = 0; + PostingsFile.rebuild = async function (...args) { + rebuilds++; + return orig.apply(this, args); + } as typeof orig; try { const db = await MiniDb.open({ dir, valueCodec: 'json', autoCompact: false }); - await db.createTextIndex('bio', { fields: ['bio'] }); - for (let i = 0; i < 50; i++) await db.set(`d${i}`, { bio: `hello doc${i}` }); - - gate = barrier(TextIndex.prototype, 'build'); - const compactP = db.compact(); - await gate.entered; // the compaction is provably inside the postings rebuild - const writes = Promise.all([ - db.set('extra', { bio: 'hello extra' }), - db.set('d0', { bio: 'goodbye replaced' }), - db.del('d1'), - ]); - gate.release(); - await writes; - await compactP; - assert.equal(db.stats.compactions, 1); - - assert.equal(db.search('bio', 'hello', { limit: 10_000 }).length, 49); - assert.deepEqual(db.search('bio', 'extra').map((h) => h.key), ['extra']); - assert.deepEqual(db.search('bio', 'goodbye').map((h) => h.key), ['d0']); - assert.deepEqual(db.search('bio', 'doc1').map((h) => h.key), []); + await db.createTextIndex('bio', { fields: ['bio'] }); // rewrite #1 + await db.set('a', { bio: 'hello world' }); + await db.compact(); // delta dirty -> generation build rewrites postings (#2) + await db.compact(); // clean now -> re-published by link, no rewrite + assert.equal(rebuilds, 2); + assert.deepEqual(db.search('bio', 'hello').map((h) => h.key), ['a']); await db.close(); - - // The mid-compaction writes are durable and consistent across a reopen. - const db2 = await MiniDb.open({ dir, valueCodec: 'json' }); - assert.equal(db2.search('bio', 'hello', { limit: 10_000 }).length, 49); - assert.deepEqual(db2.search('bio', 'extra').map((h) => h.key), ['extra']); - await db2.close(); } finally { - gate?.restore(); + PostingsFile.rebuild = orig; await fs.rm(dir, { recursive: true, force: true }); } }); +for (const indexGenerations of [false, true]) { + test(`MiniDb: writes during a compaction postings rebuild stay consistent [indexGenerations: ${indexGenerations}]`, async () => { + const dir = await tmpDir(); + // Deterministic barrier instead of the old "3000 docs keep the compaction + // busy long enough" timing inference (review #28): the compaction's + // derived-state rebuild is parked on a deferred, so the writes PROVABLY + // land while it is in flight, and each write's promise is explicitly + // settled instead of fire-and-forget. The barrier arms AFTER + // createTextIndex so its call 1 is the compaction rebuild, not the + // create. On the legacy path the rebuild is TextIndex.build; with + // generations it is the staged build's PostingsFile.rebuild inside the + // generation publish. + let gate!: ReturnType; + try { + const db = await MiniDb.open({ dir, valueCodec: 'json', autoCompact: false, indexGenerations }); + await db.createTextIndex('bio', { fields: ['bio'] }); + for (let i = 0; i < 50; i++) await db.set(`d${i}`, { bio: `hello doc${i}` }); + + gate = indexGenerations ? barrier(PostingsFile, 'rebuild') : barrier(TextIndex.prototype, 'build'); + const compactP = db.compact(); + await gate.entered; // the compaction is provably inside the postings rebuild + const writes = Promise.all([db.set('extra', { bio: 'hello extra' }), db.set('d0', { bio: 'goodbye replaced' }), db.del('d1')]); + gate.release(); + await writes; + await compactP; + assert.equal(db.stats.compactions, 1); + + assert.equal(db.search('bio', 'hello', { limit: 10_000 }).length, 49); + assert.deepEqual(db.search('bio', 'extra').map((h) => h.key), ['extra']); + assert.deepEqual(db.search('bio', 'goodbye').map((h) => h.key), ['d0']); + assert.deepEqual(db.search('bio', 'doc1').map((h) => h.key), []); + await db.close(); + + // The mid-compaction writes are durable and consistent across a reopen. + const db2 = await MiniDb.open({ dir, valueCodec: 'json' }); + assert.equal(db2.search('bio', 'hello', { limit: 10_000 }).length, 49); + assert.deepEqual(db2.search('bio', 'extra').map((h) => h.key), ['extra']); + await db2.close(); + } finally { + gate?.restore(); + await fs.rm(dir, { recursive: true, force: true }); + } + }); +} + // ---- trigram (n-gram literal tokenizer) ------------------------------------ test('trigram: normalization (case, NFKC, code points)', () => { From e9779f156d165a12a7d7cc1b22f8a78e5be976e1 Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Tue, 4 Aug 2026 18:56:14 +0800 Subject: [PATCH 12/15] feat(minidb): workerize text-index builds and split MiniDb into facets - split the monolithic src/index.ts into facet modules (mini-db, types, value-codec, memory-guard, backup, query-engine, text-registry, wal-group, generation-builder/loader, write-path, read-path, index-admin, lifecycle, stats) and move text-index.ts to text-index/ - run corpus-scale text-index builds off the main thread via the bounded worker engine (src/worker/), exported through the new worker-runtime subpath, with inline fallback for small corpora and rollback switches - defer the open-time fallback text rebuild into a maintenance task; searches on a not-yet-committed base raise TextIndexBuildingError - add the unified maintenance scheduler, bounded async read surface, and a maintenance bench - kap-server search: switch to searchBoundedAsync and serve the building page while the index base rebuilds after fallback recovery - kimi-code: install the SEA-bundled minidb text-build worker at startup, bundle it via the native asset scripts, and add the startup-trace util plus the KIMI_TUI_INPUT_LATENCY debug probe --- .oxlintrc.json | 20 + AGENTS.md | 4 +- apps/kimi-code/.gitignore | 2 +- apps/kimi-code/package.json | 1 + apps/kimi-code/scripts/native/01-bundle.mjs | 6 + apps/kimi-code/scripts/native/assets.mjs | 27 +- .../kimi-code/scripts/native/check-bundle.mjs | 89 +- apps/kimi-code/scripts/native/manifest.mjs | 12 +- apps/kimi-code/scripts/native/smoke.mjs | 21 +- apps/kimi-code/src/cli/run-shell.ts | 5 + apps/kimi-code/src/main.ts | 16 + apps/kimi-code/src/native/minidb-worker.ts | 69 + apps/kimi-code/src/native/native-assets.ts | 243 +- apps/kimi-code/src/native/smoke.ts | 104 +- apps/kimi-code/src/tui/kimi-tui.ts | 12 + apps/kimi-code/src/tui/utils/input-latency.ts | 105 + apps/kimi-code/src/utils/startup-trace.ts | 34 + .../test/native/native-assets.test.ts | 214 +- .../test/tui/utils/input-latency.test.ts | 30 + apps/kimi-code/tsdown.worker.config.ts | 30 + packages/kap-server/src/search/contract.ts | 5 +- .../kap-server/src/search/searchService.ts | 117 +- .../test/search/searchService.test.ts | 58 + packages/minidb/DESIGN_NOTES.md | 7 +- packages/minidb/README.md | 19 +- packages/minidb/bench/maintenance.ts | 438 ++ .../minidb/bench/measure-session-memory.ts | 2 +- packages/minidb/package.json | 4 + packages/minidb/src/backup.ts | 183 + packages/minidb/src/codec.ts | 306 +- packages/minidb/src/compaction.ts | 26 +- packages/minidb/src/gen-codec.ts | 68 +- packages/minidb/src/generation-builder.ts | 845 ++++ packages/minidb/src/generation-loader.ts | 493 +++ packages/minidb/src/index-admin.ts | 246 ++ packages/minidb/src/index.ts | 3691 +---------------- packages/minidb/src/lifecycle.ts | 525 +++ packages/minidb/src/maintenance.ts | 365 ++ packages/minidb/src/memory-guard.ts | 102 + packages/minidb/src/mini-db.ts | 1390 +++++++ packages/minidb/src/query-engine.ts | 496 +++ packages/minidb/src/read-path.ts | 145 + packages/minidb/src/recovery.ts | 46 +- packages/minidb/src/snapshot.ts | 131 +- packages/minidb/src/stats.ts | 104 + packages/minidb/src/store.ts | 13 + packages/minidb/src/text-index/builder.ts | 125 + packages/minidb/src/text-index/image.ts | 173 + .../{text-index.ts => text-index/index.ts} | 857 ++-- packages/minidb/src/text-index/tokenize.ts | 69 + packages/minidb/src/text-index/types.ts | 136 + packages/minidb/src/text-postings.ts | 19 +- packages/minidb/src/text-registry.ts | 292 ++ packages/minidb/src/trigram.ts | 2 +- packages/minidb/src/types.ts | 133 + packages/minidb/src/value-codec.ts | 122 + packages/minidb/src/value-reader.ts | 33 +- packages/minidb/src/wal-group.ts | 191 + packages/minidb/src/worker-runtime.ts | 71 + packages/minidb/src/worker/text-build-core.ts | 751 ++++ .../minidb/src/worker/text-build-worker.ts | 49 + packages/minidb/src/worker/text-build.ts | 418 ++ packages/minidb/src/write-path.ts | 784 ++++ packages/minidb/test/compaction.test.ts | 49 +- packages/minidb/test/db.test.ts | 77 +- packages/minidb/test/generation.test.ts | 206 +- packages/minidb/test/recovery.test.ts | 140 + packages/minidb/test/text-index.test.ts | 226 +- packages/minidb/test/worker-build.test.ts | 899 ++++ packages/minidb/tsdown.config.ts | 2 +- pnpm-lock.yaml | 3 + 71 files changed, 12427 insertions(+), 4269 deletions(-) create mode 100644 apps/kimi-code/src/native/minidb-worker.ts create mode 100644 apps/kimi-code/src/tui/utils/input-latency.ts create mode 100644 apps/kimi-code/src/utils/startup-trace.ts create mode 100644 apps/kimi-code/test/tui/utils/input-latency.test.ts create mode 100644 apps/kimi-code/tsdown.worker.config.ts create mode 100644 packages/minidb/bench/maintenance.ts create mode 100644 packages/minidb/src/backup.ts create mode 100644 packages/minidb/src/generation-builder.ts create mode 100644 packages/minidb/src/generation-loader.ts create mode 100644 packages/minidb/src/index-admin.ts create mode 100644 packages/minidb/src/lifecycle.ts create mode 100644 packages/minidb/src/maintenance.ts create mode 100644 packages/minidb/src/memory-guard.ts create mode 100644 packages/minidb/src/mini-db.ts create mode 100644 packages/minidb/src/query-engine.ts create mode 100644 packages/minidb/src/read-path.ts create mode 100644 packages/minidb/src/stats.ts create mode 100644 packages/minidb/src/text-index/builder.ts create mode 100644 packages/minidb/src/text-index/image.ts rename packages/minidb/src/{text-index.ts => text-index/index.ts} (52%) create mode 100644 packages/minidb/src/text-index/tokenize.ts create mode 100644 packages/minidb/src/text-index/types.ts create mode 100644 packages/minidb/src/text-registry.ts create mode 100644 packages/minidb/src/types.ts create mode 100644 packages/minidb/src/value-codec.ts create mode 100644 packages/minidb/src/wal-group.ts create mode 100644 packages/minidb/src/worker-runtime.ts create mode 100644 packages/minidb/src/worker/text-build-core.ts create mode 100644 packages/minidb/src/worker/text-build-worker.ts create mode 100644 packages/minidb/src/worker/text-build.ts create mode 100644 packages/minidb/src/write-path.ts create mode 100644 packages/minidb/test/worker-build.test.ts diff --git a/.oxlintrc.json b/.oxlintrc.json index 003359f31d2..51969ea2557 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -90,6 +90,26 @@ "eslint/no-console": "off" } }, + { + // The stage-6 worker closure: these modules (and everything + // packages/minidb/src/worker/ pulls in) are loaded by a bare + // node:worker_threads Worker under Node's native type stripping with + // `execArgv: ['--experimental-transform-types']`, which requires + // explicit `.ts` import specifiers (the strip loader does not remap + // `.js` -> `.ts`). Keep the exception scoped to exactly that closure. + "files": [ + "packages/minidb/src/worker/**/*.ts", + "packages/minidb/src/codec.ts", + "packages/minidb/src/crc32.ts", + "packages/minidb/src/trigram.ts", + "packages/minidb/src/text-postings.ts", + "packages/minidb/src/text-index/tokenize.ts", + "packages/minidb/src/gen-codec.ts" + ], + "rules": { + "import/extensions": "off" + } + }, { "files": ["packages/kosong/src/providers/**/*.ts"], "rules": { diff --git a/AGENTS.md b/AGENTS.md index 5596f198574..b360bd3f86b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,11 +26,11 @@ This is a TypeScript monorepo built for agent-assisted development. Keep the roo - `packages/oauth`: Kimi OAuth and managed auth utilities. - `packages/telemetry`: shared client-side telemetry infrastructure. - `packages/transcript`: the isomorphic transcript rendering data layer — agent-granular L1 store, idempotent L2 operations, `off/turn/block/delta` L3 subscription granularity, framework-free L4 view registry, and turn-cursor pagination. Pure TypeScript (browser-safe, no engine imports) and the sole owner of all transcript contract types (`src/contract/`); consumed by `packages/kap-server` (engine events → transcript, REST + WS surface; live stores backfill history from the persisted per-agent wire records — main on first attach, any agent on demand, cold sessions rebuild any agent — with 0-based turn ordinals matching the engine's). The cold rebuild is a two-level fold over `wire.jsonl` as the single source of truth: `history/groupTurns.ts` (context messages → turn tree) plus `history/foldFacts.ts` (non-context records → tasks, interactions, todos, goal/plan/swarm meta, and end-appended markers/taskrefs; interactions left pending at shutdown fold to `cancelled`). Plan content is a recorded fact too: each ExitPlanMode review submission offloads the document to `agents//plan//v.md` and persists a reference-only `plan.revision` record (`{id, version, path, sha256, bytes}`), which projects — live and cold — to a `plan.revision` marker and the `modes.plan` badge (`{reviewPath, version}`). It also owns the op-batch sequencing contract (`transcriptSeqSchema` in `contract/schema.ts`): a per-(session, agent) monotonic batch `seq` on `transcript.ops` / `transcript.reset` / the REST transcript response, the `transcript_since` subscription cursor, and the `GET .../transcript/ops` catch-up response shape — every field optional so pre-seq peers fall back to loss-signal-driven refreshes. Beyond the timeline, the model carries wire-equivalent detail: steps carry `usage` / `finishReason` / `timing` (LLM latencies) / `retry` / interrupt reason, turns carry `durationMs` / `error` / `usage`, tool frames carry the streamed `inputText` and the latest `progress`, tasks carry subagent `resultSummary` / `error` / `stateReason` / `usage`, `meta.agent` mirrors the agent status slices (model / usage / context / permission / phase), a global `prompts` entity (op `prompt.upsert`) tracks the prompt queue, and `hook.result` lands as a `'hook'` marker. These live-projected fields are NOT backfilled by the cold rebuild (known limitation). -- `packages/kap-server`: the Kimi Code server, backed by the DI × Scope agent engine (`@moonshot-ai/agent-core-v2` — four scopes, App/Workspace/Session/Agent; session create/resume/fork routes compose `ISessionIndex` → `IWorkspaceLifecycleService.handlerFor` → the handler's `ISessionLifecycleService`, and the fs routes resolve session → handler → the Workspace-scope fs services, with one exception: `fs:search` also accepts a workspace reference (registered id or absolute root) in the `{session_id}` slot, so a not-yet-created draft session's `@` file mention resolves the workspace handler directly; the first-class session-less form is `POST /api/v1/workspace/fs:search` (the workspace reference travels in the body)). Exposes sessions over REST + WebSocket (`/api/v1` + `/api/v1/ws`); bootstrapped from `src/start.ts` and consumed by `apps/kimi-code`. The RPC surface is `/api/v1/debug/*` — a reflection dispatcher over the ENTIRE scoped DI registry (every Service callable, no whitelist, Workspace scope addressable alongside App/Session/Agent; `src/transport/registerDebugRoutes.ts` + `serviceDispatcherRoutes.ts`), mounted only with `--debug-endpoints` on a loopback bind and gated by the global bearer auth; repo dev scripts pass the flag. Its transcript surface implements the op-batch sequencing contract: `TranscriptService.dispatchOps` assigns every dispatched batch a per-agent consecutive `seq` and retains it in a bounded in-memory journal (`TRANSCRIPT_OPS_JOURNAL_CAPACITY`, dies with the live store); WS `transcript.ops`/`transcript.reset` payloads carry the seq/watermark, a `transcript_since` subscription cursor (carried, with the per-agent grades, by the `subscribe_v2` control frame — the only transcript subscription channel; its agent-grained counterpart `unsubscribe_v2` detaches listed agents' streams, or the whole session's when `agent_ids` is absent, letting the detached agents' legacy events flow again) replays journaled batches instead of a baseline reset when the journal covers it, and `GET /sessions/{id}/transcript/ops?since_seq=` serves point-to-point catch-up (`complete: false` = journal can't cover or session cold → caller falls back to a full refresh). Beside the paged route, `GET /sessions/{id}/transcript/plan?agent_id=[&tool_call_id=]` projects an agent's ExitPlanMode plan info (content / path / options / review outcome; `tool_call_id` narrows to one call, omitted lists every recoverable plan) from the first available fact — the linked approval interaction's persisted request display, the live tool frame's display, or the tool result output text. The baseline `transcript.reset` itself is items-empty (`TRANSCRIPT_RESET_TAIL_TURNS = 0`): it carries only global state + the watermark + `has_more_older`, because history always pages in over REST. When a WS connection subscribes to the transcript protocol (grade ≠ `off` for an agent), the broadcaster suppresses the transcript-projected `session_event` types for that connection × agent (`TRANSCRIPT_PROJECTED_EVENT_TYPES` + `suppressedByTranscript` in `sessionEventBroadcaster.ts`; cursor replay via `getBufferedSince` applies the same filter). Suppression is only a per-connection send view — the journal still records everything, and connections without transcript grades are unaffected. The session's work aggregate behind `event.session.work_changed` (`busy` / `main_turn_active` / `pending_interaction` / `last_turn_reason`) is owned by the core's `ISessionActivityView` (`sessionActivity` domain, Session scope): the broadcaster only schedules the wire emission around turn frames (`busy:false` lands after `turn.ended`), and `resolveSessionFacts` (`src/routes/sessions.ts`) reads the same view — never fold per-agent activity at the edge. Delivery split on `/api/v1/ws`: global events (`session.meta.updated` and the `event.session.*` / `event.workspace.*` / `event.config.*` families, including every activated session's `event.session.work_changed`) fan out to EVERY established connection — `WsConnectionV1` registers itself via `broadcaster.addGlobalTarget` on construction and unregisters on close — while session/agent-grained events only reach connections subscribed to that session (subject to `agent_filter` and the transcript suppression above); transcript frames are a separate channel governed by the per-agent grades alone and bypass `agent_filter` entirely. The global search surface is `POST /api/v1/search` (`src/search/` + `src/routes/search.ts`): a cross-session full-text search over user messages, assistant text, and session titles, backed by a single minidb database at `/search-index` (`IGlobalSearchService`, App scope — the write-lock holder is the indexer, other processes open read-only and catch up via WAL). It serves two modes: `terms` (the default — minidb's inverted text index over ASCII words + CJK uni/bigrams, no positions, term-level AND) and `literal` (substring-exact search: a hashed 2/3-gram index supplies candidates, every candidate's text is then confirmed with `includes`, so hits carry zero false positives; literal ignores `sort` and returns newest-first). The index route is fully bounded (stage 4): a search request serves the currently published generation and never awaits a sync/reopen/reindex — it kicks the single-flight + debounced background coordinator instead, and reports `index_state.stale` / `index_state.degraded` when serving a behind view or after a failed refresh; every query runs under explicit budgets (max terms, postings visits via `MiniDb.searchBounded`, candidate caps, confirmation text volume, a match deadline) with over-budget pages flagged `incomplete: 'candidate_cap' | 'postings_budget' | 'deadline'`; pagination is keyset over `(time, key)` / `(score, time, key)` with versioned v2 tokens pinning the index generation (a rebuild/reopen/rescan invalidates old tokens with `invalid_page_token`; legacy v1 offset tokens are still accepted and upgraded), and per-session sync scans only that session's file-meta keys (`\0meta\file\\`, migrated from the pre-v2 hash-only keys by a one-time background pass). When `container.session_id` is provided and that session is live in this process (`TranscriptService.forSessionLive` returns a store, wired via `setLiveTranscriptSource` in `start.ts`), BOTH modes instead scan the in-memory transcript store (turn prompts + assistant text frames, history established via `whenReady`/`ensureAgentHistory`) — no index involved; terms-mode live hits are scored Σ log(1+tf) (comparable only within a route, per the `GlobalSearchSource` contract), live-route errors never fall back to the index, and the response's `source: 'live' | 'index'` field (also mixed into the page-token fingerprint, so a mid-pagination route flip invalidates the old token) tells the caller which route served the page. +- `packages/kap-server`: the Kimi Code server, backed by the DI × Scope agent engine (`@moonshot-ai/agent-core-v2` — four scopes, App/Workspace/Session/Agent; session create/resume/fork routes compose `ISessionIndex` → `IWorkspaceLifecycleService.handlerFor` → the handler's `ISessionLifecycleService`, and the fs routes resolve session → handler → the Workspace-scope fs services, with one exception: `fs:search` also accepts a workspace reference (registered id or absolute root) in the `{session_id}` slot, so a not-yet-created draft session's `@` file mention resolves the workspace handler directly; the first-class session-less form is `POST /api/v1/workspace/fs:search` (the workspace reference travels in the body)). Exposes sessions over REST + WebSocket (`/api/v1` + `/api/v1/ws`); bootstrapped from `src/start.ts` and consumed by `apps/kimi-code`. The RPC surface is `/api/v1/debug/*` — a reflection dispatcher over the ENTIRE scoped DI registry (every Service callable, no whitelist, Workspace scope addressable alongside App/Session/Agent; `src/transport/registerDebugRoutes.ts` + `serviceDispatcherRoutes.ts`), mounted only with `--debug-endpoints` on a loopback bind and gated by the global bearer auth; repo dev scripts pass the flag. Its transcript surface implements the op-batch sequencing contract: `TranscriptService.dispatchOps` assigns every dispatched batch a per-agent consecutive `seq` and retains it in a bounded in-memory journal (`TRANSCRIPT_OPS_JOURNAL_CAPACITY`, dies with the live store); WS `transcript.ops`/`transcript.reset` payloads carry the seq/watermark, a `transcript_since` subscription cursor (carried, with the per-agent grades, by the `subscribe_v2` control frame — the only transcript subscription channel; its agent-grained counterpart `unsubscribe_v2` detaches listed agents' streams, or the whole session's when `agent_ids` is absent, letting the detached agents' legacy events flow again) replays journaled batches instead of a baseline reset when the journal covers it, and `GET /sessions/{id}/transcript/ops?since_seq=` serves point-to-point catch-up (`complete: false` = journal can't cover or session cold → caller falls back to a full refresh). Beside the paged route, `GET /sessions/{id}/transcript/plan?agent_id=[&tool_call_id=]` projects an agent's ExitPlanMode plan info (content / path / options / review outcome; `tool_call_id` narrows to one call, omitted lists every recoverable plan) from the first available fact — the linked approval interaction's persisted request display, the live tool frame's display, or the tool result output text. The baseline `transcript.reset` itself is items-empty (`TRANSCRIPT_RESET_TAIL_TURNS = 0`): it carries only global state + the watermark + `has_more_older`, because history always pages in over REST. When a WS connection subscribes to the transcript protocol (grade ≠ `off` for an agent), the broadcaster suppresses the transcript-projected `session_event` types for that connection × agent (`TRANSCRIPT_PROJECTED_EVENT_TYPES` + `suppressedByTranscript` in `sessionEventBroadcaster.ts`; cursor replay via `getBufferedSince` applies the same filter). Suppression is only a per-connection send view — the journal still records everything, and connections without transcript grades are unaffected. The session's work aggregate behind `event.session.work_changed` (`busy` / `main_turn_active` / `pending_interaction` / `last_turn_reason`) is owned by the core's `ISessionActivityView` (`sessionActivity` domain, Session scope): the broadcaster only schedules the wire emission around turn frames (`busy:false` lands after `turn.ended`), and `resolveSessionFacts` (`src/routes/sessions.ts`) reads the same view — never fold per-agent activity at the edge. Delivery split on `/api/v1/ws`: global events (`session.meta.updated` and the `event.session.*` / `event.workspace.*` / `event.config.*` families, including every activated session's `event.session.work_changed`) fan out to EVERY established connection — `WsConnectionV1` registers itself via `broadcaster.addGlobalTarget` on construction and unregisters on close — while session/agent-grained events only reach connections subscribed to that session (subject to `agent_filter` and the transcript suppression above); transcript frames are a separate channel governed by the per-agent grades alone and bypass `agent_filter` entirely. The global search surface is `POST /api/v1/search` (`src/search/` + `src/routes/search.ts`): a cross-session full-text search over user messages, assistant text, and session titles, backed by a single minidb database at `/search-index` (`IGlobalSearchService`, App scope — the write-lock holder is the indexer, other processes open read-only and catch up via WAL). It serves two modes: `terms` (the default — minidb's inverted text index over ASCII words + CJK uni/bigrams, no positions, term-level AND) and `literal` (substring-exact search: a hashed 2/3-gram index supplies candidates, every candidate's text is then confirmed with `includes`, so hits carry zero false positives; literal ignores `sort` and returns newest-first). The index route is fully bounded (stage 4): a search request serves the currently published generation and never awaits a sync/reopen/reindex — it kicks the single-flight + debounced background coordinator instead, and reports `index_state.stale` / `index_state.degraded` when serving a behind view or after a failed refresh, and `index_state.state: 'building'` while the served handle's text base is still being (re)built by the deferred fallback build (searches get the empty building page, never a partial result); every query runs under explicit budgets (max terms, postings visits via `MiniDb.searchBoundedAsync`, candidate caps, confirmation text volume, a match deadline) with over-budget pages flagged `incomplete: 'candidate_cap' | 'postings_budget' | 'deadline'`; pagination is keyset over `(time, key)` / `(score, time, key)` with versioned v2 tokens pinning the index generation (a rebuild/reopen/rescan invalidates old tokens with `invalid_page_token`; legacy v1 offset tokens are still accepted and upgraded), and per-session sync scans only that session's file-meta keys (`\0meta\file\\`, migrated from the pre-v2 hash-only keys by a one-time background pass). When `container.session_id` is provided and that session is live in this process (`TranscriptService.forSessionLive` returns a store, wired via `setLiveTranscriptSource` in `start.ts`), BOTH modes instead scan the in-memory transcript store (turn prompts + assistant text frames, history established via `whenReady`/`ensureAgentHistory`) — no index involved; terms-mode live hits are scored Σ log(1+tf) (comparable only within a route, per the `GlobalSearchSource` contract), live-route errors never fall back to the index, and the response's `source: 'live' | 'index'` field (also mixed into the page-token fingerprint, so a mid-pagination route flip invalidates the old token) tells the caller which route served the page. - `packages/klient`: the client SDK — a contract-driven facade over agent-core-v2 with aggregated `global.*` / `session(id).*` / `agent(id).*` methods, zod validation on every call, and klient-level typed event forwarding. Transport is chosen once at creation via subpath entry (`@moonshot-ai/klient/ipc|memory`); both return the same `Klient`. The package also hosts the e2e suites: the legacy `/api/v1` live suites (`test/e2e/legacy/`) and the docker e2e runner (`pnpm --filter @moonshot-ai/klient docker:e2e`). See `packages/klient/AGENTS.md`. - `packages/server-e2e`: live e2e tests and scenarios against a running server (`KIMI_SERVER_URL`, default `http://127.0.0.1:58627`). See `packages/server-e2e/AGENTS.md`. - `packages/tree-sitter-bash`: a pure-TypeScript bash parser (no runtime deps, no wasm) that produces a syntax tree with tree-sitter-bash 0.25.0 named-node type names and UTF-16 code-unit offsets. `parse(source, { timeoutMs, maxNodes })` runs under a deterministic budget (default 50 ms / 50k nodes, plus per-chain recursion depth caps) and returns a discriminated `ParseResult` (`{ ok, rootNode, hasError }` or `{ ok: false, reason: 'aborted' }`) — callers must treat aborted/hasError trees as "cannot analyze" and degrade. Parser only, no safety judgments; consumers (e.g. Bash tool permission matching) live elsewhere. Known deviations from the reference are tracked in the package README's "Known differences" section, pinned by differential fixtures tested against the real `tree-sitter-bash` wasm (dev-only). -- `packages/minidb`: the embedded JSON document store (`MiniDb`) behind kap-server's search index — snapshot + WAL persistence with an exclusive write lock (losers open read-only and catch up from the WAL), plus a larger-than-RAM full-text layer: `src/text-index.ts` is the inverted index (in-RAM dictionary + delta, on-disk postings in `src/text-postings.ts`) with an injectable `tokenizer`/`queryTokenizer`; the default tokenizer keeps ASCII words and CJK uni/bigrams, while `src/trigram.ts` provides the hashed 2/3-gram tokenizer (NFKC + lowercase, code-point windows) that backs substring-exact search. Text-index definitions (including the tokenizer name) persist in `db.textindexes.json`. Derived state (store image, dt/secondary/compound indexes, text dictionary + postings + doc table) is checkpointed as persistent index **generations** (`generations/g-NNNNNN/` + `CURRENT`, format v1 — `src/generation.ts` layout/manifest, `src/gen-codec.ts` binary images): the writer builds them into a `g-N.tmp-*` dir and atomically publishes (rename + CURRENT swap, fsyncs strict), each compaction's rotation and the generation publish form one transaction (replacing the old synchronous `rebuildTextPostings()` tail), and open loads the published generation + WAL delta replay instead of re-decoding every value / re-tokenizing the corpus / rewriting postings (the legacy full recovery remains the automatic fallback for missing/invalid/unknown-version generations; `OpenOptions.indexGenerations: false` forces the legacy path). Load-time integrity is per-file crc32 + definition hashes — a corrupt or definition-mismatched image rebuilds only the affected index from the loaded store. +- `packages/minidb`: the embedded JSON document store (`MiniDb`) behind kap-server's search index — snapshot + WAL persistence with an exclusive write lock (losers open read-only and catch up from the WAL), plus a larger-than-RAM full-text layer: `src/text-index/` is the inverted index (in-RAM dictionary + delta, on-disk postings in `src/text-postings.ts`; the module is split into `tokenize.ts` / `types.ts` / `builder.ts` / `image.ts` around the `TextIndex` core in `index.ts`) with an injectable `tokenizer`/`queryTokenizer`; the default tokenizer keeps ASCII words and CJK uni/bigrams, while `src/trigram.ts` provides the hashed 2/3-gram tokenizer (NFKC + lowercase, code-point windows) that backs substring-exact search. Text-index definitions (including the tokenizer name) persist in `db.textindexes.json`. Derived state (store image, dt/secondary/compound indexes, text dictionary + postings + doc table) is checkpointed as persistent index **generations** (`generations/g-NNNNNN/` + `CURRENT`, format v1 — `src/generation.ts` layout/manifest, `src/gen-codec.ts` binary images): the writer builds them into a `g-N.tmp-*` dir and atomically publishes (rename + CURRENT swap, fsyncs strict), each compaction's rotation and the generation publish form one transaction (replacing the old synchronous `rebuildTextPostings()` tail), and open loads the published generation + WAL delta replay instead of re-decoding every value / re-tokenizing the corpus / rewriting postings (the full recovery remains the automatic **fallback** for missing/invalid/unknown-version generations; `OpenOptions.indexGenerations: false` forces that path). On the fallback path the corpus-scale text rebuild is no longer awaited inside `open()`: it runs as a `'text-build'` maintenance task on the same bounded engine pinned at the recovery checkpoint (rollback: `OpenOptions.deferOpenTextBuilds: false`), searches on a not-yet-committed index raise `TextIndexBuildingError` (state surfaced via `MiniDb.textIndexBuilding` — the guard is a dedicated `basePending` flag, so staged builds over a live old base keep serving), and a read-only opener builds into a private scratch dir next to the db dir (`.ro-scratch/-*`, dropped on close) and adopts the disk base there instead of aggregating a full in-RAM base. Async base reads are commit-safe via a base-swap epoch (`TextIndex.baseEpoch`): a read straddling a base commit re-reads from the fresh base and never caches a stale list. A healthy writer also keeps a valid generation around at runtime — the per-write WAL-growth trigger (`MiniDb.maybeAutoGenerationBuild`, 4 MiB staleness rule, throttled with failure backoff) covers the started-from-empty window the open-time kick cannot, and `close()` publishes a missing/stale generation best-effort (`buildGeneration('close')`). Load-time integrity is per-file crc32 + definition hashes — a corrupt or definition-mismatched image rebuilds only the affected index from the loaded store. Heavy maintenance runs through the unified **maintenance scheduler** (`src/maintenance.ts` — one heavy task per database at a time, queue backpressure, disk free-space preflight, deadline/cancellation, shutdown drain-or-cancel, `MiniDb.maintenanceStatus()` read model; nested submissions from inside a task run inline via AsyncLocalStorage to avoid self-deadlock). Full-text generation artifacts (tokenization → bounded-memory aggregation → segmented external merge → postings/dictionary/base-docs) are produced **off the main thread** by a worker build (`src/worker/text-build-core.ts`, hosted by `src/worker/text-build.ts` + `src/worker/text-build-worker.ts` — Node-native type-stripping with `execArgv: ['--experimental-transform-types']`, explicit `.ts` import specifiers in the whole worker closure; worker writes only inside the tmp generation dir, the main thread verifies (sanity + streaming crc) and swaps the live base via `TextIndex.commitRebase` after `beginRebase`; `OpenOptions.textBuildWorker: false` is the rollback switch; a missing worker file or slot pressure hosts the SAME bounded core inline on the main thread instead — the in-thread staged aggregation is kept only for small corpora (< 4096 docs), custom function tokenizers, and the explicit rollback). The same bounded engine (worker-or-inline + rebase, `MiniDb.boundedTextBuild`) also backs the two full-corpus initial-build entries — `createTextIndex` and the open-time loader rebuild of a corrupt/definition-mismatched image — so first-time indexing of a large existing store no longer aggregates the whole term->postings map in RAM. The async read surface is additive: `getAsync` / `searchAsync` / `searchBoundedAsync` / `queryAsync` (`ValueReader.readAsync`, `PostingsFile.readAsync`, byte-bounded decoded-postings cache via `TextIndexOptions.cacheBytes`); recovery scans use the async sequential scanner (`scanFrameRefsFdAsync` — windowed reads, sliced CRC, periodic yields, AbortSignal, bounded corruption-resync candidate budget shared with the sync scanner); compaction's disk-mode snapshot groups live refs by (file, offset) and reads them with bounded-concurrency async positioned reads (`src/snapshot.ts`). ## Environment Requirements diff --git a/apps/kimi-code/.gitignore b/apps/kimi-code/.gitignore index 901b7a6d26d..762220ab4c0 100644 --- a/apps/kimi-code/.gitignore +++ b/apps/kimi-code/.gitignore @@ -8,4 +8,4 @@ agents/ src/generated/vis-web-asset.ts # Copied from packages/pi-tui/native at build time by scripts/copy-native-assets.mjs -native/ +/native/ diff --git a/apps/kimi-code/package.json b/apps/kimi-code/package.json index ea52280f2a7..c67b35fda1f 100644 --- a/apps/kimi-code/package.json +++ b/apps/kimi-code/package.json @@ -91,6 +91,7 @@ "@moonshot-ai/kimi-telemetry": "workspace:^", "@moonshot-ai/kimi-web": "workspace:^", "@moonshot-ai/migration-legacy": "workspace:^", + "@moonshot-ai/minidb": "workspace:^", "@moonshot-ai/pi-tui": "workspace:^", "@moonshot-ai/vis-server": "workspace:^", "@moonshot-ai/vis-web": "workspace:*", diff --git a/apps/kimi-code/scripts/native/01-bundle.mjs b/apps/kimi-code/scripts/native/01-bundle.mjs index df46acc1103..9f917e0196a 100644 --- a/apps/kimi-code/scripts/native/01-bundle.mjs +++ b/apps/kimi-code/scripts/native/01-bundle.mjs @@ -15,6 +15,12 @@ export async function runBundleStep() { // miss it (npm builds get it via the `prebuild` script). await run(process.execPath, [buildVisAssetPath]); await run(process.execPath, [tsdownCliPath, '--config', 'tsdown.native.config.ts']); + // Bundle the minidb text-build worker into one self-contained ESM file so + // it can ride the SEA blob as an asset (02-sea-blob.mjs) and be spawned + // from disk at runtime — bundled binaries otherwise lack the worker entry + // and heavy text-index builds degrade to the inline main-thread core. + // Runs after the main bundle with clean:false so both verified files remain. + await run(process.execPath, [tsdownCliPath, '--config', 'tsdown.worker.config.ts']); await run(process.execPath, [checkBundlePath]); } diff --git a/apps/kimi-code/scripts/native/assets.mjs b/apps/kimi-code/scripts/native/assets.mjs index 859262449fa..3c9f3b20476 100644 --- a/apps/kimi-code/scripts/native/assets.mjs +++ b/apps/kimi-code/scripts/native/assets.mjs @@ -5,7 +5,12 @@ import { createRequire } from 'node:module'; import { dirname, extname, isAbsolute, join, relative, resolve } from 'node:path'; import { pathToFileURL } from 'node:url'; -import { NATIVE_ASSET_MANIFEST_VERSION, buildManifestKey } from './manifest.mjs'; +import { + MINIDB_TEXT_BUILD_WORKER_ASSET, + NATIVE_ASSET_MANIFEST_VERSION, + buildManifestKey, + buildRuntimeAssetKey, +} from './manifest.mjs'; import { resolveTargetDeps, SUPPORTED_TARGETS } from './native-deps.mjs'; export { NATIVE_ASSET_MANIFEST_VERSION }; @@ -229,7 +234,10 @@ async function packageManifestEntries({ packageName, packageRoot, files, target export const nativeAssetManifestKey = buildManifestKey; export function nativeAssetSummary(manifest) { - return manifest.packages.map((pkg) => `${pkg.name}: ${pkg.files.length} files`); + return [ + ...manifest.packages.map((pkg) => `${pkg.name}: ${pkg.files.length} files`), + `runtime: ${manifest.runtimeFiles.length} files`, + ]; } export async function collectNativeAssets({ appRoot, target }) { @@ -264,10 +272,25 @@ export async function collectNativeAssets({ appRoot, target }) { Object.assign(assets, result.assets); } + const workerSource = resolve(appRoot, 'dist-native', 'intermediates', 'text-build-worker.mjs'); + const workerBytes = await readFile(workerSource); + const workerAssetKey = buildRuntimeAssetKey(target, MINIDB_TEXT_BUILD_WORKER_ASSET.key); + const runtimeFiles = [ + { + key: MINIDB_TEXT_BUILD_WORKER_ASSET.key, + assetKey: workerAssetKey, + relativePath: MINIDB_TEXT_BUILD_WORKER_ASSET.relativePath, + sha256: sha256(workerBytes), + mode: MINIDB_TEXT_BUILD_WORKER_ASSET.mode, + }, + ]; + assets[workerAssetKey] = workerSource; + const manifest = { version: NATIVE_ASSET_MANIFEST_VERSION, target, packages: manifestPackages, + runtimeFiles, }; return { diff --git a/apps/kimi-code/scripts/native/check-bundle.mjs b/apps/kimi-code/scripts/native/check-bundle.mjs index 3cd10c278d9..bf63064068c 100644 --- a/apps/kimi-code/scripts/native/check-bundle.mjs +++ b/apps/kimi-code/scripts/native/check-bundle.mjs @@ -1,10 +1,8 @@ +import { existsSync, readFileSync } from 'node:fs'; import { builtinModules } from 'node:module'; -import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; -import { nativeJsBundlePath } from './paths.mjs'; - -const bundlePath = nativeJsBundlePath(); -const text = readFileSync(bundlePath, 'utf-8'); +import { nativeIntermediatesDir, nativeJsBundlePath } from './paths.mjs'; const builtins = new Set([ ...builtinModules, @@ -23,18 +21,8 @@ const optionalRuntimeRequires = new Set([ 'utf-8-validate', ]); const optionalRelativeRuntimeRequires = new Set(['./crypto/build/Release/sshcrypto.node']); -const handledNativeRuntimeRequires = new Set(); - -function isAllowedSpecifier(specifier) { - if (builtins.has(specifier) || specifier.startsWith('node:')) return true; - if (optionalRuntimeRequires.has(specifier)) return true; - if (handledNativeRuntimeRequires.has(specifier)) return true; - return false; -} -const errors = []; - -function executableLines() { +function executableLines(text) { return text .split('\n') .map((line) => line.trim()) @@ -45,48 +33,51 @@ function executableLines() { }); } -for (const line of executableLines()) { - for (const match of line.matchAll(/(? { if (specifier.startsWith('.') || specifier.startsWith('/')) { - if (optionalRelativeRuntimeRequires.has(specifier)) continue; - errors.push(`relative require remains: ${specifier}`); - continue; + if (!allowedRelative.has(specifier)) errors.push(`relative ${kind} remains: ${specifier}`); + return; } - if (!isAllowedSpecifier(specifier)) { - errors.push(`external require remains: ${specifier}`); + if (!builtins.has(specifier) && !specifier.startsWith('node:') && !allowedExternal.has(specifier)) { + errors.push(`external ${kind} remains: ${specifier}`); } - } + }; - for (const match of line.matchAll(/(? 0) { - console.error(`Native JS bundle check failed for ${bundlePath}:`); - for (const error of errors) { - console.error(`- ${error}`); - } - process.exit(1); +const bundles = [ + { path: nativeJsBundlePath(), worker: false }, + { path: resolve(nativeIntermediatesDir(), 'text-build-worker.mjs'), worker: true }, +]; +let failed = false; +for (const bundle of bundles) { + const errors = checkBundle(bundle.path, { worker: bundle.worker }); + if (errors.length === 0) continue; + failed = true; + console.error(`Native JS bundle check failed for ${bundle.path}:`); + for (const error of errors) console.error(`- ${error}`); } +if (failed) process.exit(1); diff --git a/apps/kimi-code/scripts/native/manifest.mjs b/apps/kimi-code/scripts/native/manifest.mjs index 30d5e9da320..1344a24f46f 100644 --- a/apps/kimi-code/scripts/native/manifest.mjs +++ b/apps/kimi-code/scripts/native/manifest.mjs @@ -1,10 +1,20 @@ -export const NATIVE_ASSET_MANIFEST_VERSION = 1; +export const NATIVE_ASSET_MANIFEST_VERSION = 2; export const WEB_ASSET_MANIFEST_VERSION = 1; +export const MINIDB_TEXT_BUILD_WORKER_ASSET = Object.freeze({ + key: 'minidb-text-build-worker', + relativePath: 'runtime/minidb/text-build-worker.mjs', + mode: 0o644, +}); + export function buildManifestKey(target) { return `native/${target}/manifest.json`; } +export function buildRuntimeAssetKey(target, key) { + return `native/${target}/runtime/${key}`; +} + export function isManifestVersionSupported(version) { return version === NATIVE_ASSET_MANIFEST_VERSION; } diff --git a/apps/kimi-code/scripts/native/smoke.mjs b/apps/kimi-code/scripts/native/smoke.mjs index ed3a8624f70..0d0f2604b40 100644 --- a/apps/kimi-code/scripts/native/smoke.mjs +++ b/apps/kimi-code/scripts/native/smoke.mjs @@ -1,5 +1,5 @@ import { execFile } from 'node:child_process'; -import { readFile, stat } from 'node:fs/promises'; +import { mkdir, readFile, rm, stat } from 'node:fs/promises'; import { resolve } from 'node:path'; import { promisify } from 'node:util'; @@ -73,10 +73,19 @@ assertIncludes(helpOutput, 'Usage: kimi', '--help'); const exportHelpOutput = await runKimi(['export', '--help']); assertIncludes(exportHelpOutput, 'Usage: kimi export', 'export --help'); -const nativeAssetOutput = await runKimiWithEnv(['--version'], { - KIMI_CODE_HOME: smokeHome, - KIMI_CODE_NATIVE_ASSET_SMOKE: '1', -}); -assertIncludes(nativeAssetOutput, `Native asset smoke passed: ${target}`, 'native asset smoke'); +const smokeCache = resolve(smokeHome, 'cache'); +await rm(smokeHome, { recursive: true, force: true }); +await mkdir(smokeCache, { recursive: true }); +try { + const nativeAssetOutput = await runKimiWithEnv(['--version'], { + KIMI_CODE_CACHE_DIR: smokeCache, + KIMI_CODE_HOME: smokeHome, + KIMI_CODE_NATIVE_ASSET_SMOKE: '1', + }); + assertIncludes(nativeAssetOutput, `Native asset smoke passed: ${target}`, 'native asset smoke'); + assertIncludes(nativeAssetOutput, 'MiniDb worker build passed', 'MiniDb worker smoke'); +} finally { + await rm(smokeHome, { recursive: true, force: true }); +} console.log(`Native smoke passed: ${executablePath}`); diff --git a/apps/kimi-code/src/cli/run-shell.ts b/apps/kimi-code/src/cli/run-shell.ts index 3e1a9b89c24..35b0ca9e01d 100644 --- a/apps/kimi-code/src/cli/run-shell.ts +++ b/apps/kimi-code/src/cli/run-shell.ts @@ -25,6 +25,7 @@ import type { TuiConfig } from '#/tui/config'; import { loadTuiConfig, TuiConfigParseError } from '#/tui/config'; import { CHROME_GUTTER } from '#/tui/constant/rendering'; import { KimiTUI } from '#/tui/index'; +import { startupTrace } from '#/utils/startup-trace'; import { currentTheme, getColorPalette } from '#/tui/theme'; import { toTerminalHyperlink } from '#/utils/terminal-hyperlink'; import { restoreTerminalModes } from '#/utils/terminal-restore'; @@ -87,6 +88,7 @@ export async function runShell( const harness = engineV2 ? createKimiHarnessV2(harnessOptions) : createKimiHarness(harnessOptions); + startupTrace('harness:created'); log.info('kimi-code starting', { version, uiMode: CLI_UI_MODE, @@ -107,6 +109,7 @@ export async function runShell( return; } const config = await harness.getConfig(); + startupTrace('config:loaded'); // Config diagnostics (deprecated keys, invalid sections, ...) are surfaced // by the TUI itself at `finishStartup` via `showConfigWarningsIfAny` — // folded into the dim startup notice they were too easy to miss. @@ -243,7 +246,9 @@ export async function runShell( }; try { const initStartedAt = Date.now(); + startupTrace('tui.start:begin'); await tui.start(); + startupTrace('tui.start:end'); const initMs = Date.now() - initStartedAt; const startupSessionId = tui.getCurrentSessionId(); const mcpMs = await tui.getStartupMcpMs(); diff --git a/apps/kimi-code/src/main.ts b/apps/kimi-code/src/main.ts index 28ed63a9006..7c4e1040b0b 100644 --- a/apps/kimi-code/src/main.ts +++ b/apps/kimi-code/src/main.ts @@ -24,6 +24,7 @@ import { import { createProgram } from './cli/commands'; import { finalizeHeadlessRun } from './cli/headless-exit'; +import { startupTrace } from './utils/startup-trace'; import type { CLIOptions } from './cli/options'; import { OptionConflictError, validateOptions } from './cli/options'; import { runPrompt } from './cli/run-prompt'; @@ -36,6 +37,7 @@ import { runUpdatePreflight } from './cli/update/preflight'; import { createKimiCodeHostIdentity, getVersion } from './cli/version'; import { CLI_SHUTDOWN_TIMEOUT_MS, CLI_UI_MODE, PROCESS_NAME } from './constant/app'; import { cleanupStaleNativeCacheForCurrent } from './native/native-assets'; +import { installMinidbTextBuildWorker } from './native/minidb-worker'; import { installNativeModuleHook } from './native/module-hook'; import { runNativeAssetSmokeIfRequested } from './native/smoke'; @@ -56,6 +58,7 @@ export async function handleMainCommand( version: string, ): Promise { let validated: ReturnType; + startupTrace('main:enter'); try { validated = validateOptions(opts); } catch (error) { @@ -66,10 +69,12 @@ export async function handleMainCommand( throw error; } + startupTrace('preflight:begin'); const preflightResult = await runUpdatePreflight( version, validated.uiMode === 'print' ? { track, isTTY: false } : { track }, ); + startupTrace('preflight:end'); if (preflightResult === 'exit') { process.exit(0); } @@ -79,6 +84,7 @@ export async function handleMainCommand( return { headlessCompleted: true }; } + startupTrace('runShell:begin'); await runShell(validated.options, version); return { headlessCompleted: false }; } @@ -142,6 +148,16 @@ export function main(): void { // invalid proxy URL is reported and ignored rather than aborting startup. installGlobalProxyDispatcher(); installNativeModuleHook(); + // Best-effort SEA worker installation. Diagnostics are trace-only and avoid + // exposing the user's cache path; failure keeps MiniDb's bounded inline mode. + const workerInstall = installMinidbTextBuildWorker(); + startupTrace( + workerInstall.status === 'installed' + ? `minidb-worker:installed basename=${workerInstall.basename} sha256=${workerInstall.assetSha256}` + : workerInstall.status === 'failed' + ? `minidb-worker:failed code=${workerInstall.errorCode} sha256=${workerInstall.assetSha256 ?? 'unknown'}` + : `minidb-worker:${workerInstall.status}`, + ); if (runNativeAssetSmokeIfRequested()) return; // Start the background cleanup of stale native cache. Fire-and-forget; must not block startup or throw. diff --git a/apps/kimi-code/src/native/minidb-worker.ts b/apps/kimi-code/src/native/minidb-worker.ts new file mode 100644 index 00000000000..babad491e5d --- /dev/null +++ b/apps/kimi-code/src/native/minidb-worker.ts @@ -0,0 +1,69 @@ +import { basename } from 'node:path'; + +import { + configureTextBuildWorkerRuntime, + getTextBuildWorkerRuntimeState, +} from '@moonshot-ai/minidb/worker-runtime'; + +import { MINIDB_TEXT_BUILD_WORKER_ASSET } from '../../scripts/native/manifest.mjs'; +import { + getEmbeddedNativeAssetManifest, + getMinidbTextBuildWorkerFile, + getSeaAssetSource, + type NativeAssetOptions, +} from './native-assets'; + +export type MinidbTextBuildWorkerInstallStatus = + | { readonly status: 'not-sea' } + | { readonly status: 'asset-missing' } + | { + readonly status: 'installed'; + readonly assetSha256: string; + readonly basename: string; + } + | { + readonly status: 'failed'; + readonly errorCode: string; + readonly assetSha256?: string; + }; + +function errorCode(error: unknown): string { + const code = (error as NodeJS.ErrnoException | undefined)?.code; + if (typeof code === 'string' && code.length > 0) return code; + return error instanceof Error ? error.name : 'UNKNOWN'; +} + +/** Install the SEA-bundled worker without making optional extraction fatal. */ +export function installMinidbTextBuildWorker( + options: NativeAssetOptions = {}, +): MinidbTextBuildWorkerInstallStatus { + const source = options.source ?? getSeaAssetSource(); + if (source === null) return { status: 'not-sea' }; + + let assetSha256: string | undefined; + try { + const manifest = options.manifest ?? getEmbeddedNativeAssetManifest(source); + const file = manifest?.runtimeFiles.find( + (entry) => entry.key === MINIDB_TEXT_BUILD_WORKER_ASSET.key, + ); + if (manifest === null || file === undefined) return { status: 'asset-missing' }; + assetSha256 = file.sha256; + + const workerPath = getMinidbTextBuildWorkerFile({ ...options, source, manifest }); + if (workerPath === null) return { status: 'asset-missing' }; + configureTextBuildWorkerRuntime(workerPath); + const runtime = getTextBuildWorkerRuntimeState(); + if (!runtime.configured) throw new Error('MiniDb worker runtime was not configured'); + return { + status: 'installed', + assetSha256, + basename: basename(workerPath), + }; + } catch (error) { + return { + status: 'failed', + errorCode: errorCode(error), + assetSha256, + }; + } +} diff --git a/apps/kimi-code/src/native/native-assets.ts b/apps/kimi-code/src/native/native-assets.ts index d66547695cb..a6922466143 100644 --- a/apps/kimi-code/src/native/native-assets.ts +++ b/apps/kimi-code/src/native/native-assets.ts @@ -11,11 +11,15 @@ import { } from 'node:fs'; import { createRequire } from 'node:module'; import { homedir } from 'node:os'; -import { dirname, join, win32 as pathWin32 } from 'node:path'; +import { dirname, isAbsolute, join, relative, resolve, win32 as pathWin32 } from 'node:path'; import { join as joinPosix } from 'pathe'; import { KIMI_BUILD_INFO } from '#/cli/build-info'; -import { NATIVE_ASSET_MANIFEST_VERSION as MANIFEST_VERSION, buildManifestKey } from '../../scripts/native/manifest.mjs'; +import { + MINIDB_TEXT_BUILD_WORKER_ASSET, + NATIVE_ASSET_MANIFEST_VERSION as MANIFEST_VERSION, + buildManifestKey, +} from '../../scripts/native/manifest.mjs'; export const NATIVE_ASSET_MANIFEST_VERSION = MANIFEST_VERSION; @@ -32,10 +36,15 @@ export interface NativeAssetPackage { readonly files: readonly NativeAssetFile[]; } +export interface NativeRuntimeAssetFile extends NativeAssetFile { + readonly key: string; +} + export interface NativeAssetManifest { readonly version: typeof NATIVE_ASSET_MANIFEST_VERSION; readonly target: string; readonly packages: readonly NativeAssetPackage[]; + readonly runtimeFiles: readonly NativeRuntimeAssetFile[]; } export interface NativeAssetSource { @@ -53,10 +62,6 @@ export interface NativeAssetOptions { readonly version?: string; } -type RawNativeAssetManifest = Omit & { - readonly version: number; -}; - interface NodeSeaModule { isSea(): boolean; getAssetKeys(): string[]; @@ -97,6 +102,149 @@ function sha256(bytes: Buffer | Uint8Array | string): string { return createHash('sha256').update(bytes).digest('hex'); } +function manifestObject(value: unknown, label: string): Record { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw new Error(`Invalid native asset manifest: ${label} must be an object`); + } + return value as Record; +} + +function manifestString(value: unknown, label: string): string { + if (typeof value !== 'string' || value.length === 0) { + throw new Error(`Invalid native asset manifest: ${label} must be a non-empty string`); + } + return value; +} + +function validateRelativePath(value: unknown, label: string): string { + const path = manifestString(value, label); + const segments = path.split(/[\\/]/); + if ( + isAbsolute(path) || + /^[a-zA-Z]:/.test(path) || + path.startsWith('\\\\') || + segments.some((segment) => segment.length === 0 || segment === '.' || segment === '..') + ) { + throw new Error(`Invalid native asset manifest: ${label} must be a safe relative path`); + } + return path; +} + +function validateAssetFile( + value: unknown, + label: string, + assetKeys: Set, + relativePaths: Set, +): NativeAssetFile { + const file = manifestObject(value, label); + const assetKey = manifestString(file['assetKey'], `${label}.assetKey`); + if (assetKeys.has(assetKey)) { + throw new Error(`Invalid native asset manifest: duplicate assetKey ${assetKey}`); + } + assetKeys.add(assetKey); + const relativePath = validateRelativePath(file['relativePath'], `${label}.relativePath`); + const portableRelativePath = relativePath.replaceAll('\\', '/'); + if (relativePaths.has(portableRelativePath)) { + throw new Error(`Invalid native asset manifest: duplicate relativePath ${relativePath}`); + } + relativePaths.add(portableRelativePath); + const fileSha256 = file['sha256']; + if (typeof fileSha256 !== 'string' || !/^[a-f0-9]{64}$/.test(fileSha256)) { + throw new Error(`Invalid native asset manifest: ${label}.sha256 must be 64 lowercase hex characters`); + } + const mode = file['mode']; + if ( + mode !== undefined && + (!Number.isInteger(mode) || (mode as number) < 0 || (mode as number) > 0o777) + ) { + throw new Error(`Invalid native asset manifest: ${label}.mode must be an integer between 0 and 0777`); + } + return { + assetKey, + relativePath, + sha256: fileSha256, + mode: mode as number | undefined, + }; +} + +export function validateNativeAssetManifest( + value: unknown, + expectedTarget?: string, +): NativeAssetManifest { + const manifest = manifestObject(value, 'root'); + if (manifest['version'] !== NATIVE_ASSET_MANIFEST_VERSION) { + throw new Error(`Unsupported native asset manifest version: ${String(manifest['version'])}`); + } + const target = manifestString(manifest['target'], 'target'); + if (expectedTarget !== undefined && target !== expectedTarget) { + throw new Error(`Native asset manifest target mismatch: ${target} !== ${expectedTarget}`); + } + const manifestPackages = manifest['packages']; + if (!Array.isArray(manifestPackages)) { + throw new TypeError('Invalid native asset manifest: packages must be an array'); + } + const manifestRuntimeFiles = manifest['runtimeFiles']; + if (!Array.isArray(manifestRuntimeFiles)) { + throw new TypeError('Invalid native asset manifest: runtimeFiles must be an array'); + } + + const assetKeys = new Set(); + const relativePaths = new Set(); + const packageNames = new Set(); + const packages = manifestPackages.map((value, packageIndex): NativeAssetPackage => { + const label = `packages[${packageIndex}]`; + const pkg = manifestObject(value, label); + const name = manifestString(pkg['name'], `${label}.name`); + if (packageNames.has(name)) { + throw new Error(`Invalid native asset manifest: duplicate package name ${name}`); + } + packageNames.add(name); + const root = validateRelativePath(pkg['root'], `${label}.root`); + const packageFiles = pkg['files']; + if (!Array.isArray(packageFiles)) { + throw new TypeError(`Invalid native asset manifest: ${label}.files must be an array`); + } + return { + name, + root, + files: packageFiles.map((file, fileIndex) => + validateAssetFile(file, `${label}.files[${fileIndex}]`, assetKeys, relativePaths), + ), + }; + }); + + const runtimeKeys = new Set(); + const runtimeFiles = manifestRuntimeFiles.map((value, index): NativeRuntimeAssetFile => { + const label = `runtimeFiles[${index}]`; + const raw = manifestObject(value, label); + const key = manifestString(raw['key'], `${label}.key`); + if (runtimeKeys.has(key)) { + throw new Error(`Invalid native asset manifest: duplicate runtime key ${key}`); + } + runtimeKeys.add(key); + return { + ...validateAssetFile(raw, label, assetKeys, relativePaths), + key, + }; + }); + + return { + version: NATIVE_ASSET_MANIFEST_VERSION, + target, + packages, + runtimeFiles, + }; +} + +function resolveAssetPath(cacheRoot: string, relativePath: string): string { + const path = resolve(cacheRoot, ...relativePath.split(/[\\/]/)); + const fromRoot = relative(cacheRoot, path); + if (fromRoot === '..' || fromRoot.startsWith('../') || fromRoot.startsWith('..\\') || isAbsolute(fromRoot)) { + throw new Error(`Native asset path escapes cache root: ${relativePath}`); + } + return path; +} + function optionalEnvValue(env: NodeJS.ProcessEnv, key: string): string | null { const value = env[key]; return typeof value === 'string' && value.length > 0 ? value : null; @@ -124,14 +272,9 @@ export function getEmbeddedNativeAssetManifest( const key = nativeAssetManifestKey(target); if (!source.getAssetKeys().includes(key)) return null; const raw = source.getRawAsset(key); - const manifest = JSON.parse(toBuffer(raw).toString('utf-8')) as RawNativeAssetManifest; - if (manifest.version !== NATIVE_ASSET_MANIFEST_VERSION) { - throw new Error(`Unsupported native asset manifest version: ${manifest.version}`); - } - if (manifest.target !== target) { - throw new Error(`Native asset manifest target mismatch: ${manifest.target} !== ${target}`); - } - return manifest as NativeAssetManifest; + const parsed: unknown = JSON.parse(toBuffer(raw).toString('utf-8')); + validateNativeAssetManifest(parsed, target); + return parsed as NativeAssetManifest; } export function getNativeCacheBase(options: NativeAssetOptions = {}): string { @@ -159,13 +302,14 @@ export function getNativeAssetCacheRoot( manifest: NativeAssetManifest, options: NativeAssetOptions = {}, ): string { + const validated = validateNativeAssetManifest(manifest); const version = sanitizeSegment(options.version ?? KIMI_BUILD_INFO.version ?? 'dev'); const manifestHash = sha256(JSON.stringify(manifest)); return join( getNativeCacheBase(options), 'native', version, - sanitizeSegment(manifest.target), + sanitizeSegment(validated.target), manifestHash, ); } @@ -219,27 +363,59 @@ export function ensureNativeAssetTree(options: NativeAssetOptions = {}): string const source = options.source ?? getSeaAssetSource(); if (source === null) return null; - const manifest = + const rawManifest = options.manifest ?? getEmbeddedNativeAssetManifest(source, currentTarget()); - if (manifest === null) return null; - - const cacheRoot = getNativeAssetCacheRoot(manifest, options); - for (const pkg of manifest.packages) { - for (const file of pkg.files) { - const bytes = toBuffer(source.getRawAsset(file.assetKey)); - const actualSha256 = sha256(bytes); - if (actualSha256 !== file.sha256) { - throw new Error( - `Native asset checksum mismatch for ${file.assetKey}: ${actualSha256} !== ${file.sha256}`, - ); - } - ensureFile(join(cacheRoot, file.relativePath), bytes, file.sha256, file.mode); + if (rawManifest === null) return null; + const manifest = validateNativeAssetManifest(rawManifest); + + const cacheRoot = getNativeAssetCacheRoot(rawManifest, options); + const sourceKeys = new Set(source.getAssetKeys()); + const files = [ + ...manifest.packages.flatMap((pkg) => pkg.files), + ...manifest.runtimeFiles, + ]; + for (const file of files) { + if (!sourceKeys.has(file.assetKey)) { + throw new Error(`Native asset is missing: ${file.assetKey}`); } + const bytes = toBuffer(source.getRawAsset(file.assetKey)); + const actualSha256 = sha256(bytes); + if (actualSha256 !== file.sha256) { + throw new Error( + `Native asset checksum mismatch for ${file.assetKey}: ${actualSha256} !== ${file.sha256}`, + ); + } + ensureFile(resolveAssetPath(cacheRoot, file.relativePath), bytes, file.sha256, file.mode); } ensureEntryFile(cacheRoot); return cacheRoot; } +export function getNativeRuntimeFile( + key: string, + options: NativeAssetOptions = {}, +): string | null { + const source = options.source ?? getSeaAssetSource(); + if (source === null) return null; + + const rawManifest = + options.manifest ?? getEmbeddedNativeAssetManifest(source, currentTarget()); + if (rawManifest === null) return null; + const manifest = validateNativeAssetManifest(rawManifest); + + const file = manifest.runtimeFiles.find((entry) => entry.key === key); + if (file === undefined) return null; + + const cacheRoot = ensureNativeAssetTree({ ...options, source, manifest: rawManifest }); + return cacheRoot === null ? null : resolveAssetPath(cacheRoot, file.relativePath); +} + +export function getMinidbTextBuildWorkerFile( + options: NativeAssetOptions = {}, +): string | null { + return getNativeRuntimeFile(MINIDB_TEXT_BUILD_WORKER_ASSET.key, options); +} + export function getNativePackageRoot( packageName: string, options: NativeAssetOptions = {}, @@ -247,15 +423,16 @@ export function getNativePackageRoot( const source = options.source ?? getSeaAssetSource(); if (source === null) return null; - const manifest = + const rawManifest = options.manifest ?? getEmbeddedNativeAssetManifest(source, currentTarget()); - if (manifest === null) return null; + if (rawManifest === null) return null; + const manifest = validateNativeAssetManifest(rawManifest); const pkg = manifest.packages.find((entry) => entry.name === packageName); if (pkg === undefined) return null; - const cacheRoot = ensureNativeAssetTree({ ...options, source, manifest }); - return cacheRoot === null ? null : join(cacheRoot, pkg.root); + const cacheRoot = ensureNativeAssetTree({ ...options, source, manifest: rawManifest }); + return cacheRoot === null ? null : resolveAssetPath(cacheRoot, pkg.root); } export function hasNativePackage(packageName: string, manifest: NativeAssetManifest): boolean { diff --git a/apps/kimi-code/src/native/smoke.ts b/apps/kimi-code/src/native/smoke.ts index c77f1419d04..1d330bc8777 100644 --- a/apps/kimi-code/src/native/smoke.ts +++ b/apps/kimi-code/src/native/smoke.ts @@ -1,14 +1,17 @@ +import { mkdtempSync, mkdirSync, rmSync } from 'node:fs'; import { createRequire } from 'node:module'; import { dirname, join } from 'node:path'; -import { getEmbeddedNativeAssetManifest, getNativePackageRoot } from './native-assets'; +import { MiniDb } from '@moonshot-ai/minidb'; + +import { + getEmbeddedNativeAssetManifest, + getNativeCacheBase, + getNativePackageRoot, +} from './native-assets'; const smokePackages = ['@mariozechner/clipboard', '@moonshot-ai/pi-tui']; -// Verify pi-tui's native helper can actually be loaded through the module hook. -// pi-tui computes native helper paths from process.execPath and require()s them; -// those paths do not exist next to the SEA binary, so this only succeeds when -// installNativeModuleHook() redirects the require into the native-asset cache. function smokePiTuiNativeLoad(): void { const platform = process.platform; const arch = process.arch; @@ -18,42 +21,81 @@ function smokePiTuiNativeLoad(): void { } else if (platform === 'win32' && (arch === 'x64' || arch === 'arm64')) { rel = join('native', 'win32', 'prebuilds', `win32-${arch}`, 'win32-console-mode.node'); } - if (rel === undefined) return; // Linux: no native helper, nothing to load. + if (rel === undefined) return; const req = createRequire(import.meta.url); - const bogusPath = join(dirname(process.execPath), rel); - const helper = req(bogusPath) as { + const helper = req(join(dirname(process.execPath), rel)) as { isModifierPressed?: unknown; enableVirtualTerminalInput?: unknown; }; - const ok = - typeof helper.isModifierPressed === 'function' || - typeof helper.enableVirtualTerminalInput === 'function'; - if (!ok) { - throw new Error(`pi-tui native helper loaded but exports are unexpected: ${rel}`); + if ( + typeof helper.isModifierPressed !== 'function' && + typeof helper.enableVirtualTerminalInput !== 'function' + ) { + throw new TypeError(`pi-tui native helper exports are unexpected: ${rel}`); } } -export function runNativeAssetSmokeIfRequested(): boolean { - if (process.env['KIMI_CODE_NATIVE_ASSET_SMOKE'] !== '1') return false; - +async function smokeMinidbWorker(): Promise { + const cacheBase = getNativeCacheBase(); + mkdirSync(cacheBase, { recursive: true }); + const dir = mkdtempSync(join(cacheBase, 'sea-minidb-smoke-')); + let db: MiniDb> | null = null; try { - const manifest = getEmbeddedNativeAssetManifest(); - if (manifest === null) { - throw new Error('Native asset manifest is not available.'); + db = await MiniDb.open>({ dir, valueCodec: 'json' }); + const total = 4_200; + for (let base = 0; base < total; base += 500) { + await db.batch( + Array.from({ length: Math.min(500, total - base) }, (_, offset) => { + const id = base + offset; + return { + op: 'set' as const, + key: `doc-${id}`, + value: { text: `sea worker searchable document ${id}` }, + }; + }), + ); + } + await db.createTextIndex('smoke', { fields: ['text'] }); + if (db.stats.textWorkerBuilds < 1) { + throw new Error(`MiniDb worker did not run: ${JSON.stringify(db.stats)}`); + } + if (db.stats.textWorkerFallbacks !== 0) { + throw new Error( + `MiniDb worker unexpectedly fell back: ${db.stats.lastTextWorkerFallback ?? 'unknown'}`, + ); + } + if (!db.search('smoke', 'searchable').some((hit) => hit.key === 'doc-0')) { + throw new Error('MiniDb worker-built text index returned an incorrect search result'); } - for (const packageName of smokePackages) { - const packageRoot = getNativePackageRoot(packageName, { manifest }); - if (packageRoot === null) { - throw new Error(`Native package is not available: ${packageName}`); - } + } finally { + await db?.close().catch(() => {}); + rmSync(dir, { recursive: true, force: true }); + } +} + +async function runSmoke(): Promise { + const manifest = getEmbeddedNativeAssetManifest(); + if (manifest === null) throw new Error('Native asset manifest is not available.'); + for (const packageName of smokePackages) { + if (getNativePackageRoot(packageName, { manifest }) === null) { + throw new Error(`Native package is not available: ${packageName}`); } - smokePiTuiNativeLoad(); - process.stdout.write(`Native asset smoke passed: ${manifest.target}\n`); - process.exit(0); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - process.stderr.write(`Native asset smoke failed: ${message}\n`); - process.exit(1); } + smokePiTuiNativeLoad(); + await smokeMinidbWorker(); + process.stdout.write(`Native asset smoke passed: ${manifest.target}; MiniDb worker build passed\n`); +} + +export function runNativeAssetSmokeIfRequested(): boolean { + if (process.env['KIMI_CODE_NATIVE_ASSET_SMOKE'] !== '1') return false; + void runSmoke().then( + () => process.exit(0), + (error: unknown) => { + const message = error instanceof Error ? error.message : String(error); + process.stderr.write(`Native asset smoke failed: ${message}\n`); + process.exit(1); + }, + ); + return true; } diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index 714dd7e4b73..a979de72195 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -144,6 +144,8 @@ import { formatErrorMessage } from './utils/event-payload'; import { pickForegroundTasks } from './utils/foreground-task'; import { ImageAttachmentStore, type ImageAttachment } from './utils/image-attachment-store'; import { extractMediaAttachments, rewriteMediaPlaceholders } from './utils/image-placeholder'; +import { installInputLatencyProbe } from './utils/input-latency'; +import { startupTrace } from '#/utils/startup-trace'; import { REPLAY_TURN_LIMIT } from './utils/message-replay'; import { hasPatchChanges } from './utils/object-patch'; import { sessionRowsForPicker } from './utils/session-picker-rows'; @@ -574,6 +576,7 @@ export class KimiTUI { // ========================================================================= async start(): Promise { + startupTrace('tui:start'); // Signal handlers must be installed before raw mode to avoid EIO loops. this.registerSignalHandlers(); // Outer try rolls back signal listeners on startup failure. @@ -601,16 +604,25 @@ export class KimiTUI { return; } + startupTrace('trustPrompt:begin'); const trustPromptStartedLoop = await this.maybeRunWorkspaceTrustPrompt(); + startupTrace('trustPrompt:end'); + startupTrace('initMainTui:begin'); const shouldReplayHistory = await this.initMainTui(); + startupTrace('initMainTui:end'); + // Debug-only input→render latency overlay (KIMI_TUI_INPUT_LATENCY=1). + if (process.env['KIMI_TUI_INPUT_LATENCY']) installInputLatencyProbe(this.state.ui); // When the trust prompt already started the event loop, starting it // again would re-run pi-tui's terminal.start() — stacking a second // Kitty keyboard-protocol push (leaking CSI-u mode past exit) and // duplicate stdin listeners. if (!trustPromptStartedLoop) this.startEventLoop(); + startupTrace('eventLoop:started'); try { this.startBackgroundFdAutocomplete(); + startupTrace('finishStartup:begin'); await this.finishStartup(shouldReplayHistory); + startupTrace('finishStartup:end'); } catch (error) { this.disposeTerminalTracking(); this.state.ui.stop(); diff --git a/apps/kimi-code/src/tui/utils/input-latency.ts b/apps/kimi-code/src/tui/utils/input-latency.ts new file mode 100644 index 00000000000..8ad69f718b5 --- /dev/null +++ b/apps/kimi-code/src/tui/utils/input-latency.ts @@ -0,0 +1,105 @@ +// src/tui/utils/input-latency.ts +// +// Debug-only input→render latency probe, enabled with KIMI_TUI_INPUT_LATENCY=1. +// Registers a pi-tui input listener (event timestamps) and mounts a +// non-capturing overlay in the top-right corner whose render() drains the +// queue: each pending input event is stamped against the frame that first +// renders after it, and the overlay shows the live stats (last / p50 / p95 / +// p99 / max, plus >100ms / >300ms / >1s counters and the five worst samples). +// Optional JSONL sink: KIMI_TUI_INPUT_LATENCY_LOG= appends one record +// per event for post-hoc analysis. +// +// The measured latency is "input event → start of the first frame rendered +// after it" — it includes input handling and the 16ms render throttle, and +// underestimates by the frame's own diff/write tail (sub-ms to a few ms), +// which is the right granularity for diagnosing >100ms stalls. + +import { appendFileSync, mkdirSync } from 'node:fs'; +import path from 'node:path'; +import type { Component, TUI } from '@moonshot-ai/pi-tui'; + +/** Rolling sample cap for the percentile window. */ +const MAX_SAMPLES = 500; + +export interface LatencySample { + latency: number; + at: string; +} + +/** The pure stats core (exported for tests): feed it input→render latencies + * and it keeps the rolling window, counters, and the five worst samples. */ +export class LatencyStats { + last = 0; + events = 0; + over100 = 0; + over300 = 0; + over1000 = 0; + readonly worst: LatencySample[] = []; + private readonly samples: number[] = []; + + record(latency: number, at: string): void { + this.last = latency; + this.events++; + if (latency > 100) this.over100++; + if (latency > 300) this.over300++; + if (latency > 1000) this.over1000++; + this.samples.push(latency); + if (this.samples.length > MAX_SAMPLES) this.samples.shift(); + const smallestKept = this.worst[this.worst.length - 1]?.latency ?? -1; + if (this.worst.length < 5 || latency >= smallestKept) { + this.worst.push({ latency, at }); + this.worst.sort((a, b) => b.latency - a.latency); + if (this.worst.length > 5) this.worst.length = 5; + } + } + + percentile(p: number): number { + if (this.samples.length === 0) return 0; + const sorted = [...this.samples].sort((a, b) => a - b); + return sorted[Math.min(sorted.length - 1, Math.ceil((p / 100) * sorted.length) - 1)]!; + } + + max(): number { + return this.samples.length === 0 ? 0 : Math.max(...this.samples); + } + + formatLines(): string[] { + if (this.events === 0) return [' input→render: (type something) ']; + const head = + ` io ${this.last.toFixed(0)}ms | p50 ${this.percentile(50).toFixed(0)} p95 ${this.percentile(95).toFixed(0)}` + + ` p99 ${this.percentile(99).toFixed(0)} max ${this.max().toFixed(0)}ms | n=${this.events}` + + ` >100:${this.over100} >300:${this.over300} >1s:${this.over1000} `; + const worstLine = ` worst: ${this.worst.map((w) => `${w.latency.toFixed(0)}ms@${w.at}`).join(' ')} `; + return [head, worstLine]; + } +} + +/** Install the probe on a running TUI (call only when the env flag is set). */ +export function installInputLatencyProbe(tui: TUI): void { + const stats = new LatencyStats(); + const pending: number[] = []; + const logPath = process.env['KIMI_TUI_INPUT_LATENCY_LOG']; + if (logPath) mkdirSync(path.dirname(logPath), { recursive: true }); + + tui.addInputListener(() => { + pending.push(performance.now()); + return undefined; + }); + + const overlay: Component = { + invalidate: () => {}, + render: () => { + if (pending.length > 0) { + const now = performance.now(); + const at = new Date().toISOString().slice(11, 23); + for (const t of pending.splice(0)) { + const latency = now - t; + stats.record(latency, at); + if (logPath) appendFileSync(logPath, `${JSON.stringify({ t: new Date().toISOString(), latencyMs: Math.round(latency) })}\n`); + } + } + return stats.formatLines(); + }, + }; + tui.showOverlay(overlay, { nonCapturing: true, anchor: 'top-right', margin: 0 }); +} diff --git a/apps/kimi-code/src/utils/startup-trace.ts b/apps/kimi-code/src/utils/startup-trace.ts new file mode 100644 index 00000000000..65ac7eb441e --- /dev/null +++ b/apps/kimi-code/src/utils/startup-trace.ts @@ -0,0 +1,34 @@ +// src/utils/startup-trace.ts +// +// Debug-only startup phase tracer, enabled with KIMI_STARTUP_TRACE=1. +// Each call appends one `