feat: isolate the full-text search index from the session index and the main thread (#2701)

* feat(minidb): instrument open lifecycle with phase timings and status

Add MiniDb.lifecycleStatus() exposing the no-generation/generation-load/
wal-catch-up/full-rebuild/ready/degraded state machine plus per-phase
timings (generation candidate load, store/non-text/text image load,
postings integrity check, WAL scan/apply, full recovery, text rebuild
hosting), so snapshot load, WAL catch-up and full rebuild can be told
apart in diagnostics.

Also add a repeatable open-lifecycle bench (small data, large WAL delta,
large full-text generation, corrupt generation) and fixtures proving a
healthy generation open performs no full-corpus tokenization while a
corrupt or missing generation falls back. Log search-index and
query-store open diagnostics in kap-server and agent-core-v2 so a
listSessions call can be attributed to the database it touches.

No persistence format or product behavior change.

* feat(agent-core-v2): isolate the session index from the global search index

Harden the separation between the session read model and the full-text
search index so session operations never depend on search availability:

- Reject text index definitions in MiniDbQueryStore at definition level,
  keeping the session query-store a structural-only read model with no
  postings/tokenizer artifacts, and assert its generation carries no
  full-text files.
- Share one authoritative scan between the first list and the initial
  projection (single-flight) instead of scanning twice; reads may only
  join an in-flight scan, and every fallback read folds the mirror's
  pending queue so read-your-writes holds while preparing.
- Keep withReadModel() fallback semantics pinned by tests:
  uninitialized/preparing reads hit authoritative metadata immediately,
  ready reads use the read model, degraded keeps falling back with a
  diagnosable status reason.
- Guard session metadata writes so a mirror failure degrades only the
  read model and never fails the session lifecycle.
- Prove via tests that listSessions/--resume/--continue never open the
  global search DB (including when search-index is unopenable), and that
  only real full-text search requests report building/stale/degraded.

* perf(minidb): slice open-time work so it never blocks the main thread

Make the whole generation-open path cooperative:

- Replace the synchronous postings/store CRC verification with chunked
  async variants (readGenerationFileCheckedAsync, verifyFileIntegrityAsync)
  that keep the exact bytes/crc-mismatch error semantics.
- Give the WAL-delta apply a primitive-op + wall-clock budget
  (walApplySlicer), so a batch frame unrolling into thousands of ops can
  no longer run as one uninterruptible slice; torn-tail, corrupt-batch
  and read-only behaviors are unchanged.
- Slice the big attach loops: Store.bulkLoadRefsAsync +
  SkipList.bulkLoadAsync for the store image, async parsers and
  loadImageAsync for secondary/compound images, and
  TextIndex.attachImageAsync for the docs/dictionary map construction.
- Queue text builds on worker-slot pressure (WorkerSlots.acquireBounded,
  bounded by MiniDb.textBuildSlotWaitMs, abort-aware) instead of falling
  back to an unbounded inline build; a persisted drought hosts the
  bounded inline core as the explicit last resort with stats accounting.

Bench (bench/open-lifecycle, seed 42): event-loop delay max across the
four open scenarios drops from 45/734/331/492 ms to ~12-28 ms with wall
time flat or better.

* feat(kap-server): run the global search index in a dedicated worker

Move the whole search-index MiniDb lifecycle (open, generation load,
WAL replay, sync, rebuild, compaction) off the main thread into a
long-lived worker_threads host, so it never shares the event loop with
TUI input:

- Add a versioned request/response protocol and worker entry hosting a
  host-agnostic SearchIndexCore; the same core also backs an inline
  backend kept as the explicit rollback
  (KIMI_CODE_EXPERIMENTAL_SEARCH_WORKER=false, flag default ON).
- The worker exclusively owns the search-index handle. The lock token
  is reported at acquire time (new MiniDb OpenOptions.onLockAcquired
  hook) and reaped on dirty exit; an orphan-lock detector (same-pid
  lock row whose token no live holder owns) recovers the window where
  the token report is lost, so a mid-open crash can never freeze the
  index into a silent permanent read-only.
- Crash handling: in-flight requests are rejected with typed errors,
  respawn uses capped exponential backoff, per-request watchdogs
  terminate wedged workers, and beginClose propagates into the worker
  so dispose stays bounded during a long sync. Page tokens pin a
  boot-salted generation, so tokens issued before a transparent worker
  restart fail closed with invalid_page_token.
- The main process keeps the sync coordinator (debounce/coalescing/
  single-flight), live transcript routing, query normalization and
  page-token codec; searches keep reading the published generation and
  report building/stale/degraded instead of waiting for sync/rebuild.
- Wire the worker into the CLI packaging: self-contained worker bundles
  for npm dist and the SEA asset manifest/installer/smoke check, plus a
  dev runtime (type-stripping + .ts resolve hook) scoped to worker
  execArgv.

* feat(kap-server): model search and session-index lifecycles explicitly

Consolidate the two-index separation into explicit, diagnosable
lifecycles:

- Surface the global search state machine (stopped / opening / building
  / ready / degraded / closing) end to end: SearchIndexCore.lifecycleState,
  SearchWorkerHost lifecycle snapshots cached from RPC responses (and
  invalidated across worker generations), a never-throwing status()
  carrying the lifecycle, and a synchronous lifecycleReport() that
  neither kicks the open nor spawns the worker. Corrupt search-index
  rebuilds are announced with a dedicated warn log so building, stale,
  degraded, corrupt and worker-unavailable stay distinguishable.
- Turn MiniDb read-only replica catch-up fully cooperative:
  catchUpWalAsync scans frames with the windowed async scanner and
  yields per primitive op on the shared walApplySlicer budget, while a
  per-instance catchUpChain serializes concurrent catch-ups so each
  caller keeps its atomic watermark advance. The stale synchronous
  implementations are removed.
- Pin the dependency direction and availability timing with tests:
  session list/create/resume survive a corrupt or unopenable search
  index (also end-to-end with a dead query-store), search generation
  reuse and stale-serving keep working across restarts, concurrent cold
  callers open the index / spawn the worker exactly once, resume-then-
  fetchSessions performs no duplicate authoritative scan, and a clean
  dispose releases the lock and settles at stopped.
- Document the experimental flag surface (persistence_minidb_readmodel,
  search_worker) in the root guide.

* feat(agent-core-v2): default the session read model on and roll out the separation

Rollout and validation for the index separation plan:

- Flip persistence_minidb_readmodel to default ON (rollback via
  KIMI_CODE_EXPERIMENTAL_PERSISTENCE_MINIDB_READMODEL=false or the
  experimental config section); session list/--resume/--continue now
  always go through the isolated session read model with the
  authoritative fallback. Test harnesses pin the flag off where shared
  fixtures require hermetic homes, while the dedicated suites keep
  explicit on/off coverage.
- Add a probe proving the main thread stays responsive while the
  search worker rebuilds and swaps a generation (reindex), completing
  the TUI responsiveness matrix.
- Record the rollout state in the agent-core-v2 guide (session index
  section) and the root flag line.
- Add changesets for the CLI (worker isolation, session index
  independence) and minidb (cooperative open lifecycle).

Validation: full suites green across minidb (551), agent-core-v2
(4760), kap-server (1005), node-sdk (343), klient (91) and the CLI app
(2567); open-lifecycle bench event-loop delay max is down from
45/734/331/492 ms to ~16-22 ms across the four scenarios with wall
time flat or better.

* fix(agent-core-v2): evict deleted sessions from the mirror queue and drain the index on close

Two issues surfaced by the read-model default in the acp-server suite:

- ISessionIndex.remove only deleted from the query store, but a summary
  still queued in the mirror was folded back into reads (and re-written
  by the next flush), resurrecting a deleted session in listings. The
  mirror now exposes evict(id): drop the queued summary and wait out an
  in-flight flush before the store delete.
- RunningAcpServer.close and SDKRpcClientV2.close disposed the engine
  without awaiting the asynchronous mirror flush / query-store close,
  so a host removing homeDir right after close() raced in-flight shard
  closes (ENOTEMPTY). Both now follow the kap-server shutdown order:
  drain the mirror while the store is open, dispose, then await the
  drains.

* fix(minidb): pause active expiry during the sliced bulk load

The store's active-expire timer is armed at construction, so during a
sliced bulkLoadRefsAsync a tick can fire mid-load: it reaps a TTL key
from the map while the order skiplist is still the old empty one, and
the final bulkLoadAsync then rebuilds order from the stale orderEntries
snapshot — resurrecting the expired key in the ordered index (and
duplicating it if the key is later set again). The sync bulkLoadRefs had
no yield windows, so guard the async path with a bulkLoading flag that
defers expiry ticks until the load settles (finally-safe).

* chore: consolidate changesets into the TUI startup freeze fix
This commit is contained in:
Haozhe 2026-08-07 07:38:16 +08:00 committed by GitHub
parent c0b61c6e55
commit 7cd64766c8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
79 changed files with 8283 additions and 1877 deletions

View file

@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---
Fix multi-second typing and rendering freezes at startup or while idle when a large search index loads, replays, or rebuilds.

View file

@ -91,12 +91,13 @@
}
},
{
// The stage-6 worker closure: these modules (and everything
// packages/minidb/src/worker/ pulls in) are loaded by a bare
// The worker closures: these modules (and everything
// packages/minidb/src/worker/ and
// packages/kap-server/src/search/worker/ pull 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.
// `.js` -> `.ts`). Keep the exception scoped to exactly those closures.
"files": [
"packages/minidb/src/worker/**/*.ts",
"packages/minidb/src/codec.ts",
@ -104,7 +105,10 @@
"packages/minidb/src/trigram.ts",
"packages/minidb/src/text-postings.ts",
"packages/minidb/src/text-index/tokenize.ts",
"packages/minidb/src/gen-codec.ts"
"packages/minidb/src/gen-codec.ts",
"packages/kap-server/src/search/worker/**/*.ts",
"packages/kap-server/src/search/indexCore.ts",
"packages/kap-server/src/search/match.ts"
],
"rules": {
"import/extensions": "off"

View file

@ -62,7 +62,9 @@ This is a TypeScript monorepo built for agent-assisted development. Keep the roo
## Experimental Features
- Gate a not-yet-public feature behind an experimental flag. Add the flag to the registry at `packages/agent-core/src/flags/registry.ts`, then check it with `flags.enabled('my-feature')`. Flags are env-driven and default off: `KIMI_CODE_EXPERIMENTAL_<NAME>` toggles one, `KIMI_CODE_EXPERIMENTAL_FLAG` enables all. Release by flipping the entry's `default` to `true`.
- Gate a not-yet-public feature behind an experimental flag. Flags are env-driven and default off: `KIMI_CODE_EXPERIMENTAL_<NAME>` toggles one, `KIMI_CODE_EXPERIMENTAL_FLAG` enables all. Release by flipping the entry's `default` to `true`.
- `packages/agent-core` (v1): add the flag to the central registry at `packages/agent-core/src/flags/registry.ts`, then check it with `flags.enabled('my-feature')`.
- `packages/agent-core-v2` and kap-server modules: there is no central catalog — declare the flag in the owning domain via `registerFlagDefinition` at import time (see `packages/agent-core-v2/docs/flag.md`), then check it with `IFlagService.enabled(id)`. Current search-index-separation flags: `persistence_minidb_readmodel` (session read model, default on) and `search_worker` (global search worker host, default on).
## Where to Update Instructions

View file

@ -50,7 +50,7 @@
"provenance": true
},
"scripts": {
"build": "tsdown && node scripts/copy-native-assets.mjs && node scripts/check-web-assets.mjs",
"build": "tsdown && tsdown --config tsdown.dist-worker.config.ts && node scripts/copy-native-assets.mjs && node scripts/check-web-assets.mjs",
"prebuild": "node scripts/build-vis-asset.mjs",
"catalog:update": "node scripts/update-catalog.mjs --out dist/built-in-catalog.json",
"smoke": "node scripts/smoke.mjs",

View file

@ -15,11 +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.
// Bundle the off-main-thread workers (the minidb text-build worker and
// the kap-server global-search worker) into self-contained ESM files so
// they can ride the SEA blob as assets (02-sea-blob.mjs) and be spawned
// from disk at runtime — bundled binaries otherwise lack the worker
// entries and heavy index work degrades to inline main-thread cores.
// Runs after the main bundle with clean:false so all verified files remain.
await run(process.execPath, [tsdownCliPath, '--config', 'tsdown.worker.config.ts']);
await run(process.execPath, [checkBundlePath]);
}

View file

@ -6,6 +6,7 @@ import { dirname, extname, isAbsolute, join, relative, resolve } from 'node:path
import { pathToFileURL } from 'node:url';
import {
KAP_SEARCH_WORKER_ASSET,
MINIDB_TEXT_BUILD_WORKER_ASSET,
NATIVE_ASSET_MANIFEST_VERSION,
buildManifestKey,
@ -272,19 +273,23 @@ 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,
const runtimeFiles = [];
for (const [fileName, asset] of [
['text-build-worker.mjs', MINIDB_TEXT_BUILD_WORKER_ASSET],
['search-worker.mjs', KAP_SEARCH_WORKER_ASSET],
]) {
const workerSource = resolve(appRoot, 'dist-native', 'intermediates', fileName);
const workerBytes = await readFile(workerSource);
const workerAssetKey = buildRuntimeAssetKey(target, asset.key);
runtimeFiles.push({
key: asset.key,
assetKey: workerAssetKey,
relativePath: MINIDB_TEXT_BUILD_WORKER_ASSET.relativePath,
relativePath: asset.relativePath,
sha256: sha256(workerBytes),
mode: MINIDB_TEXT_BUILD_WORKER_ASSET.mode,
},
];
assets[workerAssetKey] = workerSource;
mode: asset.mode,
});
assets[workerAssetKey] = workerSource;
}
const manifest = {
version: NATIVE_ASSET_MANIFEST_VERSION,

View file

@ -71,6 +71,7 @@ function checkBundle(bundlePath, { worker = false } = {}) {
const bundles = [
{ path: nativeJsBundlePath(), worker: false },
{ path: resolve(nativeIntermediatesDir(), 'text-build-worker.mjs'), worker: true },
{ path: resolve(nativeIntermediatesDir(), 'search-worker.mjs'), worker: true },
];
let failed = false;
for (const bundle of bundles) {

View file

@ -7,6 +7,12 @@ export const MINIDB_TEXT_BUILD_WORKER_ASSET = Object.freeze({
mode: 0o644,
});
export const KAP_SEARCH_WORKER_ASSET = Object.freeze({
key: 'kap-search-worker',
relativePath: 'runtime/kap-server/search-worker.mjs',
mode: 0o644,
});
export function buildManifestKey(target) {
return `native/${target}/manifest.json`;
}

View file

@ -84,6 +84,7 @@ try {
});
assertIncludes(nativeAssetOutput, `Native asset smoke passed: ${target}`, 'native asset smoke');
assertIncludes(nativeAssetOutput, 'MiniDb worker build passed', 'MiniDb worker smoke');
assertIncludes(nativeAssetOutput, 'search worker ready', 'search worker smoke');
} finally {
await rm(smokeHome, { recursive: true, force: true });
}

View file

@ -38,6 +38,7 @@ 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 { installKapSearchWorker } from './native/search-worker';
import { installNativeModuleHook } from './native/module-hook';
import { runNativeAssetSmokeIfRequested } from './native/smoke';
@ -158,6 +159,17 @@ export function main(): void {
? `minidb-worker:failed code=${workerInstall.errorCode} sha256=${workerInstall.assetSha256 ?? 'unknown'}`
: `minidb-worker:${workerInstall.status}`,
);
// Same pattern for the global-search worker: extracted from the SEA blob so
// the search index runs off the main thread; a failure leaves the search
// surface degraded (the `search_worker` flag restores the inline host).
const searchWorkerInstall = installKapSearchWorker();
startupTrace(
searchWorkerInstall.status === 'installed'
? `search-worker:installed basename=${searchWorkerInstall.basename} sha256=${searchWorkerInstall.assetSha256}`
: searchWorkerInstall.status === 'failed'
? `search-worker:failed code=${searchWorkerInstall.errorCode} sha256=${searchWorkerInstall.assetSha256 ?? 'unknown'}`
: `search-worker:${searchWorkerInstall.status}`,
);
if (runNativeAssetSmokeIfRequested()) return;
// Start the background cleanup of stale native cache. Fire-and-forget; must not block startup or throw.

View file

@ -16,6 +16,7 @@ import { join as joinPosix } from 'pathe';
import { KIMI_BUILD_INFO } from '#/cli/build-info';
import {
KAP_SEARCH_WORKER_ASSET,
MINIDB_TEXT_BUILD_WORKER_ASSET,
NATIVE_ASSET_MANIFEST_VERSION as MANIFEST_VERSION,
buildManifestKey,
@ -416,6 +417,10 @@ export function getMinidbTextBuildWorkerFile(
return getNativeRuntimeFile(MINIDB_TEXT_BUILD_WORKER_ASSET.key, options);
}
export function getKapSearchWorkerFile(options: NativeAssetOptions = {}): string | null {
return getNativeRuntimeFile(KAP_SEARCH_WORKER_ASSET.key, options);
}
export function getNativePackageRoot(
packageName: string,
options: NativeAssetOptions = {},

View file

@ -0,0 +1,72 @@
import { basename } from 'node:path';
import {
configureSearchWorkerRuntime,
getSearchWorkerRuntimeState,
} from '@moonshot-ai/kap-server/search-worker-runtime';
import { KAP_SEARCH_WORKER_ASSET } from '../../scripts/native/manifest.mjs';
import {
getEmbeddedNativeAssetManifest,
getKapSearchWorkerFile,
getSeaAssetSource,
type NativeAssetOptions,
} from './native-assets';
export type KapSearchWorkerInstallStatus =
| { 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 global-search worker without making optional
* extraction fatal. Without it the search service resolves no worker entry
* inside the single-file binary and reports the index as degraded; the
* `search_worker` experimental flag restores the in-process host.
*/
export function installKapSearchWorker(
options: NativeAssetOptions = {},
): KapSearchWorkerInstallStatus {
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 === KAP_SEARCH_WORKER_ASSET.key);
if (manifest === null || file === undefined) return { status: 'asset-missing' };
assetSha256 = file.sha256;
const workerPath = getKapSearchWorkerFile({ ...options, source, manifest });
if (workerPath === null) return { status: 'asset-missing' };
configureSearchWorkerRuntime(workerPath);
const runtime = getSearchWorkerRuntimeState();
if (!runtime.configured) throw new Error('search worker runtime was not configured');
return {
status: 'installed',
assetSha256,
basename: basename(workerPath),
};
} catch (error) {
return {
status: 'failed',
errorCode: errorCode(error),
assetSha256,
};
}
}

View file

@ -1,8 +1,11 @@
import { mkdtempSync, mkdirSync, rmSync } from 'node:fs';
import { createRequire } from 'node:module';
import { once } from 'node:events';
import { dirname, join } from 'node:path';
import { Worker } from 'node:worker_threads';
import { MiniDb } from '@moonshot-ai/minidb';
import { getSearchWorkerRuntimeState } from '@moonshot-ai/kap-server/search-worker-runtime';
import {
getEmbeddedNativeAssetManifest,
@ -74,6 +77,34 @@ async function smokeMinidbWorker(): Promise<void> {
}
}
async function smokeSearchWorker(): Promise<void> {
// The SEA-extracted global-search worker entry must boot from disk and
// complete the versioned ready handshake.
const runtime = getSearchWorkerRuntimeState();
if (!runtime.configured) {
throw new Error('search worker runtime was not configured');
}
const cacheBase = getNativeCacheBase();
mkdirSync(cacheBase, { recursive: true });
const dir = mkdtempSync(join(cacheBase, 'sea-search-worker-'));
const worker = new Worker(runtime.path, {
workerData: { dir, bootSalt: 'sea-smoke' },
});
try {
const ready = once(worker, 'message', {
signal: AbortSignal.timeout(15_000),
}) as Promise<unknown[]>;
const [event] = await ready;
const v = (event as { type?: string; v?: number }).v;
if ((event as { type?: string }).type !== 'ready' || typeof v !== 'number') {
throw new Error(`search worker handshake is unexpected: ${JSON.stringify(event)}`);
}
} finally {
await worker.terminate().catch(() => {});
rmSync(dir, { recursive: true, force: true });
}
}
async function runSmoke(): Promise<void> {
const manifest = getEmbeddedNativeAssetManifest();
if (manifest === null) throw new Error('Native asset manifest is not available.');
@ -84,7 +115,10 @@ async function runSmoke(): Promise<void> {
}
smokePiTuiNativeLoad();
await smokeMinidbWorker();
process.stdout.write(`Native asset smoke passed: ${manifest.target}; MiniDb worker build passed\n`);
await smokeSearchWorker();
process.stdout.write(
`Native asset smoke passed: ${manifest.target}; MiniDb worker build passed; search worker ready\n`,
);
}
export function runNativeAssetSmokeIfRequested(): boolean {

View file

@ -0,0 +1,41 @@
// Bundles the kap-server global-search worker
// (packages/kap-server/src/search/worker/entry.ts) into ONE self-contained
// `dist/search-worker.mjs` sibling of the main bundle. The search worker
// host resolves it at runtime next to `dist/main.mjs` (dev/tests use the TS
// source; the SEA binary uses the extracted asset from
// tsdown.worker.config.ts). Separate config because rolldown forbids
// `codeSplitting: false` with multiple inputs.
import { resolve } from 'node:path';
import { defineConfig } from 'tsdown';
const appRoot = import.meta.dirname;
export default defineConfig({
entry: {
'search-worker': resolve(
appRoot,
'../../packages/kap-server/src/search/worker/entry.ts',
),
},
format: ['esm'],
// Shares the main bundle's dist (never wipe it) and lands as
// `dist/search-worker.mjs`.
outDir: 'dist',
clean: false,
dts: false,
hash: false,
platform: 'node',
target: 'node24',
sourcemap: false,
minify: false,
silent: true,
deps: {
onlyBundle: false,
},
outputOptions: {
codeSplitting: false,
entryFileNames: '[name].mjs',
},
});

View file

@ -1,11 +1,18 @@
// Dedicated tsdown config that bundles the minidb text-build worker
// (packages/minidb/src/worker/text-build-worker.ts) and its whole import
// closure into ONE self-contained plain-JS ESM file. The SEA single-file
// binary embeds it as an asset (scripts/native/02-sea-blob.mjs) and spawns a
// real worker thread from it at runtime (src/native/minidb-worker.ts);
// without it the bundled binary has no worker entry file on disk and heavy
// text-index builds degrade to the inline main-thread core, stalling the
// event loop on large corpora.
// Dedicated tsdown config that bundles the off-main-thread workers into
// self-contained ESM files so they can ride the SEA blob as assets
// (02-sea-blob.mjs) and be spawned from disk at runtime:
// - text-build-worker.mjs: the minidb text-build worker
// (packages/minidb/src/worker/text-build-worker.ts);
// - search-worker.mjs: the kap-server global-search worker
// (packages/kap-server/src/search/worker/entry.ts).
// Without them the bundled binary lacks the worker entry files on disk and
// heavy index work degrades to the inline main-thread cores, stalling the
// event loop on large corpora. Runs after the main bundle with clean:false
// so all verified files remain.
//
// One config per entry: rolldown forbids `codeSplitting: false` with
// multiple inputs, and each worker must be a single self-contained file
// (check-bundle.mjs enforces zero remaining externals/relative imports).
import { resolve } from 'node:path';
@ -13,18 +20,31 @@ import { defineConfig } from 'tsdown';
const here = import.meta.dirname;
export default defineConfig({
entry: [resolve(here, '../../packages/minidb/src/worker/text-build-worker.ts')],
format: ['esm'],
outDir: resolve(here, 'dist-native/intermediates'),
entryFileNames: 'text-build-worker.mjs',
codeSplitting: false,
platform: 'node',
target: 'node24',
dts: false,
sourcemap: false,
minify: false,
silent: true,
// The intermediates dir also holds main.cjs & co. — never wipe it.
clean: false,
});
function workerConfig(name: string, entry: string) {
return defineConfig({
entry: { [name]: resolve(here, entry) },
format: ['esm'],
outDir: resolve(here, 'dist-native/intermediates'),
entryFileNames: '[name].mjs',
codeSplitting: false,
platform: 'node',
target: 'node24',
dts: false,
sourcemap: false,
minify: false,
silent: true,
deps: {
// Force-bundle the workspace packages the entries import
// (`@moonshot-ai/minidb` and its subpaths) so the output is
// self-contained.
alwaysBundle: [/^@moonshot-ai\//],
},
// The intermediates dir also holds main.cjs & co. — never wipe it.
clean: false,
});
}
export default [
workerConfig('text-build-worker', '../../packages/minidb/src/worker/text-build-worker.ts'),
workerConfig('search-worker', '../../packages/kap-server/src/search/worker/entry.ts'),
];

View file

@ -16,9 +16,13 @@ import { Readable, Writable } from 'node:stream';
import { ndJsonStream, type AgentConnection, type Stream } from '@agentclientprotocol/sdk';
import {
bootstrap,
drainQueryStoreDisposals,
drainSessionIndexMirror,
drainSessionMetadataWrites,
getLiveSessionById,
IAppendLogStore,
ISessionContext,
ISessionIndexMirror,
logSeed,
resolveConfigPath,
resolveKimiHome,
@ -166,7 +170,19 @@ export async function runAcpServerWithStream(
} catch {
// ignore — disposal proceeds regardless
}
// Same shutdown order as kap-server: settle queued session-metadata
// writes, then drain the session-index mirror while the query store is
// still open, so a queued summary lands in the read model.
await drainSessionMetadataWrites();
await core.accessor.get(ISessionIndexMirror).drain();
core.dispose();
// `core.dispose()` runs the mirror's and the query store's synchronous
// `dispose()`, whose drains/closes are asynchronous — await them so an
// embedding host that removes homeDir right after close() never races
// an in-flight shard close (ENOTEMPTY on teardown).
await drainSessionIndexMirror();
await drainQueryStoreDisposals();
await drainSessionMetadataWrites();
})();
return closePromise;
};

View file

@ -78,6 +78,10 @@ Business domains **do not implement persistence themselves** — they depend on
Business code must not `import 'node:fs'`, write SQL, hand-roll append-logs / atomic writes, or hold file handles. Generic Stores are named by **access pattern** (`IAppendLogStore`, `IAtomicDocumentStore`); only domain-unique Stores are named after the domain (`ISessionIndex`). See `.agents/skills/agent-core-dev/persistence.md` for the full layering rules and decision tree.
## Session index
`ISessionIndex` (`src/app/sessionIndex/`, App scope) serves session list/resume reads over two paths: the authoritative directory scan (`sessionIndexSource`, always correct, linear) and the minidb-backed derived read model (`IQueryStore` at `<home>/cache/query-store`, keyset-paged, `O(log N + limit)`), gated by the `persistence_minidb_readmodel` flag (default ON; roll back via `KIMI_CODE_EXPERIMENTAL_PERSISTENCE_MINIDB_READMODEL=false` or the `[experimental]` config section). The read model has an explicit lifecycle — `uninitialized → preparing → ready/degraded` via `prepare()`/`status()`; reads while preparing answer from the authoritative store immediately and fold the `ISessionIndexMirror` queue in for read-your-writes; the first list shares one single-flight authoritative scan with the initial projection. The query-store is structural-only — text-index definitions are rejected at definition level, so session operations never touch the global full-text index (`<home>/search-index`, owned by kap-server's search surface).
## Conversation undo
`context.undo` is the only persisted undo fact. `contextMemory/conversationTime.ts` owns the conversation clock (`isUndoAnchor` — the single tick predicate used by `computeUndoCut`, the checkpoint reducers, and the transcript reducer) and the checkpoint protocol. A wire Model whose state must follow conversation undo (todo, plan, task-notification delivery, …) **MUST** be defined with `defineCheckpointedModel` — never hand-roll the push/clear/restore reducers — which also registers it into `CHECKPOINTED_MODELS` for the undo pipeline's pre-cut depth check. World-time state (turn counters, task registries, revision counters) must stay outside checkpointed Models.

View file

@ -128,6 +128,12 @@ export interface ISessionIndexMirror {
record(summary: SessionSummary): void;
/** Summaries accepted but not yet flushed (read-your-writes window). */
pending(): readonly SessionSummary[];
/**
* Forget a session on the delete path: drop any queued summary and wait
* out an in-flight flush that may still carry it, so the caller's
* follow-up query-store delete is not resurrected by the mirror.
*/
evict(id: string): Promise<void>;
/** Flush everything currently queued; resolves with the queue empty. */
drain(): Promise<void>;
}

View file

@ -8,7 +8,10 @@
* 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.
* currently published generation. `evict()` forgets a deleted session (the
* `ISessionIndex.remove` path): reads fold the queue in for
* read-your-writes, so a queued creation must be dropped and an in-flight
* flush waited out before the store delete, or the entry is resurrected.
*
* Everything here is best-effort: a flush failure keeps the entries queued,
* backs off, and after repeated failures gives up until the next `record`
@ -110,6 +113,14 @@ export class SessionIndexMirror extends Disposable implements ISessionIndexMirro
return [...this.pendingMap.values()];
}
async evict(id: string): Promise<void> {
this.pendingMap.delete(id);
// A flush that already snapshotted this id may still be writing it;
// wait it out so the caller's store delete lands after that batch.
await this.flushing;
this.pendingMap.delete(id);
}
async drain(): Promise<void> {
this.timer.cancel();
while (this.pendingMap.size > 0) {

View file

@ -11,10 +11,24 @@
* generation; the next run clears its own stragglers before writing.
* Publishing also schedules the previous generation's drop.
*
* The initial projection coincides with the first list request (read paths
* kick `prepare()` single-flight and fall back to the authoritative scan
* while unprepared). `sharedScan()` makes that ONE scan serve both: the
* projection joins a running scan or reuses one that just settled within
* a short window instead of enumerating every session directory a second
* time. Fallback reads use `sharedScanForRead()`: they join only a scan
* that is still in flight and otherwise drive a fresh one, so a read never
* serves a settled snapshot that could predate a just-created session. (A
* joined scan may still have started before the read; the index folds the
* mirror's pending queue into the result to cover that window.) A
* projection run consumes the slot on settle, so a later re-projection
* always scans fresh.
*
* 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
* authoritative set (always fresh a stale snapshot could regress counters
* the mirror just updated), 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:
@ -46,6 +60,15 @@ import {
const WRITE_CHUNK = 500;
const SCAN_CONCURRENCY = 16;
/**
* How long a settled shared scan stays reusable BY A PROJECTION. The window
* only needs to cover the gap between a fallback read finishing its scan and
* the kicked projection reaching its own scan call (query-store open +
* collection housekeeping in between); a projection never reuses a slot
* older than this. Fallback reads never reuse a settled scan at all (see
* `sharedScanForRead`).
*/
const SHARED_SCAN_REUSE_MS = 30_000;
export interface SessionIndexProjectorDeps {
readonly storage: IFileSystemStorageService;
@ -66,11 +89,87 @@ export interface ReconcileResult {
readonly removed: number;
}
/** One consistent pass over the authoritative session metadata set. */
export interface AuthoritativeScan {
readonly summaries: SessionSummary[];
readonly counts: Map<string, { active: number; archived: number }>;
}
interface ScanSlot {
readonly promise: Promise<AuthoritativeScan>;
readonly reusableUntil: number;
settled: boolean;
}
export class SessionIndexProjector {
private scanSlot: ScanSlot | undefined;
constructor(private readonly deps: SessionIndexProjectorDeps) {}
/**
* The projection's scan: joins a running shared scan, reuses one that
* settled within the reuse window, or starts a fresh one. The projection
* publishes a point-in-time derived model by design, so a just-finished
* snapshot is safe for it (the mirror queue and reconciliation heal the
* gap) and this is what keeps a fast first read + kicked projection
* from scanning the directory tree twice.
*/
sharedScan(): Promise<AuthoritativeScan> {
const slot = this.scanSlot;
if (slot !== undefined && (!slot.settled || Date.now() < slot.reusableUntil)) {
return slot.promise;
}
return this.startScan();
}
/**
* A fallback read's scan: joins a scan that is still in flight or starts a
* fresh one. A settled snapshot is NEVER served to a read it could
* predate a session this process just created, breaking read-your-writes.
* Joining an in-flight scan is NOT the same freshness as enumerating here
* and now: the scan may have started (and passed a directory) before this
* call, so the caller folds the mirror's pending queue into the result
* every pending entry is known to be durable on disk.
*/
sharedScanForRead(): Promise<AuthoritativeScan> {
const slot = this.scanSlot;
if (slot !== undefined && !slot.settled) return slot.promise;
return this.startScan();
}
private startScan(): Promise<AuthoritativeScan> {
const slot: ScanSlot = {
promise: this.scanAuthoritative(),
reusableUntil: Date.now() + SHARED_SCAN_REUSE_MS,
settled: false,
};
const markSettled = (): void => {
slot.settled = true;
};
void slot.promise.then(markSettled, markSettled);
this.scanSlot = slot;
return slot.promise;
}
/** Scan the authoritative set into a fresh generation and publish it. */
async project(generation: number): Promise<ProjectionResult> {
// Captured BEFORE the housekeeping below: the scan overlaps it, and the
// finally invalidates exactly the slot this run consumed — never a newer
// scan a concurrent fallback read started meanwhile.
const scan = this.sharedScan();
try {
return await this.doProject(generation, scan);
} finally {
// The consumed slot must not serve a LATER projection: a re-projection
// always scans the authoritative set fresh.
if (this.scanSlot?.promise === scan) this.scanSlot = undefined;
}
}
private async doProject(
generation: number,
scan: Promise<AuthoritativeScan>,
): Promise<ProjectionResult> {
const { queryStore, log } = this.deps;
const collection = sessionCollection(generation);
const counters = sessionCountersCollection(generation);
@ -83,7 +182,7 @@ export class SessionIndexProjector {
field: `custom.${PARENT_SESSION_ID_KEY}`,
});
const { summaries, counts } = await this.scanAuthoritative();
const { summaries, counts } = await scan;
await this.batchChunks(
summaries.map((summary) => ({
kind: 'put' as const,
@ -156,10 +255,7 @@ export class SessionIndexProjector {
return result;
}
private async scanAuthoritative(): Promise<{
summaries: SessionSummary[];
counts: Map<string, { active: number; archived: number }>;
}> {
private async scanAuthoritative(): Promise<AuthoritativeScan> {
const { storage, docs, sessionsScope } = this.deps;
const summaries: SessionSummary[] = [];
const counts = new Map<string, { active: number; archived: number }>();

View file

@ -25,6 +25,17 @@
* model is never healed by per-request backfill. Degraded reads retry
* `prepare()` after a short backoff.
*
* The first list request coincides with the initial projection: the read is
* served by the authoritative fallback AND kicks `prepare()`. To keep that
* request from paying two full directory scans, the uninitialized/preparing
* fallback joins the projector's in-flight shared scan or drives the one
* the projection will reuse (`SessionIndexProjector.sharedScanForRead`) so
* one authoritative pass serves both, and the mirror's pending queue is
* folded into the result so a session created or mutated after the scan
* passed its directory still shows up. Flag-off hosts and the `degraded`
* fallback keep the targeted per-workspace enumeration (with the same
* pending fold).
*
* 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
@ -331,11 +342,14 @@ export class FileSessionIndex extends Disposable implements ISessionIndex {
* 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.
* drop the entry anyway this closes the stale-read window in between. The
* mirror queue is evicted first (waiting out an in-flight flush): reads
* fold the queue in for read-your-writes, and a late flush would otherwise
* resurrect the entry after the store delete. With the read model off
* there is no derived state to evict beyond the queue.
*/
async remove(id: string): Promise<void> {
await this.mirror.evict(id);
await this.withReadModel(
async (generation) => {
await this.queryStore.delete(sessionCollection(generation), id);
@ -635,17 +649,11 @@ export class FileSessionIndex extends Disposable implements ISessionIndex {
return { items: query.limit !== undefined ? items.slice(0, query.limit) : items };
}
const workspaceIds = query.workspaceIds ?? (await listWorkspaceIds(this.storage, this.sessionsScope));
const collected: SessionSummary[] = [];
for (const workspaceId of workspaceIds) {
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 (!summaryMatchesChildOf(summary, query.childOf)) continue;
collected.push(summary);
}
}
const collected = (await this.collectAuthoritative(query.workspaceIds)).filter(
(summary) =>
(query.includeArchived === true || !summary.archived) &&
summaryMatchesChildOf(summary, query.childOf),
);
const items = collected.toSorted(canonicalOrder);
let start = 0;
@ -678,18 +686,59 @@ export class FileSessionIndex extends Disposable implements ISessionIndex {
private async countLegacy(query: SessionCountQuery): Promise<number> {
let count = 0;
const workspaceIds =
query.workspaceIds ?? (await listWorkspaceIds(this.storage, this.sessionsScope));
for (const workspaceId of workspaceIds) {
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;
}
for (const summary of await this.collectAuthoritative(query.workspaceIds)) {
if (query.includeArchived === true || !summary.archived) count += 1;
}
return count;
}
/**
* Collect the authoritative summaries behind a legacy read. While the read
* model is enabled but not yet ready, the kicked initial projection is
* scanning the same authoritative set, so the read joins that in-flight
* scan (or drives the one the projection will reuse) instead of running a
* second full directory scan. Flag-off hosts and the degraded fallback
* keep the targeted per-workspace enumeration.
*
* Either way the mirror's pending queue is folded in by id (pending
* entries win): every queued summary was recorded only after its
* `state.json` is durable, and a scan/enumeration that started before the
* write may legitimately have passed the directory already the fold is
* what keeps read-your-writes on this path too.
*/
private async collectAuthoritative(
workspaceIds: readonly string[] | undefined,
): Promise<SessionSummary[]> {
let collected: SessionSummary[];
if (
this.readModelEnabled() &&
(this.state === 'uninitialized' || this.state === 'preparing')
) {
const { summaries } = await this.projector.sharedScanForRead();
collected =
workspaceIds === undefined
? summaries
: summaries.filter((summary) => workspaceIds.includes(summary.workspaceId));
} else {
const ids = workspaceIds ?? (await listWorkspaceIds(this.storage, this.sessionsScope));
collected = [];
for (const workspaceId of ids) {
for (const sessionId of await listSessionIds(this.storage, this.sessionsScope, workspaceId)) {
const summary = await readSessionSummary(this.docs, this.sessionsScope, workspaceId, sessionId);
if (summary !== undefined) collected.push(summary);
}
}
}
const pending = this.mirror.pending();
if (pending.length === 0) return collected;
const byId = new Map(collected.map((summary) => [summary.id, summary]));
for (const summary of pending) {
if (workspaceIds !== undefined && !workspaceIds.includes(summary.workspaceId)) continue;
byId.set(summary.id, summary);
}
return [...byId.values()];
}
private readModelEnabled(): boolean {
return this.flags.enabled(READ_MODEL_FLAG);
}

View file

@ -2,9 +2,9 @@
* `minidb` persistence backend flag contribution.
*
* Gates the minidb-backed derived read-model (`IQueryStore`) and the consumers
* that read through it. Off by default; enable via
* `KIMI_CODE_EXPERIMENTAL_PERSISTENCE_MINIDB_READMODEL` or the `[experimental]`
* config section.
* that read through it. On by default; roll back to the legacy authoritative
* read path via `KIMI_CODE_EXPERIMENTAL_PERSISTENCE_MINIDB_READMODEL=false` or
* the `[experimental]` config section.
*/
import { type FlagDefinitionInput, registerFlagDefinition } from '#/app/flag/flagRegistry';
@ -15,7 +15,7 @@ export const persistenceMiniDbReadModelFlag: FlagDefinitionInput = {
description:
'Use the minidb-backed IQueryStore as a derived read model for session indexing and wire replay.',
env: 'KIMI_CODE_EXPERIMENTAL_PERSISTENCE_MINIDB_READMODEL',
default: false,
default: true,
surface: 'core',
};

View file

@ -38,6 +38,14 @@
* cluster-wide registry, and value indexes are created `sparse` so documents
* from other collections (which lack the indexed field) are skipped.
*
* This store is a STRUCTURAL read model: stores, datetime/order columns, and
* secondary/compound indexes only. `ensureIndex` rejects text-index
* definitions a text index would pull tokenizers, postings files, and
* full-text generation builds into the session-list critical path, which is
* exactly what the search-index separation forbids here; full-text search
* lives in the kap-server search-index database (`IGlobalSearchService`),
* never in this cluster.
*
* 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
@ -146,6 +154,9 @@ export class MiniDbQueryStore extends Disposable implements IQueryStore {
}
private openFresh(): Promise<ClusterDb> {
// Answers "which database does a session-list read touch" from logs alone:
// the read model lives in this cluster, one MiniDb per shard directory.
this.log.info('minidb query-store opening', { dir: this.dir, shardCount: SHARD_COUNT });
return ClusterDb.open({
dir: this.dir,
shardCount: SHARD_COUNT,
@ -272,6 +283,11 @@ export class MiniDbQueryStore extends Disposable implements IQueryStore {
}
async ensureIndex(collection: string, def: IndexDef): Promise<void> {
if (def.kind === 'text') {
throw new Error(
`minidb query-store is a structural read model: text index "${def.name}" on collection "${collection}" is rejected; full-text search lives in the kap-server search-index database`,
);
}
const guard = `${collection}:${def.kind}:${def.name}`;
if (this.ensuredIndexes.has(guard)) return;
const name = indexName(collection, def.name);
@ -279,10 +295,8 @@ export class MiniDbQueryStore extends Disposable implements IQueryStore {
try {
if (def.kind === 'value') {
await db.createIndex(name, { field: def.field, sparse: true, unique: def.unique });
} else if (def.kind === 'compound') {
await db.createCompoundIndex(name, { groupBy: def.groupBy, orderBy: def.orderBy });
} else {
await db.createTextIndex(name, { fields: def.fields });
await db.createCompoundIndex(name, { groupBy: def.groupBy, orderBy: def.orderBy });
}
} catch (error) {
if (!(error instanceof Error) || !error.message.includes('already exists')) throw error;

View file

@ -20,7 +20,9 @@
* `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
* derived store (no mirror flush, no query-store lock), and a mirror failure
* is logged and swallowed the read model heals by reconciliation, the
* session lifecycle never sees it. 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. Queued writes
@ -160,20 +162,30 @@ export class SessionMetadata extends Service implements ISessionMetadata {
}
private mirrorToReadModel(): void {
this.mirror.record(
buildSessionSummary({
id: this.data.id,
workspaceId: this.ctx.workspaceId,
cwd: this.ctx.cwd,
title: this.data.title,
lastPrompt: this.data.lastPrompt,
createdAt: this.data.createdAt,
updatedAt: this.data.updatedAt,
archived: this.data.archived === true,
custom: this.data.custom,
lastTurnReason: this.data.lastTurnReason,
}),
);
try {
this.mirror.record(
buildSessionSummary({
id: this.data.id,
workspaceId: this.ctx.workspaceId,
cwd: this.ctx.cwd,
title: this.data.title,
lastPrompt: this.data.lastPrompt,
createdAt: this.data.createdAt,
updatedAt: this.data.updatedAt,
archived: this.data.archived === true,
custom: this.data.custom,
lastTurnReason: this.data.lastTurnReason,
}),
);
} catch (error) {
// The authoritative document is already durable at this point; a mirror
// failure only degrades the read model (reconciliation heals it) and
// must never fail the session mutation itself.
this.log.warn('session index mirror record failed; the read model heals by reconciliation', {
sessionId: this.ctx.sessionId,
error: error instanceof Error ? error.message : String(error),
});
}
}
private async load(): Promise<void> {

View file

@ -32,6 +32,7 @@ import { FileStorageService } from '#/persistence/backends/node-fs/fileStorageSe
import { IAtomicDocumentStore } from '#/persistence/interface/atomicDocumentStore';
import {
IQueryStore,
type Checkpoint,
type ColumnPageQuery,
type IQuery,
type Page,
@ -707,6 +708,89 @@ describe('FileSessionIndex (read model)', () => {
expect(unknown.items).toEqual([]);
});
it('remove evicts a queued mirror entry so a deleted session stays unlisted', async () => {
await seedSession('a', { createdAt: 1, updatedAt: 2 });
const store = build();
await store.prepare();
// Created after the projection: present only in the mirror queue.
mirror.record(summary('fresh', { createdAt: 3, updatedAt: 10 }));
const before = await store.listRecent({ workspaceIds: [workspaceId] });
expect(before.items.map((s) => s.id)).toEqual(['fresh', 'a']);
// sessionLifecycle.delete removes the authoritative doc, then evicts
// through remove(): the queued summary must not fold the session back in.
await store.remove('fresh');
expect(mirror.pending()).toEqual([]);
const after = await store.listRecent({ workspaceIds: [workspaceId] });
expect(after.items.map((s) => s.id)).toEqual(['a']);
// A later flush must not resurrect the evicted summary either.
await mirror.drain();
const settled = await store.listRecent({ workspaceIds: [workspaceId] });
expect(settled.items.map((s) => s.id)).toEqual(['a']);
});
it('remove waits out an in-flight mirror flush before deleting from the store', async () => {
await seedSession('a', { createdAt: 1, updatedAt: 2 });
let batchGate: Promise<void> | undefined;
let releaseBatch: () => void = () => {};
let notifyBatchEntered: (() => void) | undefined;
class GatedQueryStore extends MiniDbQueryStore {
override async batch(ops: readonly WriteOp[]): Promise<void> {
notifyBatchEntered?.();
const gate = batchGate;
batchGate = undefined;
if (gate !== undefined) await gate;
return super.batch(ops);
}
}
registerScopedService(
LifecycleScope.App,
IQueryStore,
GatedQueryStore,
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();
};
queryStore = host.app.accessor.get(IQueryStore);
mirror = host.app.accessor.get(ISessionIndexMirror);
const store = host.app.accessor.get(ISessionIndex) as FileSessionIndex;
await store.prepare();
// Arm the gate, queue a summary, and start a flush that parks inside
// batch while still carrying the id.
const entered = new Promise<void>((resolve) => {
notifyBatchEntered = resolve;
});
batchGate = new Promise<void>((resolve) => {
releaseBatch = resolve;
});
mirror.record(summary('fresh', { createdAt: 3, updatedAt: 10 }));
const draining = mirror.drain();
await entered;
// remove() must block behind the in-flight flush; the flush lands first,
// then the store delete erases the resurrected entry.
const removing = store.remove('fresh');
releaseBatch();
await Promise.all([removing, draining]);
const page = await store.listRecent({ workspaceIds: [workspaceId] });
expect(page.items.map((s) => s.id)).toEqual(['a']);
});
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 });
@ -765,9 +849,17 @@ describe('FileSessionIndex (read model)', () => {
const status = await store.prepare();
expect(status.state).toBe('degraded');
expect(status.degradedCount).toBe(1);
// The degraded state stays diagnosable: the reason names the failing stage.
expect(status.reason).toBe('projection failed');
// Reads still work — from the authoritative documents.
const fallback = await store.listRecent({ workspaceIds: [workspaceId] });
expect(fallback.items.map((s) => s.id)).toEqual(['b', 'a']);
// The fallback keeps serving while degraded (no silent read-model answers).
expect(store.status()).toEqual({
state: 'degraded',
reason: 'projection failed',
degradedCount: 1,
});
const recovered = await store.prepare();
expect(recovered).toEqual({ state: 'ready', generation: 1, degradedCount: 1 });
@ -880,6 +972,471 @@ describe('FileSessionIndex (read model)', () => {
expect(await store.count({ workspaceIds: [workspaceId] })).toBe(2);
});
it('the first read kicks one initial projection and shares its authoritative scan', async () => {
await seedSession('a', { title: 'a', createdAt: 1, updatedAt: 3 });
await seedSession('b', { title: 'b', createdAt: 2, updatedAt: 2 });
await seedSession('c', { title: 'c', createdAt: 3, updatedAt: 1 });
// Counts authoritative document reads: one full scan is exactly two gets
// per seeded session (the `<sessionDir>/state.json` miss plus the
// `<sessionDir>/session-meta/state.json` fallback hit). The first list
// kicks the initial projection AND falls back to the authoritative path;
// both must be served by ONE shared scan, not one scan each.
class CountingDocs extends JsonAtomicDocumentStore {
gets = 0;
override async get<T>(scope: string, key: string): Promise<T | undefined> {
this.gets += 1;
return super.get<T>(scope, key);
}
}
const fileStorage = new FileStorageService(homeDir);
const docs = new CountingDocs(fileStorage);
const host = createScopedTestHost([
stubPair(IFileSystemStorageService, fileStorage),
stubPair(IAtomicDocumentStore, docs),
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;
const [page, status, sameFlight] = await Promise.all([
store.listRecent({ workspaceIds: [workspaceId] }),
store.prepare(),
store.prepare(),
]);
expect(page.items.map((s) => s.id)).toEqual(['a', 'b', 'c']);
// Single-flight: two concurrent prepares plus the read's kick produced
// exactly one projection (generation 1, never 2).
expect(status).toEqual({ state: 'ready', generation: 1, degradedCount: 0 });
expect(sameFlight).toEqual(status);
expect(docs.gets).toBe(6);
// Warm reads come from the read model — no further document reads.
const warm = await store.listRecent({ workspaceIds: [workspaceId] });
expect(warm.items.map((s) => s.id)).toEqual(['a', 'b', 'c']);
expect(await store.count({ workspaceIds: [workspaceId] })).toBe(3);
expect(docs.gets).toBe(6);
});
it('serves authoritative reads immediately while preparing', async () => {
await seedSession('a', { title: 'a', createdAt: 1, updatedAt: 2 });
await seedSession('b', { title: 'b', createdAt: 2, updatedAt: 3 });
// Blocks the read-model open at the checkpoint read: prepare() stays in
// `preparing` until the gate is released.
class GatedQueryStore extends MiniDbQueryStore {
private gate: Promise<void> | undefined;
private openGate: (() => void) | undefined;
hold(): void {
this.gate = new Promise((resolve) => {
this.openGate = resolve;
});
}
release(): void {
this.openGate?.();
this.gate = undefined;
}
override async getCheckpoint(source: string): Promise<Checkpoint | undefined> {
await this.gate;
return super.getCheckpoint(source);
}
}
registerScopedService(
LifecycleScope.App,
IQueryStore,
GatedQueryStore,
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();
};
queryStore = host.app.accessor.get(IQueryStore);
mirror = host.app.accessor.get(ISessionIndexMirror);
const store = host.app.accessor.get(ISessionIndex) as FileSessionIndex;
(queryStore as GatedQueryStore).hold();
const preparing = store.prepare();
expect(store.status().state).toBe('preparing');
// Neither the list, the count, nor the point lookup waits for the blocked
// projection: all answer from the authoritative metadata immediately.
const page = await store.listRecent({ workspaceIds: [workspaceId] });
expect(page.items.map((s) => s.id)).toEqual(['b', 'a']);
expect(await store.count({ workspaceIds: [workspaceId] })).toBe(2);
expect((await store.get('b'))?.title).toBe('b');
expect(store.status().state).toBe('preparing');
(queryStore as GatedQueryStore).release();
const status = await preparing;
expect(status).toEqual({ state: 'ready', generation: 1, degradedCount: 0 });
const warm = await store.listRecent({ workspaceIds: [workspaceId] });
expect(warm.items.map((s) => s.id)).toEqual(['b', 'a']);
});
it('folds the mirror queue into reads that join an in-flight scan (read-your-writes)', async () => {
await seedSession('a', { title: 'a', createdAt: 1, updatedAt: 2 });
await seedSession('b', { title: 'b', createdAt: 2, updatedAt: 3 });
// Parks the shared scan mid-flight at the first document read, so a
// mutation landing while the scan is running is guaranteed to postdate
// the scan's pass over its directory.
class GatedDocs extends JsonAtomicDocumentStore {
private gate: Promise<void> | undefined;
private openGate: (() => void) | undefined;
private markFirstGet: (() => void) | undefined;
readonly firstGet = new Promise<void>((resolve) => {
this.markFirstGet = resolve;
});
hold(): void {
this.gate = new Promise((resolve) => {
this.openGate = resolve;
});
}
release(): void {
this.openGate?.();
}
override async get<T>(scope: string, key: string): Promise<T | undefined> {
this.markFirstGet?.();
await this.gate;
return super.get<T>(scope, key);
}
}
const fileStorage = new FileStorageService(homeDir);
const docs = new GatedDocs(fileStorage);
const host = createScopedTestHost([
stubPair(IFileSystemStorageService, fileStorage),
stubPair(IAtomicDocumentStore, docs),
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;
docs.hold();
const first = store.listRecent({ workspaceIds: [workspaceId] });
await docs.firstGet; // the shared scan is now parked mid-flight
// A creation and an archive flip land while the scan is running: both
// state.json documents are durable and their summaries sit in the mirror
// queue (exactly what SessionMetadata does after persisting).
await seedSession('c', { title: 'c', createdAt: 3, updatedAt: 4 });
mirror.record(summary('c', { title: 'c', createdAt: 3, updatedAt: 4 }));
mirror.record(summary('a', { archived: true, updatedAt: 5 }));
// The second read JOINS the in-flight scan; the scan's snapshot contains
// neither 'c' nor the archive flip. The pending fold must restore both.
const second = store.listRecent({ workspaceIds: [workspaceId] });
docs.release();
const [firstPage, secondPage] = await Promise.all([first, second]);
for (const page of [firstPage, secondPage]) {
expect(page.items.map((s) => s.id)).toEqual(['c', 'b']);
}
// The projection publishes the scan's snapshot (without 'c'); the queued
// mirror entries flush into it and warm reads converge on the same view.
const status = await store.prepare();
expect(status.state).toBe('ready');
await mirror.drain();
const warm = await store.listRecent({ workspaceIds: [workspaceId] });
expect(warm.items.map((s) => s.id)).toEqual(['c', 'b']);
const all = await store.listRecent({ workspaceIds: [workspaceId], includeArchived: true });
expect(all.items.map((s) => s.id)).toEqual(['a', 'c', 'b']);
});
it('never serves a settled shared scan to a fallback read', async () => {
await seedSession('a', { title: 'a', createdAt: 1, updatedAt: 2 });
await seedSession('b', { title: 'b', createdAt: 2, updatedAt: 3 });
// Holds prepare() at the checkpoint read so the state stays `preparing`
// across several reads; counts document reads to prove scan freshness.
class GatedQueryStore extends MiniDbQueryStore {
private gate: Promise<void> | undefined;
private openGate: (() => void) | undefined;
hold(): void {
this.gate = new Promise((resolve) => {
this.openGate = resolve;
});
}
release(): void {
this.openGate?.();
this.gate = undefined;
}
override async getCheckpoint(source: string): Promise<Checkpoint | undefined> {
await this.gate;
return super.getCheckpoint(source);
}
}
class CountingDocs extends JsonAtomicDocumentStore {
gets = 0;
override async get<T>(scope: string, key: string): Promise<T | undefined> {
this.gets += 1;
return super.get<T>(scope, key);
}
}
registerScopedService(
LifecycleScope.App,
IQueryStore,
GatedQueryStore,
ScopeActivation.OnDemand,
'storage',
);
const fileStorage = new FileStorageService(homeDir);
const docs = new CountingDocs(fileStorage);
const host = createScopedTestHost([
stubPair(IFileSystemStorageService, fileStorage),
stubPair(IAtomicDocumentStore, docs),
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;
(queryStore as GatedQueryStore).hold();
const preparing = store.prepare();
// The first fallback read drives a fresh scan (2 sessions × 2 gets).
const first = await store.listRecent({ workspaceIds: [workspaceId] });
expect(first.items.map((s) => s.id)).toEqual(['b', 'a']);
expect(docs.gets).toBe(4);
// Written behind the mirror's back (an external-process creation): the
// first scan is settled by now, so the second read must scan fresh and
// see it — a reused snapshot would answer [b, a] forever.
await seedSession('c', { title: 'c', createdAt: 3, updatedAt: 4 });
const second = await store.listRecent({ workspaceIds: [workspaceId] });
expect(second.items.map((s) => s.id)).toEqual(['c', 'b', 'a']);
expect(docs.gets).toBe(10); // 3 sessions × 2 gets on the second scan
// The kicked projection still avoids a redundant pass: it reuses the
// just-settled second scan within the reuse window.
(queryStore as GatedQueryStore).release();
const status = await preparing;
expect(status).toEqual({ state: 'ready', generation: 1, degradedCount: 0 });
expect(docs.gets).toBe(10);
});
it('status() walks the read-model lifecycle and stays diagnosable through degradation', async () => {
await seedSession('a', { title: 'a', createdAt: 1, updatedAt: 2 });
// Gate the checkpoint read so `preparing` is observable; fail one batch
// later so the `degraded` transition is observable in the same walk.
class GatedFlakyQueryStore extends MiniDbQueryStore {
private gate: Promise<void> | undefined;
private openGate: (() => void) | undefined;
failNextBatch = false;
hold(): void {
this.gate = new Promise((resolve) => {
this.openGate = resolve;
});
}
release(): void {
this.openGate?.();
this.gate = undefined;
}
override async getCheckpoint(source: string): Promise<Checkpoint | undefined> {
await this.gate;
return super.getCheckpoint(source);
}
override async batch(ops: readonly WriteOp[]): Promise<void> {
if (this.failNextBatch) {
this.failNextBatch = false;
throw new Error('injected projection crash');
}
return super.batch(ops);
}
}
registerScopedService(
LifecycleScope.App,
IQueryStore,
GatedFlakyQueryStore,
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();
};
queryStore = host.app.accessor.get(IQueryStore);
mirror = host.app.accessor.get(ISessionIndexMirror);
const store = host.app.accessor.get(ISessionIndex) as FileSessionIndex;
const gated = queryStore as GatedFlakyQueryStore;
// uninitialized → preparing → ready, each observable through status().
expect(store.status()).toEqual({ state: 'uninitialized', degradedCount: 0 });
gated.hold();
const preparing = store.prepare();
expect(store.status()).toEqual({ state: 'preparing', degradedCount: 0 });
gated.release();
expect(await preparing).toEqual({ state: 'ready', generation: 1, degradedCount: 0 });
// A failing re-projection with a published generation keeps serving it:
// generation failures stay local to the read model's next attempt.
gated.failNextBatch = true;
await store.reprojectNow();
expect(store.status()).toEqual({ state: 'ready', generation: 1, degradedCount: 0 });
// A store that lost its published base (corruption rebuild) degrades on
// the next prepare — diagnosably — while reads keep answering from the
// authoritative documents.
disposeHost?.();
disposeHost = undefined;
await drainSessionIndexMirror();
await drainQueryStoreDisposals();
await fsp.rm(join(homeDir, 'cache', 'query-store'), { recursive: true, force: true });
const second = build();
(queryStore as GatedFlakyQueryStore).failNextBatch = true;
const degraded = await second.prepare();
expect(degraded.state).toBe('degraded');
expect(degraded.reason).toBe('projection failed');
expect(degraded.degradedCount).toBe(1);
const fallback = await second.listRecent({ workspaceIds: [workspaceId] });
expect(fallback.items.map((s) => s.id)).toEqual(['a']);
expect(await second.prepare()).toEqual({ state: 'ready', generation: 1, degradedCount: 1 });
});
it('a restart loads the published generation instead of re-scanning', async () => {
await seedSession('a', { title: 'a', createdAt: 1, updatedAt: 3 });
await seedSession('b', { title: 'b', createdAt: 2, updatedAt: 2 });
await seedSession('c', { title: 'c', createdAt: 3, updatedAt: 1 });
const first = build();
await first.prepare();
expect(first.status()).toEqual({ state: 'ready', generation: 1, degradedCount: 0 });
disposeHost?.();
disposeHost = undefined;
await drainSessionIndexMirror();
await drainQueryStoreDisposals();
class CountingDocs extends JsonAtomicDocumentStore {
gets = 0;
override async get<T>(scope: string, key: string): Promise<T | undefined> {
this.gets += 1;
return super.get<T>(scope, key);
}
}
const fileStorage = new FileStorageService(homeDir);
const docs = new CountingDocs(fileStorage);
const host = createScopedTestHost([
stubPair(IFileSystemStorageService, fileStorage),
stubPair(IAtomicDocumentStore, docs),
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 second = host.app.accessor.get(ISessionIndex) as FileSessionIndex;
// The healthy published generation attaches directly: same generation,
// no projection, and not a single authoritative document read.
const status = await second.prepare();
expect(status).toEqual({ state: 'ready', generation: 1, degradedCount: 0 });
expect(docs.gets).toBe(0);
const warm = await second.listRecent({ workspaceIds: [workspaceId] });
expect(warm.items.map((s) => s.id)).toEqual(['a', 'b', 'c']);
expect(docs.gets).toBe(0);
});
it('the resume-startup sequence pays one scan: point lookup, projection, then warm lists', async () => {
await seedSession('a', { title: 'a', createdAt: 1, updatedAt: 3 });
await seedSession('b', { title: 'b', createdAt: 2, updatedAt: 2 });
await seedSession('c', { title: 'c', createdAt: 3, updatedAt: 1 });
class CountingDocs extends JsonAtomicDocumentStore {
gets = 0;
override async get<T>(scope: string, key: string): Promise<T | undefined> {
this.gets += 1;
return super.get<T>(scope, key);
}
}
const fileStorage = new FileStorageService(homeDir);
const docs = new CountingDocs(fileStorage);
const host = createScopedTestHost([
stubPair(IFileSystemStorageService, fileStorage),
stubPair(IAtomicDocumentStore, docs),
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;
// `kimi --resume <id>`: a targeted point lookup (2 document reads), which
// also kicks the initial projection in the background (one shared scan =
// 3 sessions × 2 gets). The TUI's later fetchSessions must then be warm.
expect((await store.get('b'))?.title).toBe('b');
expect(await store.prepare()).toEqual({ state: 'ready', generation: 1, degradedCount: 0 });
expect(docs.gets).toBe(8);
// fetchSessions after the resume: served by the read model — zero
// additional document reads, zero directory scans.
const page = await store.listRecent({ workspaceIds: [workspaceId] });
expect(page.items.map((s) => s.id)).toEqual(['a', 'b', 'c']);
expect(docs.gets).toBe(8);
});
it('the session query-store carries no full-text index artifacts', async () => {
await seedSession('a', { title: 'alpha', createdAt: 1, updatedAt: 2 });
await seedSession('b', { title: 'beta', createdAt: 2, updatedAt: 3 });
const store = build();
await store.prepare();
mirror.record(summary('c', { title: 'gamma', createdAt: 3, updatedAt: 4 }));
await mirror.drain();
expect(await store.count({ workspaceIds: [workspaceId] })).toBe(3);
// The read model is a structural store: value documents, ordered columns
// and secondary indexes only. No shard may carry a text-index definition
// sidecar, a legacy root postings file, or generation text artifacts
// (dictionary/docs/postings).
const storeDir = join(homeDir, 'cache', 'query-store');
const entries = await fsp.readdir(storeDir, { recursive: true, withFileTypes: true });
const files = entries.filter((entry) => entry.isFile()).map((entry) => entry.name);
expect(files.length).toBeGreaterThan(0);
expect(files.filter((name) => name === 'db.textindexes.json')).toEqual([]);
expect(files.filter((name) => /^db\.text-.*\.postings$/.test(name))).toEqual([]);
expect(files.filter((name) => /^text-.*\.(dictionary|postings|docs)$/.test(name))).toEqual([]);
});
// -- stage-3 performance baselines ------------------------------------------
// Medians are logged as JSON for phase-to-phase comparison, but no
// wall-clock budget gates CI: shared-runner load can inflate any fixed

View file

@ -21,6 +21,7 @@ export function stubSessionIndexMirror(): ISessionIndexMirror & {
recorded.push(summary);
},
pending: () => recorded,
evict: async () => {},
drain: async () => {},
};
}

View file

@ -81,6 +81,7 @@ function stubSessionChain(ix: TestInstantiationService, session: ISessionScopeHa
_serviceBrand: undefined,
record: () => {},
pending: () => [],
evict: () => Promise.resolve(),
drain: () => Promise.resolve(),
});
ix.stub(IWorkspaceLifecycleService, {

View file

@ -106,6 +106,7 @@ function sessionIndexMirrorStub(): ISessionIndexMirror {
_serviceBrand: undefined,
record: () => {},
pending: () => [],
evict: () => Promise.resolve(),
drain: () => Promise.resolve(),
};
}

View file

@ -9,7 +9,7 @@ import { createScopedTestHost, stubPair } from '#/_base/di/test';
import { ILogService } from '#/_base/log/log';
import { IBootstrapService } from '#/app/bootstrap/bootstrap';
import { ClusterDb } from '@moonshot-ai/minidb/cluster';
import { MiniDbQueryStore } from '#/persistence/backends/minidb/miniDbQueryStore';
import { drainQueryStoreDisposals, MiniDbQueryStore } from '#/persistence/backends/minidb/miniDbQueryStore';
import { IQueryStore } from '#/persistence/interface/queryStore';
import { stubBootstrap } from '../../../app/bootstrap/stubs';
import { stubLog } from '../../../_base/log/stubs';
@ -36,6 +36,9 @@ describe('MiniDbQueryStore', () => {
afterEach(async () => {
disposeHost?.();
disposeHost = undefined;
// The host's synchronous dispose() fires the store's async close; await it
// so the rm below never races a late shard close (ENOTEMPTY flake).
await drainQueryStoreDisposals();
await fsp.rm(homeDir, { recursive: true, force: true });
});
@ -113,18 +116,32 @@ describe('MiniDbQueryStore', () => {
expect(page2.nextCursor).toBeUndefined();
});
it('ensureIndex is idempotent across value, compound and text kinds', async () => {
it('ensureIndex is idempotent across value and compound kinds', async () => {
const store = build();
await store.put(COLLECTION, 'a', { id: 'a', ws: 'x', n: 1, body: 'hello world' });
await store.ensureIndex(COLLECTION, { kind: 'value', name: 'byWs', field: 'ws' });
await store.ensureIndex(COLLECTION, { kind: 'value', name: 'byWs', field: 'ws' });
await store.ensureIndex(COLLECTION, { kind: 'compound', name: 'byWsN', groupBy: 'ws', orderBy: 'n' });
await store.ensureIndex(COLLECTION, { kind: 'text', name: 'body', fields: ['body'] });
await store.ensureIndex(COLLECTION, { kind: 'text', name: 'body', fields: ['body'] });
await store.ensureIndex(COLLECTION, { kind: 'compound', name: 'byWsN', groupBy: 'ws', orderBy: 'n' });
const page = await store.query(COLLECTION).where({ ws: 'x' }).execute();
expect(page.items).toHaveLength(1);
});
it('rejects text indexes: the query-store is a structural read model', async () => {
const store = build();
await store.put(COLLECTION, 'a', { id: 'a', body: 'hello world' });
await expect(
store.ensureIndex(COLLECTION, { kind: 'text', name: 'body', fields: ['body'] }),
).rejects.toThrow(/structural read model/);
// The rejected definition must not lodge a partial registration.
await expect(
store.ensureIndex(COLLECTION, { kind: 'text', name: 'body', fields: ['body'] }),
).rejects.toThrow(/structural read model/);
const storeDir = join(homeDir, 'cache', 'query-store');
const shardEntries = await fsp.readdir(join(storeDir, 'shard-00'));
expect(shardEntries.filter((name) => name.includes('text'))).toEqual([]);
});
it('stores checkpoints', async () => {
const store = build();
expect(await store.getCheckpoint('wire:abc')).toBeUndefined();

View file

@ -119,6 +119,44 @@ describe('SessionMetadata', () => {
expect(mirror.recorded[0]).toMatchObject({ id: 's1', archived: false });
});
it('persists the authoritative document before recording to the mirror', async () => {
const store = ix.get(IAtomicDocumentStore);
// Read the persisted document back from inside record(): at that point
// the mutation must already be durable.
const persistedAtRecord: Promise<Record<string, unknown> | undefined>[] = [];
const baseRecord = mirror.record;
mirror.record = (summary) => {
persistedAtRecord.push(store.get<Record<string, unknown>>(META_SCOPE, 'state.json'));
baseRecord(summary);
};
const meta = ix.get(ISessionMetadata);
await meta.ready; // first-time creation records too
await meta.update({ title: 'durable-first' });
expect(persistedAtRecord).toHaveLength(2);
const [atCreate, atUpdate] = await Promise.all(persistedAtRecord);
expect(atCreate).toMatchObject({ id: 's1', archived: false });
expect(atUpdate).toMatchObject({ title: 'durable-first' });
});
it('a mirror failure degrades the read model but never fails the metadata mutation', async () => {
mirror.record = () => {
throw new Error('mirror down');
};
const meta = ix.get(ISessionMetadata);
// The creation-time record throws inside load(); the load must survive.
await meta.ready;
await meta.update({ title: 'still fine' });
expect(await meta.read()).toMatchObject({ title: 'still fine' });
// The mutation reached the authoritative document: a fresh instance reads
// it back even though every mirror record failed.
const fresh = createFreshMetadata(ix);
expect(await fresh.read()).toMatchObject({ title: 'still fine' });
});
it('persists across instances', async () => {
const meta = ix.get(ISessionMetadata);
await meta.update({ title: 'persisted' });

View file

@ -11,3 +11,11 @@ for (const key of Object.keys(process.env)) {
delete process.env[key];
}
}
// `persistence_minidb_readmodel` defaults ON in production, but the shared
// harness boots full containers against a fixed homeDir — with the read
// model engaged, every container opens the same query-store minidb (teardown
// races, mirror flush timers under fake timers). Pin the legacy path here;
// read-model suites opt in with explicit flag service overrides
// (test/app/sessionIndex).
process.env['KIMI_CODE_EXPERIMENTAL_PERSISTENCE_MINIDB_READMODEL'] = 'false';

View file

@ -290,6 +290,7 @@ function sessionIndexMirrorStub(): ISessionIndexMirror {
_serviceBrand: undefined,
record: () => {},
pending: () => [],
evict: () => Promise.resolve(),
drain: () => Promise.resolve(),
};
}

View file

@ -30,8 +30,11 @@ Implements the op-batch sequencing contract:
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 `<home>/search-index` (`IGlobalSearchService`, App scope — the write-lock holder is the indexer, other processes open read-only and catch up via WAL).
- Everything that touches the search-index MiniDb lives in the host-agnostic core (`src/search/indexCore.ts`): open/reopen (writer election, corruption rebuild), read-only freshness (fingerprint + WAL catch-up), the incremental sync pass, the bounded index-route query execution, reindex, and close. It runs in one of two hosts behind the `SearchBackend` seam: a dedicated **worker thread** (`src/search/worker/``host.ts` RPC client + `entry.ts` worker entry + `protocol.ts` contract; the default) or the **inline** in-process host (the rollback). The `search_worker` experimental flag (`KIMI_CODE_EXPERIMENTAL_SEARCH_WORKER`, default ON) selects the host at service construction. The main thread keeps only query normalization, page-token encode/decode, the sync coordinator, the live route, and hit projection; worker failure/crash surfaces as recognizable `building`/`degraded` pages — never an implicit inline fallback. Host lifecycle guards: the worker reports the write-lock token via a `lockToken` event the moment the lock is acquired (`OpenOptions.onLockAcquired`), so a mid-open crash leaves a reapable lock; an orphan detector reaps a same-pid lock line whose token belongs to no live worker of the process and restarts (never a silent permanent read-only); every RPC carries a watchdog timeout (a wedged worker is terminated and restarted with backoff); `beginClose` reaches the worker-side core via a control message so dispose never wedges behind a running sync; and the token-pinning generation is salted per worker boot, so page tokens never validate across a worker restart.
- 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'`.
- The aggregate lifecycle (stage 5) — `stopped → opening → ready → building/degraded → closing` — is diagnosable two ways: `IGlobalSearchService.status()` keeps its historical semantics (may kick/await the open + read-only refresh) but never throws — a failed open/worker answers `lifecycle.state: 'degraded'` with the error as detail — and `lifecycleReport()` is the synchronous local read that never kicks an open or spawns the worker, so it still answers during a minutes-long first open or a worker backoff (both surface on `/api/v1/debug/globalSearch/*`). A corruption-triggered rebuild is its own logged outcome (`search-index corruption detected; rebuilding from scratch`), distinct from building/degraded lines.
- 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'`. The budget VALUES are centralized and commented: per-query work budgets at the top of `src/search/searchService.ts` (mirrored as service test knobs), worker-host budgets at the top of `src/search/worker/host.ts` (ready/close/request/sync watchdogs, heap cap, crash backoff), minidb's process-wide worker slots and slice budgets in `packages/minidb/src/maintenance.ts` / `recovery.ts`. Query concurrency is structural, not a semaphore: one worker thread per service serializes all index-route CPU work, each query is individually budgeted and watchdogged, and the worker heap cap turns a pathological burst into a degraded-restart rather than a main-process stall.
- 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\<sessionId>\<hash>`, 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.
- Dev/test worker runtime: the worker entry (`src/search/worker/entry.ts`) loads from TS source under Node's native type stripping plus a repo-local `.js``.ts` resolve hook (`src/search/worker/register-dev-hooks.mjs`); the npm bundle emits `dist/search-worker.mjs` (`apps/kimi-code/tsdown.dist-worker.config.ts`); the SEA binary embeds it as the `kap-search-worker` runtime asset (`apps/kimi-code/scripts/native/manifest.mjs`), installed at startup by `apps/kimi-code/src/native/search-worker.ts`.

View file

@ -12,6 +12,10 @@
"./contract": {
"types": "./src/contract.ts",
"default": "./src/contract.ts"
},
"./search-worker-runtime": {
"types": "./src/search/worker/runtime.ts",
"default": "./src/search/worker/runtime.ts"
}
},
"scripts": {

View file

@ -72,6 +72,29 @@ export interface GlobalSearchQuery {
readonly pageToken?: string;
}
// ---- errors ----------------------------------------------------------------
export type GlobalSearchErrorReason =
| 'invalid_query'
| 'invalid_page_token'
| 'readonly_index'
| 'index_unavailable';
/**
* Service-level error with a machine-readable reason. Lives in the contract
* (not the service module) so the search-index core which also runs inside
* the search worker thread can raise it without importing the service.
*/
export class GlobalSearchError extends Error {
constructor(
readonly reason: GlobalSearchErrorReason,
message: string,
) {
super(message);
this.name = 'GlobalSearchError';
}
}
// ---- response --------------------------------------------------------------
export interface GlobalSearchHit {

View file

@ -0,0 +1,122 @@
/**
* `search` module stored document shapes of the `<homeDir>/search-index`
* minidb database (pure types, no runtime imports).
*
* Extracted from `searchService.ts` so both the main-process service (hit
* assembly, live route) and the host-agnostic index core (`indexCore.ts`
* which also runs inside the search worker thread) share one definition.
* The worker entry's import closure is loaded under Node's native type
* stripping, so every RELATIVE import in that closure uses an explicit `.ts`
* specifier.
*/
/** Cap one indexed document's text so huge pastes do not bloat the index. */
export const MAX_DOC_TEXT_CHARS = 20_000;
export interface MessageDoc {
readonly kind: 'message';
readonly sessionId: string;
readonly workspaceId: string;
readonly sessionTitle: string;
readonly agentId: string;
readonly role: 'user' | 'assistant';
readonly text: string;
readonly time: number;
/**
* 0-based turn ordinal in the transcript view (groupTurns numbering). Absent
* for docs indexed before turn tracking existed.
*/
readonly turn?: number;
/**
* Transcript step id (`t<turn>.<step>`, engine live numbering from the wire
* record's `step` field) of the step that produced this assistant text.
* Absent for user docs and docs indexed before step tracking existed.
*/
readonly stepId?: string;
}
export interface TitleDoc {
readonly kind: 'title';
readonly sessionId: string;
readonly workspaceId: string;
readonly sessionTitle: string;
/** Titles belong to the session, not an agent — always ''. */
readonly agentId: '';
readonly role: 'title';
readonly text: string;
readonly time: number;
}
// -- turn/step counter states, persisted on file metas for resume ------------
export interface TurnOpener {
readonly turn: number;
readonly anchor: boolean;
}
export interface TurnCounterState {
/** Ordinal the next opened turn will get (0-based). */
readonly next: number;
/** Whether a turn is currently open (groupTurns' `ensureTurn` gate). */
readonly hasTurn: boolean;
/** Turn openers, in order — the replay stack for `context.undo`. */
readonly openers: readonly TurnOpener[];
}
export interface StepTrackerState {
/** Current turn's step uuid → ordinal (the wire `step` field, else the fallback counter). */
readonly byUuid: Record<string, number>;
/** `step.begin` count within the current turn — the fallback ordinal source. */
readonly begins: number;
}
export interface FileMetaDoc {
readonly kind: 'fileMeta';
/** Owning session, used to drop metas when a session disappears. */
readonly sessionId: string;
/** 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 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.
* Absent in metas written before turn tracking; treated as the initial
* state, which makes a legacy meta resume mid-file with a zeroed counter
* an accepted one-time drift, self-healing on the next shrink/rescan.
*/
readonly turnState?: TurnCounterState;
/**
* Step tracker state at `offset` persisted with the watermark for the
* same resume reason as `turnState`. Absent in metas written before step
* tracking: such a file is RESCANNED from scratch (docs dropped, offset
* reset) so stepIds are all-or-nothing per file instead of drifting.
*/
readonly stepState?: StepTrackerState;
}
export interface SessionMetaDoc {
readonly kind: 'sessionMeta';
}
export interface StatsDoc {
readonly kind: 'stats';
readonly sessions: number;
readonly documents: number;
readonly lastIndexedAt: number;
}
export type SearchDoc = MessageDoc | TitleDoc | FileMetaDoc | SessionMetaDoc | StatsDoc;

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,385 @@
/**
* `search` module query shape, sort order, keyset boundaries, bounded
* collection and the shared match/confirm pass (pure logic).
*
* Extracted from `searchService.ts` so the SAME implementation backs both
* consumers:
* - the main-process service (the live transcript route), and
* - the host-agnostic index core (`indexCore.ts`), which runs the index
* route inside the search worker thread.
*
* The worker entry's import closure is loaded under Node's native type
* stripping, so every relative import here uses an explicit `.ts` specifier.
*/
import { createHash } from 'node:crypto';
import { normalizeLiteral } from '@moonshot-ai/minidb';
import {
GlobalSearchError,
type GlobalSearchIncomplete,
type GlobalSearchSource,
} from './contract.ts';
import type { MessageDoc, SearchDoc, TitleDoc } from './docs.ts';
// ---------------------------------------------------------------------------
// Normalized query (produced by the service, consumed by both routes)
// ---------------------------------------------------------------------------
export interface NormalizedQuery {
readonly query: string;
readonly mode: 'terms' | 'literal';
/**
* Literal mode only: `normalizeLiteral(query)`, computed once and reused by
* candidate confirmation and the snippet anchor. The n-gram index's query
* tokenizer applies the same normalization to the query terms, so index and
* comparison agree by construction.
*/
readonly literalQuery?: string;
/**
* Terms mode only: the query's deduplicated terms under minidb's default
* `tokenize` (the same tokenizer the 'body' text index applies to both
* sides). Computed once here so the live route's in-memory AND match agrees
* with the index route by construction. Empty when the query tokenizes to
* nothing (e.g. punctuation only) both routes then match zero docs,
* mirroring `TextIndex.search`.
*/
readonly termsQuery?: readonly string[];
readonly op: 'AND' | 'OR';
readonly container?: { readonly sessionId?: string; readonly agentId?: string };
readonly role?: 'user' | 'assistant' | 'title';
readonly startTime?: number;
readonly endTime?: number;
readonly sort: 'score' | 'time_desc' | 'time_asc';
readonly pageSize: number;
}
/** Per-query work budget knobs (service fields, forwarded to the core). */
export interface SearchBudgets {
readonly literalCandidateCap: number;
readonly maxTextHits: number;
readonly postingsVisitBudget: number;
readonly queryDeadlineMs: number;
readonly queryTextBudgetChars: number;
}
// ---------------------------------------------------------------------------
// Page-token decoding products (tokens themselves are the service's business)
// ---------------------------------------------------------------------------
/**
* 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.
*/
export type SortBoundary = readonly (number | string)[];
export 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. */
export function boundaryWidth(q: NormalizedQuery): 2 | 3 {
return q.mode !== 'literal' && q.sort === 'score' ? 3 : 2;
}
// ---------------------------------------------------------------------------
// Sort order, boundary filtering and bounded collection (both routes)
// ---------------------------------------------------------------------------
/** One matched document with its stable key and match context. */
export 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). */
export 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).
*/
export 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. */
export 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.
*/
export 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));
}
}
/** How often the match loop re-checks the deadline (candidate iterations). */
const DEADLINE_CHECK_STRIDE = 64;
/**
* 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.
*/
export function matchDocs(
q: NormalizedQuery,
docs: Iterable<{ key: string; value: SearchDoc | undefined; score: number }>,
boundary: SortBoundary | undefined,
budget: MatchBudget,
): { rows: MatchedRow[]; incomplete?: GlobalSearchIncomplete } {
const literalQuery = q.literalQuery;
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
// collisions and non-contiguous n-gram coverage can produce false
// positives. Zero false positives is the hard guarantee of literal
// mode. The match offset doubles as the snippet anchor. Deliberate
// deviation from ES: the comparison is case-insensitive (NFKC +
// lowercase), aligned with the terms tokenizer.
const at = normalizeLiteral(doc.text).indexOf(literalQuery);
if (at === -1) continue;
rows.push({ key, value: doc, score: 0, anchor: at });
} else {
rows.push({ key, value: doc, score });
}
}
return { rows };
}
/**
* 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.
*/
export function paginateRows(
q: NormalizedQuery,
page: DecodedPage,
rows: MatchedRow[],
): { pageRows: MatchedRow[]; hasMore: boolean } {
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);
}
return { pageRows, hasMore };
}
// ---------------------------------------------------------------------------
// Page tokens v2 — keyset cursor + generation, legacy v1 offset compat
// ---------------------------------------------------------------------------
/**
* 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.
*/
export function tokenFingerprint(q: NormalizedQuery, source: GlobalSearchSource): string {
const basis = JSON.stringify([
q.query,
q.mode,
q.op,
q.container?.sessionId,
q.container?.agentId,
q.role,
q.startTime,
q.endTime,
q.sort,
source,
]);
return createHash('sha256').update(basis).digest('base64url').slice(0, 16);
}
const PAGE_TOKEN_VERSION = 2;
export function encodePageToken(
q: NormalizedQuery,
source: GlobalSearchSource,
boundary: SortBoundary,
generation: string | undefined,
): string {
return Buffer.from(
JSON.stringify({ v: PAGE_TOKEN_VERSION, f: tokenFingerprint(q, source), g: generation, b: boundary }),
).toString('base64url');
}
export function decodePageToken(
q: NormalizedQuery,
source: GlobalSearchSource,
token: string | undefined,
generation: string | undefined,
): DecodedPage {
if (token === undefined) return { kind: 'first' };
let parsed: unknown;
try {
parsed = JSON.parse(Buffer.from(token, 'base64url').toString('utf8'));
} catch {
throw new GlobalSearchError('invalid_page_token', 'pageToken is malformed');
}
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new GlobalSearchError('invalid_page_token', 'pageToken is malformed');
}
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 (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 { kind: 'keyset', boundary: p.b as SortBoundary };
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,23 @@
// Resolve hook for the DEV search worker (`--experimental-transform-types`).
//
// The worker entry's import closure runs from TypeScript source in dev and
// tests. minidb's internals import sibling modules with `.js` specifiers
// while the files on disk are `.ts` (the repo builds with bundler module
// resolution), and Node's native type stripping does no specifier remapping
// — so retry a missing relative `.js` specifier as `.ts` here. Registered by
// register-dev-hooks.mjs via `--import`; never bundled (the bundled worker
// is plain JS and needs no hook).
export async function resolve(specifier, context, nextResolve) {
try {
return await nextResolve(specifier, context);
} catch (error) {
if (
error?.code === 'ERR_MODULE_NOT_FOUND' &&
(specifier.startsWith('./') || specifier.startsWith('../')) &&
/\.m?js$/.test(specifier)
) {
return nextResolve(specifier.replace(/\.m?js$/, '.ts'), context);
}
throw error;
}
}

View file

@ -0,0 +1,207 @@
/**
* `search/worker` worker-thread entry: hosts the search-index core inside
* a dedicated worker thread.
*
* Loaded by the main-process host (`host.ts`) in one of three shapes:
* - dev / tests: this `.ts` file itself, under Node's native type
* stripping plus the repo-local `.js` `.ts` resolve hook
* (`register-dev-hooks.mjs`);
* - bundled CLI (`dist/main.mjs`): the bundled `search-worker.mjs`
* sibling emitted by tsdown;
* - SEA single-file binary: the extracted runtime asset configured via
* `configureSearchWorkerRuntime` (`runtime.ts`).
*
* The whole import closure of this file must stay loadable under
* `--experimental-transform-types`: relative imports carry explicit `.ts`
* specifiers, no decorators, erasable TypeScript only. In particular it must
* NOT import the service (`searchService.ts`, decorators) or the
* agent-core-v2 barrel (`.md?raw` imports need a bundler).
*
* Concurrency: requests are handled as their messages arrive (async
* interleaving, same semantics the in-process service had). A `close`
* request first lets every in-flight request settle, then closes the core
* which drains tracked background ops (sync/refresh) before closing the db
* responds, and lets the thread exit. The host's terminate() remains the
* hard backstop.
*/
import { parentPort, workerData } from 'node:worker_threads';
import { configureTextBuildWorkerRuntime } from '@moonshot-ai/minidb/worker-runtime';
import { GlobalSearchError, type GlobalSearchErrorReason } from '../contract.ts';
import {
SearchIndexCore,
type CoreSearchParams,
type SyncSessionInput,
} from '../indexCore.ts';
import {
SEARCH_WORKER_PROTOCOL_VERSION,
type SearchWorkerData,
type SearchWorkerErrorPayload,
type SearchWorkerEvent,
type SearchWorkerOpenResult,
type SearchWorkerCall,
type SearchWorkerRequest,
} from './protocol.ts';
const data = workerData as SearchWorkerData;
// The worker owns the search-index MiniDb, so its heavy text-generation
// builds are triggered from THIS thread — give minidb the packaged
// text-build worker entry when the main process extracted one (SEA). In dev
// minidb resolves its own sibling source entry; a missing entry degrades to
// the bounded inline core inside this worker, never the main thread.
if (typeof data.textBuildWorkerPath === 'string') {
try {
configureTextBuildWorkerRuntime(data.textBuildWorkerPath);
} catch {
// Best-effort: minidb falls back to the bounded inline build in this worker.
}
}
const port = parentPort;
if (port === null) {
throw new Error('search worker entry must run inside a worker thread');
}
const post = (event: SearchWorkerEvent): void => {
port.postMessage(event);
};
const core = new SearchIndexCore({
indexDir: data.dir,
bootSalt: data.bootSalt,
log: {
info: (message, meta) => {
post({ type: 'log', level: 'info', message, meta });
},
warn: (message, meta) => {
post({ type: 'log', level: 'warn', message, meta });
},
},
// The lock token must reach the host BEFORE the heavy open work completes
// — a worker that dies mid-open would otherwise leave an unreapable lock
// (the lock line carries this process's still-alive pid).
onLockToken: (token) => {
post({ type: 'lockToken', token });
},
});
function toErrorPayload(error: unknown): SearchWorkerErrorPayload {
if (error instanceof GlobalSearchError) {
return { message: error.message, reason: error.reason as GlobalSearchErrorReason };
}
return { message: error instanceof Error ? error.message : String(error) };
}
async function dispatch(request: SearchWorkerCall): Promise<unknown> {
switch (request.type) {
case 'open': {
await core.ensureOpen();
const result: SearchWorkerOpenResult = {
readOnly: core.db?.readOnly === true,
lockToken: core.lockTokenView,
lifecycle: core.lifecycleState(),
};
return result;
}
case 'search':
return core.search(request.params as CoreSearchParams);
case 'sync':
return core.sync((request.params as { sessions: readonly SyncSessionInput[] }).sessions);
case 'refresh': {
await core.refresh();
const result: SearchWorkerOpenResult = {
readOnly: core.db?.readOnly === true,
lockToken: core.lockTokenView,
lifecycle: core.lifecycleState(),
};
return result;
}
case 'reindex': {
await core.reindex();
const result: SearchWorkerOpenResult = {
readOnly: core.db?.readOnly === true,
lockToken: core.lockTokenView,
lifecycle: core.lifecycleState(),
};
return result;
}
case 'status':
return core.status();
case 'close':
return null;
}
}
const inFlight = new Set<Promise<void>>();
let closing = false;
async function handle(request: SearchWorkerCall): Promise<void> {
if (request.v !== SEARCH_WORKER_PROTOCOL_VERSION) {
post({
id: request.id,
type: 'error',
error: {
message: `search worker protocol mismatch: host v${request.v}, worker v${SEARCH_WORKER_PROTOCOL_VERSION}`,
},
});
return;
}
if (request.type === 'close') {
// Gate the core FIRST (an in-flight sync abandons its remaining work at
// the next checkpoint), then let every in-flight request settle and
// close — the close itself drains tracked background ops. The host's
// terminate() is the backstop when a request wedges.
closing = true;
core.beginClose();
await Promise.all(inFlight);
let error: SearchWorkerErrorPayload | null = null;
try {
await core.close();
} catch (closeError) {
error = toErrorPayload(closeError);
}
if (error !== null) post({ id: request.id, type: 'error', error });
else post({ id: request.id, type: 'result', result: null });
// Releasing the port lets the thread's event loop drain and exit.
port!.close();
return;
}
if (closing) {
post({
id: request.id,
type: 'error',
error: { message: 'search service is disposed', reason: 'index_unavailable' },
});
return;
}
try {
const result = await dispatch(request);
post({ id: request.id, type: 'result', result });
} catch (error) {
post({ id: request.id, type: 'error', error: toErrorPayload(error) });
}
}
port.on('message', (value: unknown) => {
const request = value as SearchWorkerRequest;
if (request === null || typeof request !== 'object') return;
if (request.type === 'beginClose') {
// Control message (no id, no response): flip the core into closing
// state NOW so an in-flight sync abandons its remaining work at the
// next checkpoint instead of wedging the host's dispose behind it.
closing = true;
core.beginClose();
return;
}
if (typeof request.id !== 'number' || typeof request.type !== 'string') return;
const tracked = handle(request);
inFlight.add(tracked);
void tracked.finally(() => {
inFlight.delete(tracked);
});
});
post({ type: 'ready', v: SEARCH_WORKER_PROTOCOL_VERSION });

View file

@ -0,0 +1,813 @@
/**
* `search/worker` the main-process host for the search worker thread.
*
* The host owns the worker lifecycle and speaks the request/response
* protocol (`protocol.ts`); to the search service it presents the same
* surface as the inline core (`SearchIndexCore`), so the service's
* coordinator / live route / page assembly never care which host runs the
* index.
*
* Lifecycle semantics (stage 4 work items 3, 6 and 7):
* - lazy spawn with single-flight + backoff: the first RPC spawns the
* worker; spawn failures and dirty exits back off exponentially
* (500ms 10s cap) instead of hot-looping, and RPCs during the backoff
* window fail fast with a recognizable SearchWorkerError nothing
* ever falls back to running the index on the main thread implicitly
* (the explicit rollback switch is the `search_worker` flag);
* - a dirty exit rejects every in-flight request (no dangling promises),
* records the failure for the service's degraded state, and reaps the
* search-index `db.lock` but ONLY when the lock line still carries
* the dead worker's own token, so a live owner's lock is never deleted
* (the lock file's pid is unusable for this judgment because worker
* threads share the main process pid);
* - dispose() sends `close` (the worker drains in-flight requests and
* tracked background ops, closes the db and exits) with terminate() as
* the bounded backstop.
*
* Entry resolution: the configured packaged asset (SEA) the sibling
* `entry.ts` source (dev/tests, run under Node's native type stripping plus
* the repo-local `.js` `.ts` resolve hook) the bundled
* `search-worker.mjs` sibling (packaged npm layout).
*/
import fsSync from 'node:fs';
import { randomUUID } from 'node:crypto';
import { readFile, rm } from 'node:fs/promises';
import { join } from 'node:path';
import { pathToFileURL } from 'node:url';
import { Worker } from 'node:worker_threads';
import { getTextBuildWorkerRuntimeState } from '@moonshot-ai/minidb/worker-runtime';
import { GlobalSearchError } from '../contract';
import type {
CoreIndexView,
CoreLifecycleReport,
CoreSearchParams,
CoreSearchResult,
CoreStatus,
CoreSyncOutcome,
SearchCoreLog,
SyncSessionInput,
} from '../indexCore';
import {
SEARCH_WORKER_PROTOCOL_VERSION,
type SearchWorkerData,
type SearchWorkerEvent,
type SearchWorkerOpenResult,
type SearchWorkerCallType,
type SearchWorkerResultMap,
} from './protocol';
import { getSearchWorkerRuntimeState } from './runtime';
export type SearchWorkerErrorCode =
/** No worker entry file could be resolved in this installation. */
| 'runtime-unavailable'
/** Constructor/bootstrap/handshake failure (incl. protocol mismatch). */
| 'spawn-failed'
/** The worker died after a successful handshake (crash / OOM / kill). */
| 'crashed'
/** A restart is backing off; the RPC failed fast without spawning. */
| 'backoff'
/** The host is disposed. */
| 'disposed';
/**
* Recognizable worker-availability failure. The service maps it to a
* building/degraded response (searches) or the degraded state (background
* ops) it is never swallowed silently.
*/
export class SearchWorkerError extends Error {
constructor(
readonly code: SearchWorkerErrorCode,
message: string,
) {
super(message);
this.name = 'SearchWorkerError';
}
}
export interface SearchWorkerHostOptions {
/** Absolute path of the search-index database directory. */
readonly dir: string;
readonly log: SearchCoreLog;
/** Ready-handshake budget (ms). Default 15_000. */
readonly readyTimeoutMs?: number;
/** Grace period for the drain-on-close before terminate() (ms). Default 30_000. */
readonly closeTimeoutMs?: number;
/** Watchdog budget per query/lifecycle request (ms). Default 60_000. */
readonly requestTimeoutMs?: number;
/** Watchdog budget per sync/reindex request (ms). Default 30 min. */
readonly syncTimeoutMs?: number;
/** Worker heap cap, mirroring the text-build worker. Default 1024. */
readonly maxOldSpaceMb?: number;
/** Test hook: worker factory override. */
readonly workerFactory?: (entry: { url: URL; data: SearchWorkerData; execArgv: string[] }) => Worker;
}
interface WorkerEntryResolution {
readonly url: URL;
/** File-system path for packaged entries (stat-able, absolute). */
readonly execArgv: string[];
}
type PendingRequest = {
readonly method: SearchWorkerCallType;
readonly resolve: (value: unknown) => void;
readonly reject: (error: unknown) => void;
/** Absent for `close` — doDispose races it against closeTimeoutMs itself. */
readonly watchdog?: ReturnType<typeof setTimeout>;
};
const DEFAULT_READY_TIMEOUT_MS = 15_000;
const DEFAULT_CLOSE_TIMEOUT_MS = 30_000;
const DEFAULT_MAX_OLD_SPACE_MB = 1024;
/** A worker session that survived this long resets the crash backoff. */
const STABLE_SESSION_MS = 60_000;
const BACKOFF_BASE_MS = 500;
const BACKOFF_CAP_MS = 10_000;
/**
* Watchdog budgets per request. Queries and lifecycle calls are expected to
* be bounded (the query path carries its own work budgets); sync/reindex of
* a large corpus legitimately takes minutes, so their leash is long the
* watchdog exists to catch a WEDGED worker, not to police slow work.
*/
const DEFAULT_REQUEST_TIMEOUT_MS = 60_000;
const DEFAULT_SYNC_TIMEOUT_MS = 30 * 60_000;
/**
* Grace window before treating a lock line carrying THIS process's pid but
* an unknown token as an orphan: a live sibling worker's token event rides
* the same-process port and lands within milliseconds, so a token still
* unknown after the window belongs to a dead worker.
*/
const ORPHAN_LOCK_GRACE_MS = 250;
/**
* Lock tokens held by LIVE search-index holders of this process (worker
* threads share the process pid, so the pid in a lock line cannot
* distinguish a live holder from a dead one). Fed by the workers'
* `lockToken` events (and by the inline backend's core) and pruned on
* worker exit / backend dispose; the orphan-lock detector reaps a same-pid
* lock line only when its token is NOT in this set.
*/
const liveLockTokens = new Set<string>();
/** Register a held search-index lock token (inline backend's core). */
export function noteLiveLockToken(token: string): void {
liveLockTokens.add(token);
}
/** Drop a previously registered token (inline backend close/dispose). */
export function dropLiveLockToken(token: string | undefined): void {
if (token !== undefined) liveLockTokens.delete(token);
}
export class SearchWorkerHost {
private worker: Worker | null = null;
private spawnPromise: Promise<void> | null = null;
private reapPromise: Promise<void> | null = null;
private readonly requests = new Map<number, PendingRequest>();
private nextId = 1;
/** Token of the db.lock line the current/last worker published. */
private lockToken: string | undefined;
private failures = 0;
private nextRetryAfter = 0;
private lastFailure: string | null = null;
private readyAt = 0;
private exiting = false;
private exitResolve: (() => void) | null = null;
private orphanCheckScheduled = false;
/**
* The worker-side core's lifecycle as of the last RPC response that carried
* it (every response shape does: status/open/refresh/reindex directly,
* search via its index view). Feeds `lifecycleSnapshot` the local,
* round-trip-free answer for the service's status surface.
*/
private lastCoreLifecycle: CoreLifecycleReport | null = null;
constructor(private readonly options: SearchWorkerHostOptions) {}
private get log(): SearchCoreLog {
return this.options.log;
}
private get dir(): string {
return this.options.dir;
}
/** Test/diagnostic: the lock token the current/last worker reported. */
get reportedLockToken(): string | undefined {
return this.lockToken;
}
/**
* The aggregate search lifecycle from the host's LOCAL knowledge (stage 5):
* never spawns the worker, never waits on an RPC the states a wedged or
* not-yet-running worker cannot answer for itself are exactly the ones this
* must report (stopped/opening/degraded-backoff/closing). A live worker's
* state comes from the cached last response (`lastCoreLifecycle`).
*/
lifecycleSnapshot(): CoreLifecycleReport {
if (this.exiting) {
return this.worker !== null || this.spawnPromise !== null
? { state: 'closing' }
: { state: 'stopped' };
}
if (this.spawnPromise !== null) return { state: 'opening' };
if (this.worker === null) {
if (this.lastFailure !== null) {
// Crash/spawn failure with a lazy restart pending on the next RPC
// (possibly still inside the backoff window): the index is unserved.
const wait = this.nextRetryAfter - Date.now();
return {
state: 'degraded',
detail:
wait > 0
? `search worker restart backing off for ${wait}ms (last failure: ${this.lastFailure})`
: `search worker restart pending (last failure: ${this.lastFailure})`,
};
}
// Never spawned: the lazy spawn fires on the first RPC.
return { state: 'stopped' };
}
// The worker is up but no response has landed yet (its first open is
// still in flight): the core is opening.
return this.lastCoreLifecycle ?? { state: 'opening' };
}
// -- backend surface ------------------------------------------------------------
async ensureOpen(): Promise<SearchWorkerOpenResult> {
return this.call('open');
}
async search(params: CoreSearchParams): Promise<CoreSearchResult> {
return this.call('search', params);
}
async sync(sessions: readonly SyncSessionInput[]): Promise<CoreSyncOutcome> {
return this.call('sync', { sessions });
}
async refresh(): Promise<SearchWorkerOpenResult> {
return this.call('refresh');
}
async reindex(): Promise<SearchWorkerOpenResult> {
return this.call('reindex');
}
async status(): Promise<CoreStatus> {
return this.call('status');
}
/** Test hook: hard-kill the worker (crash/restart semantics). */
async killWorkerForTest(): Promise<void> {
const worker = this.worker;
if (worker === null) return;
await worker.terminate();
await this.waitForExit();
}
// -- RPC --------------------------------------------------------------------------
private async call<T extends SearchWorkerCallType>(
type: T,
params?: unknown,
): Promise<SearchWorkerResultMap[T]> {
await this.ensureWorker();
const worker = this.worker;
if (worker === null) {
throw new SearchWorkerError('crashed', 'search worker is not running');
}
return this.sendRequest(worker, type, params) as Promise<SearchWorkerResultMap[T]>;
}
private sendRequest(
worker: Worker,
type: SearchWorkerCallType,
params?: unknown,
): Promise<unknown> {
const id = this.nextId++;
return new Promise<unknown>((resolve, reject) => {
// Watchdog budget: `open` shares the long leash with sync/reindex — a
// first open over a huge corpus replays the full WAL / runs full
// recovery with no wall-clock budget of its own, and killing it at
// the short leash would discard all progress into a backoff loop.
// `close` gets no watchdog: doDispose races it against closeTimeoutMs,
// and a shorter request leash would misjudge a legitimate drain as a
// wedged worker.
let watchdog: ReturnType<typeof setTimeout> | undefined;
if (type !== 'close') {
const timeoutMs =
type === 'sync' || type === 'reindex' || type === 'open'
? this.options.syncTimeoutMs ?? DEFAULT_SYNC_TIMEOUT_MS
: this.options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
watchdog = setTimeout(() => this.onRequestTimeout(id), timeoutMs);
watchdog.unref?.();
}
this.requests.set(id, { method: type, resolve, reject, watchdog });
try {
worker.postMessage({ id, v: SEARCH_WORKER_PROTOCOL_VERSION, type, params });
} catch (error) {
this.requests.delete(id);
if (watchdog !== undefined) clearTimeout(watchdog);
reject(
new SearchWorkerError(
'crashed',
`search worker channel broke: ${error instanceof Error ? error.message : String(error)}`,
),
);
}
});
}
/**
* Watchdog: a request outlived its budget the worker is presumed wedged.
* Reject the requester and take the crash path (terminate reject the
* remaining in-flight requests reap backed-off restart).
*/
private onRequestTimeout(id: number): void {
const pending = this.requests.get(id);
if (pending === undefined) return;
this.requests.delete(id);
const message = `search worker request '${pending.method}' timed out`;
this.log.warn(`global search: ${message}; terminating the wedged worker`, { dir: this.dir });
pending.reject(new SearchWorkerError('crashed', message));
const worker = this.worker;
if (worker !== null) {
void worker.terminate().catch(() => {});
}
}
private settleRequest(id: number, error: Error | null, result: unknown): void {
const pending = this.requests.get(id);
if (pending === undefined) return;
this.requests.delete(id);
if (pending.watchdog !== undefined) clearTimeout(pending.watchdog);
if (error !== null) {
pending.reject(error);
return;
}
this.noteResult(result);
pending.resolve(result);
}
/**
* Track the worker-published db.lock token and the read-only role from any
* response carrying them; a read-only answer triggers the orphan-lock
* detector (see recoverOrphanedLock). Also refreshes the cached core
* lifecycle (`lastCoreLifecycle`) every response shape carries it:
* status/open/refresh/reindex directly, search via its index view.
*/
private noteResult(result: unknown): void {
if (result === null || typeof result !== 'object') return;
const direct = (result as { lockToken?: unknown }).lockToken;
const nested = (result as { index?: { lockToken?: unknown } }).index?.lockToken;
const token = typeof direct === 'string' ? direct : typeof nested === 'string' ? nested : undefined;
if (token !== undefined) this.lockToken = token;
const readOnly =
(result as { readOnly?: unknown }).readOnly === true ||
(result as { index?: { readOnly?: unknown } }).index?.readOnly === true;
if (readOnly) this.scheduleOrphanCheck();
const lifecycle = (result as { lifecycle?: CoreLifecycleReport }).lifecycle;
if (lifecycle !== undefined) {
this.lastCoreLifecycle = lifecycle;
return;
}
const index = (result as { index?: CoreIndexView }).index;
if (index !== undefined) {
// A search response arrived at all, so the worker is serving: the page
// view's building/ready maps directly; a degraded MESSAGE on a serving
// page keeps the lifecycle 'ready' (the message rides status()'s
// degraded field, same as the per-page surface).
this.lastCoreLifecycle = { state: index.state === 'building' ? 'building' : 'ready' };
}
}
// -- worker lifecycle --------------------------------------------------------------
private ensureWorker(): Promise<void> {
if (this.worker !== null) return Promise.resolve();
if (this.exiting) {
return Promise.reject(new SearchWorkerError('disposed', 'search worker is disposed'));
}
if (this.spawnPromise !== null) return this.spawnPromise;
const wait = this.nextRetryAfter - Date.now();
if (wait > 0) {
return Promise.reject(
new SearchWorkerError(
'backoff',
`search worker restart backing off for ${wait}ms (last failure: ${this.lastFailure ?? 'unknown'})`,
),
);
}
this.spawnPromise = this.doSpawn().finally(() => {
this.spawnPromise = null;
});
return this.spawnPromise;
}
private resolveEntry(): WorkerEntryResolution {
const configured = getSearchWorkerRuntimeState();
if (configured.configured) {
return { url: pathToFileURL(configured.path), execArgv: [] };
}
const source = new URL('./entry.ts', import.meta.url);
try {
if (fsSync.statSync(source).isFile()) {
// Dev/tests: run the TypeScript entry under Node's native type
// stripping; the repo-local resolve hook maps minidb's internal
// `.js` specifiers onto the on-disk `.ts` sources.
return {
url: source,
execArgv: [
'--experimental-transform-types',
'--disable-warning=ExperimentalWarning',
'--import',
new URL('./register-dev-hooks.mjs', import.meta.url).href,
],
};
}
} catch {
// not a source layout — try the bundled sibling
}
const bundled = new URL('./search-worker.mjs', import.meta.url);
try {
if (fsSync.statSync(bundled).isFile()) {
return { url: bundled, execArgv: [] };
}
} catch {
// fall through to the explicit failure
}
throw new SearchWorkerError(
'runtime-unavailable',
'search worker entry not found (no configured packaged asset, no sibling source, no bundled sibling)',
);
}
private async doSpawn(): Promise<void> {
// A previous worker's lock reaping must land before the new worker's
// MiniDb open evaluates the lock.
await this.reapPromise;
const entry = this.resolveEntry();
const textBuild = getTextBuildWorkerRuntimeState();
const data: SearchWorkerData = {
dir: this.dir,
bootSalt: randomUUID(),
textBuildWorkerPath:
textBuild.configured && textBuild.entry.kind === 'packaged' ? textBuild.entry.path : undefined,
};
let worker: Worker;
try {
worker =
this.options.workerFactory?.({
url: entry.url,
data,
execArgv: entry.execArgv,
}) ??
new Worker(entry.url, {
workerData: data,
execArgv: entry.execArgv,
resourceLimits: { maxOldGenerationSizeMb: this.options.maxOldSpaceMb ?? DEFAULT_MAX_OLD_SPACE_MB },
name: 'kimi-search-worker',
});
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
this.noteFailure(`spawn failed: ${message}`);
throw new SearchWorkerError('spawn-failed', `search worker failed to start: ${message}`);
}
worker.unref();
this.attach(worker);
try {
await this.awaitReady(worker);
} catch (error) {
// Never attached as current: detach without exit handling — the
// worker's own (late) exit event is stale-guarded in onExit.
this.detach();
await worker.terminate().catch(() => {});
const message = error instanceof Error ? error.message : String(error);
this.noteFailure(`handshake failed: ${message}`);
throw new SearchWorkerError('spawn-failed', `search worker handshake failed: ${message}`);
}
this.readyAt = Date.now();
this.worker = worker;
// The failure that armed the backoff is consumed: a fresh worker starts
// with a clean slate so a LATER dirty exit records its own cause (the
// backoff counters themselves are kept for the exponential math).
this.lastFailure = null;
// The cached core lifecycle belonged to the PREVIOUS worker generation:
// the new core has not even opened its db yet, and its first open over a
// large corpus can take minutes — serving the dead worker's 'ready' from
// the snapshot in that window would misreport the index as servable.
this.lastCoreLifecycle = null;
if (this.exiting) {
// beginClose()/dispose() landed while the handshake was in flight; the
// control message posted then found no worker and was dropped — forward
// it now so the worker-side core gates any pass about to start.
try {
worker.postMessage({ v: SEARCH_WORKER_PROTOCOL_VERSION, type: 'beginClose' });
} catch {
// A broken channel here is settled by the exit path.
}
}
this.log.info('global search: worker started', { dir: this.dir });
}
private attach(worker: Worker): void {
worker.on('message', (event: SearchWorkerEvent) => this.onMessage(worker, event));
worker.on('error', (error: Error) => this.onError(worker, error));
worker.on('exit', (code: number) => this.onExit(worker, code));
}
private detach(): void {
this.worker = null;
this.exitResolve?.();
this.exitResolve = null;
}
private onMessage(worker: Worker, event: SearchWorkerEvent): void {
// Ignore a stale worker's late messages (it was replaced after a
// handshake abort; only the current worker's traffic is meaningful).
if (this.worker !== worker) return;
if (event === null || typeof event !== 'object') return;
if (event.type === 'log') {
this.log[event.level](event.message, event.meta);
return;
}
if (event.type === 'lockToken') {
// The worker just acquired the write lock (posted before the heavy
// open work runs). Register it live so a mid-open crash leaves a
// reapable lock and the orphan detector never touches it.
this.lockToken = event.token;
liveLockTokens.add(event.token);
return;
}
if (event.type === 'ready') return; // consumed by awaitReady's one-shot listener
if (event.type === 'result') {
this.settleRequest(event.id, null, event.result);
return;
}
if (event.type === 'error') {
const payload = event.error;
const error =
payload.reason !== undefined
? new GlobalSearchError(payload.reason, payload.message)
: new Error(payload.message);
this.settleRequest(event.id, error, undefined);
}
}
private onError(worker: Worker, error: Error): void {
if (this.worker !== worker) return;
// The 'exit' event follows and performs the teardown; remember the cause.
this.lastFailure = `worker error: ${error.message}`;
}
private onExit(worker: Worker, code: number): void {
// A stale worker's exit must not tear down its replacement: the
// handshake-abort path detaches a worker BEFORE its exit event lands.
if (this.worker !== worker) return;
const wasExiting = this.exiting;
const sessionMs = Date.now() - this.readyAt;
const deadToken = this.lockToken;
this.detach();
this.lockToken = undefined;
if (deadToken !== undefined) liveLockTokens.delete(deadToken);
if (this.requests.size > 0) {
// During a clean close the pending requests race the worker's exit —
// they are disposed, not crashed (the close itself already settled).
const error = wasExiting
? new SearchWorkerError('disposed', 'search worker is disposed')
: new SearchWorkerError(
'crashed',
`search worker exited with in-flight requests (code ${code}${this.lastFailure !== null ? `; ${this.lastFailure}` : ''})`,
);
for (const pending of this.requests.values()) {
if (pending.watchdog !== undefined) clearTimeout(pending.watchdog);
pending.reject(error);
}
this.requests.clear();
}
if (wasExiting) return; // clean close / handshake abort — no backoff, no reaping needed
// Dirty exit. Reap the lock the dead worker published (guarded by its
// token) so the replacement worker can acquire it — the pid in the lock
// line is useless here because worker threads share the main process
// pid and it stays alive.
// A bare kill leaves no 'error'-event message: record the exit itself so
// the backoff rejection and the lifecycle report can name the failure.
this.lastFailure ??= `worker exited with code ${code}`;
this.reapPromise = this.reapLockFile(deadToken).finally(() => {
this.reapPromise = null;
});
this.failures = sessionMs > STABLE_SESSION_MS ? 1 : this.failures + 1;
const backoff = Math.min(BACKOFF_BASE_MS * 2 ** (this.failures - 1), BACKOFF_CAP_MS);
this.nextRetryAfter = Date.now() + backoff;
this.log.warn('global search: worker exited unexpectedly; restart backed off', {
code,
backoffMs: backoff,
lastFailure: this.lastFailure ?? undefined,
});
}
/** Record a spawn/handshake failure and arm the restart backoff. */
private noteFailure(message: string): void {
this.failures += 1;
const backoff = Math.min(BACKOFF_BASE_MS * 2 ** (this.failures - 1), BACKOFF_CAP_MS);
this.nextRetryAfter = Date.now() + backoff;
this.lastFailure = message;
}
/**
* Remove the search-index db.lock ONLY when it still carries the dead
* worker's own token. A lock line written by anyone else (a live worker of
* another service instance in this process, another process entirely) is
* never touched.
*/
private async reapLockFile(token: string | undefined): Promise<void> {
if (token === undefined) return;
const lockPath = join(this.dir, 'db.lock');
try {
const raw = await readFile(lockPath, 'utf8');
const parsed = JSON.parse(raw) as { pid?: unknown; token?: unknown };
if (parsed.pid === process.pid && parsed.token === token) {
await rm(lockPath, { force: true });
this.log.warn('global search: reaped the lock left by the dead worker', { dir: this.dir });
}
} catch {
// Unreadable or already gone — the next open re-evaluates from disk.
}
}
private awaitReady(worker: Worker): Promise<void> {
return new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => {
cleanup();
reject(new Error(`ready handshake timed out after ${this.options.readyTimeoutMs ?? DEFAULT_READY_TIMEOUT_MS}ms`));
}, this.options.readyTimeoutMs ?? DEFAULT_READY_TIMEOUT_MS);
timer.unref?.();
const onMessage = (event: SearchWorkerEvent): void => {
if (event?.type !== 'ready') return;
cleanup();
if (event.v !== SEARCH_WORKER_PROTOCOL_VERSION) {
reject(
new Error(
`protocol mismatch: host v${SEARCH_WORKER_PROTOCOL_VERSION}, worker v${event.v ?? 'unknown'}`,
),
);
return;
}
resolve();
};
const onError = (error: Error): void => {
cleanup();
reject(error);
};
const onExit = (code: number): void => {
cleanup();
reject(new Error(`worker exited before ready (code ${code})`));
};
const cleanup = (): void => {
clearTimeout(timer);
worker.off('message', onMessage);
worker.off('error', onError);
worker.off('exit', onExit);
};
worker.on('message', onMessage);
worker.on('error', onError);
worker.on('exit', onExit);
});
}
private waitForExit(): Promise<void> {
if (this.worker === null) return Promise.resolve();
return new Promise<void>((resolve) => {
const previous = this.exitResolve;
this.exitResolve = () => {
previous?.();
resolve();
};
});
}
// -- dispose -------------------------------------------------------------------------
/**
* Synchronously stop accepting new work (the service's dispose() calls
* this before any awaiting): every later RPC fails fast with 'disposed',
* and the worker's core is told NOW (control message) so an in-flight
* sync abandons its remaining work at the next checkpoint instead of
* wedging the dispose behind a running pass.
*/
beginClose(): void {
this.exiting = true;
try {
this.worker?.postMessage({ v: SEARCH_WORKER_PROTOCOL_VERSION, type: 'beginClose' });
} catch {
// A broken channel means the worker is already gone — nothing to notify.
}
}
/**
* A read-only answer while the lock could be an orphaned same-pid line
* (left by a dead worker whose token event never landed) schedules a
* re-check after a grace window see recoverOrphanedLock.
*/
private scheduleOrphanCheck(): void {
if (this.orphanCheckScheduled || this.exiting) return;
this.orphanCheckScheduled = true;
const timer = setTimeout(() => {
this.orphanCheckScheduled = false;
void this.recoverOrphanedLock();
}, ORPHAN_LOCK_GRACE_MS);
timer.unref?.();
}
/**
* Safety net behind the `lockToken` event: this instance is being served
* read-only while the lock line carries THIS process's pid. The line is
* legitimate only when its token belongs to a live worker of this process
* (`liveLockTokens`); anything else is an orphan left by a dead worker
* (its token event was lost with it) reap it and restart the worker so
* the next request reopens as the writer instead of silently serving a
* frozen read-only view until process exit.
*/
private async recoverOrphanedLock(): Promise<void> {
if (this.exiting || this.worker === null) return;
const lockPath = join(this.dir, 'db.lock');
let token: string;
try {
const parsed = JSON.parse(await readFile(lockPath, 'utf8')) as {
pid?: unknown;
token?: unknown;
};
if (parsed.pid !== process.pid || typeof parsed.token !== 'string') return;
token = parsed.token;
} catch {
return; // gone or unreadable — nothing to reap
}
if (liveLockTokens.has(token)) return; // a live worker of this process owns it
// Compare-then-delete: re-read the line right before removing it — a new
// owner may have replaced it since the judgment above, and deleting a
// live owner's lock would admit a second writer.
try {
const again = JSON.parse(await readFile(lockPath, 'utf8')) as {
pid?: unknown;
token?: unknown;
};
if (again.pid !== process.pid || again.token !== token) return;
if (liveLockTokens.has(token)) return;
} catch {
return; // vanished or replaced mid-check — nothing safe to reap
}
this.log.warn('global search: reaping an orphaned same-pid lock and restarting the worker', {
dir: this.dir,
});
await rm(lockPath, { force: true }).catch(() => {});
const worker = this.worker;
if (worker === null) return;
// The current worker opened read-only because of the orphan; terminate
// it — the dirty-exit path (backoff, then a lazy respawn on the next
// RPC) brings up a writer.
await worker.terminate().catch(() => {});
}
dispose(): Promise<void> {
this.exiting = true;
this.disposePromise ??= this.doDispose();
return this.disposePromise;
}
private disposePromise: Promise<void> | null = null;
private async doDispose(): Promise<void> {
// A spawn in flight must settle (successfully or not) before the close
// decision; a spawn that wins the race still gets closed below.
await this.spawnPromise?.catch(() => {});
const worker = this.worker;
if (worker === null) return;
const closeTimeoutMs = this.options.closeTimeoutMs ?? DEFAULT_CLOSE_TIMEOUT_MS;
let timeout: ReturnType<typeof setTimeout> | null = null;
try {
// The close request bypasses ensureWorker (the host is already marked
// exiting) but still rides the request multiplexer.
await Promise.race([
this.sendRequest(worker, 'close'),
new Promise<never>((_resolve, reject) => {
timeout = setTimeout(() => {
reject(new Error(`search worker close timed out after ${closeTimeoutMs}ms`));
}, closeTimeoutMs);
timeout.unref?.();
}),
]);
} catch {
// Drain failures and timeouts both fall through to the terminate backstop.
} finally {
if (timeout !== null) clearTimeout(timeout);
}
if (this.worker !== null) {
await worker.terminate().catch(() => {});
await this.waitForExit();
}
}
}

View file

@ -0,0 +1,131 @@
/**
* `search/worker` the RPC protocol between the main-process search worker
* host (`host.ts`) and the worker thread entry (`entry.ts`).
*
* One long-lived worker exclusively owns the `<homeDir>/search-index` MiniDb
* handle (open/replay, sync, refresh, queries, reindex, close). The protocol
* is a small request/response envelope over `postMessage` with a versioned
* ready handshake deliberately NOT an extension of minidb's bounded
* text-build worker (a one-shot build runner), which has a different
* lifecycle.
*
* Pure types + constants; safe to import from both sides. The worker entry's
* import closure runs under Node's native type stripping, so relative
* imports in this file's closure use explicit `.ts` specifiers.
*/
import type { GlobalSearchErrorReason } from '../contract.ts';
import type {
CoreLifecycleReport,
CoreSearchParams,
CoreSearchResult,
CoreStatus,
CoreSyncOutcome,
SyncSessionInput,
} from '../indexCore.ts';
export const SEARCH_WORKER_PROTOCOL_VERSION = 1;
/** `workerData` payload for the search worker. */
export interface SearchWorkerData {
/** Absolute path of the search-index database directory. */
readonly dir: string;
/**
* Unique-per-spawn boot identifier, mixed into the index generation the
* worker reports: page tokens issued before a worker restart NEVER
* validate against the respawned worker (its local generation counter
* restarts from 0), so mid-pagination clients get `invalid_page_token`
* and restart the search instead of drifting across two generations that
* happen to share a number.
*/
readonly bootSalt: string;
/**
* Packaged (SEA-extracted) minidb text-build worker file. The main process
* already extracted it for its own minidb; the search worker configures
* its own copy of the runtime so full-text generation builds triggered by
* the search-index MiniDb keep running in a nested worker instead of
* inline in the search worker. Absent in dev (minidb resolves its own
* sibling .ts entry) and in plain bundles (inline-in-worker fallback).
*/
readonly textBuildWorkerPath?: string;
}
// -- requests (main → worker) -------------------------------------------------
/** RPC calls: carry a request id and always produce a response. */
export type SearchWorkerCall =
| { readonly id: number; readonly v: number; readonly type: 'open' }
| { readonly id: number; readonly v: number; readonly type: 'search'; readonly params: CoreSearchParams }
| { readonly id: number; readonly v: number; readonly type: 'sync'; readonly params: { readonly sessions: readonly SyncSessionInput[] } }
| { readonly id: number; readonly v: number; readonly type: 'refresh' }
| { readonly id: number; readonly v: number; readonly type: 'reindex' }
| { readonly id: number; readonly v: number; readonly type: 'status' }
| { readonly id: number; readonly v: number; readonly type: 'close' };
export type SearchWorkerCallType = SearchWorkerCall['type'];
/**
* Control message (no id, no response): flip the worker-side core into
* closing state NOW so an in-flight sync abandons its remaining work at the
* next checkpoint the host's beginClose()/dispose() must never be wedged
* behind a running pass.
*/
export interface SearchWorkerControlMessage {
readonly v: number;
readonly type: 'beginClose';
}
/** Everything the host may post to the worker. */
export type SearchWorkerRequest = SearchWorkerCall | SearchWorkerControlMessage;
// -- responses & events (worker → main) ----------------------------------------
/** Response payload of the `open` / `refresh` / `reindex` requests. */
export interface SearchWorkerOpenResult {
readonly readOnly: boolean;
readonly lockToken?: string;
/**
* The core's lifecycle right after the call (stage 5): lets the host keep
* its cached aggregate state exact across worker (re)opens an open that
* published a still-building text base reports 'building', not 'ready'.
*/
readonly lifecycle: CoreLifecycleReport;
}
export interface SearchWorkerResultMap {
readonly open: SearchWorkerOpenResult;
readonly search: CoreSearchResult;
readonly sync: CoreSyncOutcome;
readonly refresh: SearchWorkerOpenResult;
readonly reindex: SearchWorkerOpenResult;
readonly status: CoreStatus;
readonly close: null;
}
/** Serializable failure: typed search errors keep their reason. */
export interface SearchWorkerErrorPayload {
readonly message: string;
readonly reason?: GlobalSearchErrorReason;
}
export type SearchWorkerEvent =
| { readonly type: 'ready'; readonly v: number }
| {
readonly type: 'log';
readonly level: 'info' | 'warn';
readonly message: string;
readonly meta?: Record<string, unknown>;
}
| {
/**
* Fired the moment the worker's MiniDb acquires the write lock
* BEFORE the heavy open work (WAL replay / generation load) runs. The
* host needs the token up front: a worker that dies mid-open
* otherwise leaves a lock line carrying this process's (still alive)
* pid, which minidb's pid-liveness rule can never reclaim.
*/
readonly type: 'lockToken';
readonly token: string;
}
| { readonly id: number; readonly type: 'result'; readonly result: unknown }
| { readonly id: number; readonly type: 'error'; readonly error: SearchWorkerErrorPayload };

View file

@ -0,0 +1,7 @@
// Registers the dev search worker's module hooks (see dev-hooks.mjs).
// Passed to the worker via execArgv `--import <this file>`; the specifier is
// resolved relative to this file, so the hook module rides along regardless
// of the process cwd. Never bundled.
import { register } from 'node:module';
register('./dev-hooks.mjs', import.meta.url);

View file

@ -0,0 +1,58 @@
/**
* `search/worker` packaged worker-entry registration (mirrors minidb's
* `worker-runtime.ts` for the text-build worker).
*
* In the SEA single-file binary the search worker entry exists only as an
* embedded asset; the CLI extracts it to the native-asset cache at startup
* and configures the absolute path here ONCE (see
* `apps/kimi-code/src/native/search-worker.ts`). Everywhere else the host
* resolves the entry itself (sibling `.ts` source in dev/tests, bundled
* `.mjs` sibling in the packaged npm layout), and this stays unconfigured.
*/
import fs from 'node:fs';
import path from 'node:path';
export type SearchWorkerRuntimeState =
| { readonly configured: false }
| { readonly configured: true; readonly path: string };
let configuredPath: string | null = null;
/** Configure the packaged search worker entry once during process startup. */
export function configureSearchWorkerRuntime(entry: string): SearchWorkerRuntimeState {
if (!path.isAbsolute(entry)) {
throw new TypeError('search worker entry must be an absolute path');
}
let stat: fs.Stats;
try {
stat = fs.statSync(entry);
} catch (error) {
throw new TypeError(
`search worker entry is not readable: ${error instanceof Error ? error.message : String(error)}`,
{ cause: error },
);
}
if (!stat.isFile()) {
throw new TypeError('search worker entry must be a regular file');
}
if (configuredPath !== null) {
if (configuredPath !== entry) {
throw new Error('search worker runtime is already configured');
}
return { configured: true, path: configuredPath };
}
configuredPath = entry;
return { configured: true, path: configuredPath };
}
/** Reset process-wide configuration. Intended for tests and controlled hosts. */
export function resetSearchWorkerRuntime(): void {
configuredPath = null;
}
export function getSearchWorkerRuntimeState(): SearchWorkerRuntimeState {
return configuredPath === null
? { configured: false }
: { configured: true, path: configuredPath };
}

View file

@ -1,7 +1,12 @@
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
import { mkdir, mkdtemp, rm, stat, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
// Keep these server boots on the WORKER search host (the suite-level setup
// disables the flag to spare non-search suites the background worker load):
// this file is the end-to-end coverage of the production worker path.
process.env['KIMI_CODE_EXPERIMENTAL_SEARCH_WORKER'] = '1';
import { ISessionIndex, type SessionSummary } from '@moonshot-ai/agent-core-v2';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
@ -212,3 +217,114 @@ describe('server-v2 /api/v1/search', () => {
expect(body!.data.incomplete).toBeUndefined();
});
});
/**
* Index-separation contract (phase 2): with the global search database
* unavailable, session list / get / create / cold resume keep working only
* the real full-text search request reports the outage. The sabotage below
* plants a plain FILE at `<home>/search-index`, so every search-MiniDb open
* fails for the whole server lifetime.
*/
describe('server-v2 session routes with the global search DB unavailable', () => {
let server: RunningServer | undefined;
let home: string | undefined;
let base: string;
beforeEach(async () => {
home = await mkdtemp(join(tmpdir(), 'kimi-server-v2-search-down-'));
await writeFile(join(home, 'search-index'), 'not a minidb directory', 'utf8');
});
afterEach(async () => {
if (server !== undefined) {
await server.close();
server = undefined;
}
if (home !== undefined) {
await rm(home, { recursive: true, force: true });
home = undefined;
}
});
async function boot(): Promise<void> {
server = await startServer({
hostIdentity: TEST_HOST_IDENTITY,
host: '127.0.0.1',
port: 0,
homeDir: home as string,
logLevel: 'silent',
});
base = `http://127.0.0.1:${server.port}`;
}
async function getJson<T>(path: string): Promise<Envelope<T>> {
const res = await authedFetch(server as RunningServer, base, path);
return (await res.json()) as Envelope<T>;
}
async function postJson<T>(path: string, body?: unknown): Promise<Envelope<T>> {
const res = await authedFetch(server as RunningServer, base, path, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body ?? {}),
});
return (await res.json()) as Envelope<T>;
}
it('session list / create / get / cold resume pass with the search index down', { timeout: 30_000 }, async () => {
await boot();
// create (the write path)
const created = await postJson<{ id: string }>('/api/v1/sessions', {
metadata: { cwd: home },
});
expect(created.code).toBe(0);
const id = created.data.id;
// list (the listSessions path)
const list = await getJson<{ items: { id: string }[] }>('/api/v1/sessions');
expect(list.code).toBe(0);
expect(list.data.items.map((item) => item.id)).toContain(id);
await server!.close();
server = undefined;
// A fresh server over the same home: every session is cold, so the
// messages route resumes it from authoritative metadata — the
// `--resume` / `--continue` equivalent.
await boot();
const coldList = await getJson<{ items: { id: string }[] }>('/api/v1/sessions');
expect(coldList.code).toBe(0);
expect(coldList.data.items.map((item) => item.id)).toContain(id);
const got = await getJson<{ id: string }>(`/api/v1/sessions/${id}`);
expect(got.code).toBe(0);
const messages = await getJson<{ items: unknown[] }>(`/api/v1/sessions/${id}/messages`);
expect(messages.code).toBe(0);
// The session flows never opened the search database: the sabotage file
// is still exactly what the test planted.
const probe = await stat(join(home as string, 'search-index'));
expect(probe.isFile()).toBe(true);
});
it('only the full-text search request reports the index outage', { timeout: 30_000 }, async () => {
await boot();
const created = await postJson<{ id: string }>('/api/v1/sessions', {
metadata: { cwd: home },
});
expect(created.code).toBe(0);
// The search request surfaces the failure (50001) once the boot-time
// background open has failed; until then it may answer `building`.
await expect
.poll(
async () => (await postJson<SearchPageWire>('/api/v1/search', { query: 'anything' })).code,
{ timeout: 10_000, interval: 100 },
)
.toBe(50001);
const search = await postJson<SearchPageWire>('/api/v1/search', { query: 'anything' });
expect(search.code).toBe(50001);
expect(search.msg).toContain('search index failed to open');
// Session routes stay green throughout.
const list = await getJson<{ items: unknown[] }>('/api/v1/sessions');
expect(list.code).toBe(0);
});
});

File diff suppressed because it is too large Load diff

View file

@ -1354,6 +1354,10 @@ describe('server-v2 /api/v1/sessions (minidb read model)', () => {
let home: string | undefined;
let base: string;
// The suite-level setup pins the read-model flag OFF (env outranks the
// `[experimental]` config section), so this describe re-enables it per test.
const READ_MODEL_ENV = 'KIMI_CODE_EXPERIMENTAL_PERSISTENCE_MINIDB_READMODEL';
const READ_MODEL_CONFIG = [
'default_model = "stub"',
'',
@ -1367,12 +1371,10 @@ describe('server-v2 /api/v1/sessions (minidb read model)', () => {
'model = "stub"',
'max_context_size = 1000',
'',
'[experimental]',
'persistence_minidb_readmodel = true',
'',
].join('\n');
beforeEach(async () => {
process.env[READ_MODEL_ENV] = '1';
home = await mkdtemp(join(tmpdir(), 'kimi-server-v2-sessions-rm-'));
await writeFile(join(home, 'config.toml'), READ_MODEL_CONFIG, 'utf8');
server = await startServer({
@ -1387,6 +1389,7 @@ describe('server-v2 /api/v1/sessions (minidb read model)', () => {
});
afterEach(async () => {
process.env[READ_MODEL_ENV] = 'false';
if (server !== undefined) {
await server.close();
server = undefined;
@ -1465,4 +1468,45 @@ describe('server-v2 /api/v1/sessions (minidb read model)', () => {
const relisted = await getJson<PageWire>('/api/v1/sessions?include_archive=true');
expect(relisted.body.data.items.map((s) => s.id)).toEqual([id]);
});
it('serves session routes from the authoritative store when the read model cannot open', async () => {
// Break the read model at its root: a plain FILE where the query-store
// directory must be. Boot-time prepare fails; every later access retries
// and fails the same way for the whole server lifetime.
await (server as RunningServer).close();
server = undefined;
await rm(join(home as string, 'cache', 'query-store'), { recursive: true, force: true });
await writeFile(join(home as string, 'cache', 'query-store'), 'sabotage', 'utf8');
// The boot itself must survive the read-model failure (prepare's failure
// is logged, never propagated).
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}`;
// The degradation is diagnosable through the debug surface.
const status = await getJson<{ state: string; reason?: string; degradedCount: number }>(
'/api/v1/debug/sessionIndex/status',
);
expect(status.body.data.state).toBe('degraded');
expect(status.body.data.degradedCount).toBeGreaterThan(0);
// Session lifecycle is untouched: create, list, point-lookup all answer
// from the authoritative metadata.
const created = await postJson<SessionWire>('/api/v1/sessions', {
metadata: { cwd: home as string },
});
expect(created.body.code).toBe(0);
const id = created.body.data.id;
const listed = await getJson<PageWire>('/api/v1/sessions');
expect(listed.body.data.items.some((s) => s.id === id)).toBe(true);
const fetched = await getJson<{ id: string }>(`/api/v1/sessions/${id}`);
expect(fetched.body.data.id).toBe(id);
});
});

View file

@ -1,13 +1,13 @@
/**
* 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
* The kap-server suites pin flag-off engine behavior: 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.
* whole suite scrub the env here, then pin the defaults-ON flags OFF
* below; a test that wants a flag re-enables its env var explicitly.
*/
delete process.env['KIMI_CODE_EXPERIMENTAL_FLAG'];
@ -16,3 +16,22 @@ for (const key of Object.keys(process.env)) {
delete process.env[key];
}
}
// Stage-4 note: default the `search_worker` flag OFF for server-booting
// suites. Every startServer would otherwise lazily spawn a real search
// worker thread, and in dev/test the entry loads its TypeScript closure via
// Node's type stripping — CPU-heavy enough that the accumulated background
// load pushed unrelated heavy tests (e.g. prompts image compression) past
// the 5s default timeout under full-suite parallelism. Suites that exercise
// the search surface pin their host explicitly: searchService.test.ts
// injects flag stubs per service, and searchRoute.test.ts re-enables the
// env var for its end-to-end worker coverage.
process.env['KIMI_CODE_EXPERIMENTAL_SEARCH_WORKER'] = 'false';
// Stage-6 note: default the `persistence_minidb_readmodel` flag OFF for
// server-booting suites. The flag defaults ON in production, but several
// suites assert wire semantics the read model deliberately changes
// (externally written sessions become eventually consistent). The dedicated
// read-model describe in sessions.test.ts re-enables the env var per test —
// the env source outranks the `[experimental]` config section.
process.env['KIMI_CODE_EXPERIMENTAL_PERSISTENCE_MINIDB_READMODEL'] = 'false';

View file

@ -1,6 +1,6 @@
# minidb Agent Guide
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.
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; `OpenOptions.onLockAcquired` reports the held lock token right after acquisition, before recovery work — supervisors hosting MiniDb in a worker thread need it because worker threads share the host process pid, so the pid in the lock line alone cannot drive stale reclamation), plus a larger-than-RAM full-text layer.
## Text index
@ -12,14 +12,16 @@ Derived state (store image, dt/secondary/compound indexes, text dictionary + pos
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 (`<dir>.ro-scratch/<pid>-*`, 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.
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. The whole open path is cooperative (phase 3, main-thread relief): payload verification is chunked with yields (`readGenerationFileCheckedAsync`, `verifyFileIntegrityAsync` — same error semantics as the sync originals), the store/image map construction is sliced (`Store.bulkLoadRefsAsync` — with the active-expire timer paused for the load's duration, so a mid-load reap can never diverge the map from the not-yet-rebuilt ordered index — the async image parsers, the secondary/compound `loadImageAsync`, `TextIndex.attachImageAsync`), and the WAL-delta apply yields on primitive-op + wall-clock budgets (`walApplySlicer` — a batch frame unrolls into thousands of ops, so frame-granular yielding could never bound a slice).
Open-time lifecycle telemetry — the `no-generation` / `generation-load` / `wal-catch-up` / `full-rebuild` / `ready` / `degraded` state machine plus per-phase wall-clock timings (image loads, postings crc, WAL scan/apply, full recovery, text rebuilds incl. their hosting mode) — lives in the `LifecycleTracker` (`src/lifecycle-status.ts`), fed by `lifecycle.ts` / `generation-loader.ts` / `recovery.ts` and read through `MiniDb.lifecycleStatus()`; `bench/open-lifecycle.ts` (`pnpm bench:open`) is the repeatable event-loop-delay / input-latency baseline over the four open paths (small corpus, large WAL delta, large full-text generation, corrupt generation).
## Maintenance and worker builds
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.
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 hosts the SAME bounded core inline on the main thread, while worker-slot pressure first QUEUES for a process-wide slot (`WorkerSlots.acquireBounded`, bounded by `MiniDb.textBuildSlotWaitMs`, default 30 s, abort-aware) and only a persisted drought hosts the inline core as the explicit last resort — 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.
## Async read surface
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`).
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`). Read-replica catch-up (`MiniDb.catchUpFromWal`) is cooperative too: the delta scan rides the same async scanner and the apply yields on primitive-op + wall-clock budgets (`walApplySlicer`), with calls serialized per instance so overlapping catch-ups never interleave mid-apply — a replica that fell far behind no longer stalls the host loop in one synchronous scan+apply.

View file

@ -0,0 +1,367 @@
// bench/open-lifecycle.ts
//
// Phase-1 baseline (plan/01-baseline-and-boundaries.md): what MiniDb's open()
// costs the host process, per lifecycle path. Four scenario families —
// 1. small corpus with a healthy published generation;
// 2. a large WAL delta past the generation checkpoint (wal-catch-up);
// 3. a large full-text generation (store image + text images + postings crc);
// 4. a corrupt generation (the full-recovery fallback, then the deferred
// background text rebuild: the 'degraded' → 'ready' window)
// each measuring the open's wall time, event-loop delay, simulated input
// latency (a setImmediate probe racing the operation — how fast the loop
// services a fresh macrotask, i.e. a keypress handler), and memory peaks, and
// recording the lifecycleStatus() phase breakdown so a generation attach can
// be told apart from a full rebuild by numbers, not by inference.
//
// Run: node --import tsx bench/open-lifecycle.ts (full sizes)
// node --import tsx bench/open-lifecycle.ts --quick (smoke sizes)
// node --import tsx bench/open-lifecycle.ts --json .tmp/open-lifecycle.json
//
// Knobs (env): BENCH_SEED.
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';
import { GEN_BUILD_WAL_DELTA_BYTES } from '../src/generation-builder.js';
const fmt = (n) => n.toLocaleString('en-US', { maximumFractionDigits: 0 });
const MIB = 1024 * 1024;
async function tmpDir() {
return fs.mkdtemp(path.join(os.tmpdir(), 'minidb-open-bench-'));
}
// ---- fixed-seed synthetic data (same generator family as bench.ts) ----------
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 = ['持久化', '快照', '索引', '恢复', '压缩', '查询', '缓存', '日志', '事务', '复制'];
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));
if (i % 97 === 0) words.push('walrus');
if (i % 131 === 0) words.push('持久化');
docs.push({ key: `m${i}`, body: words.join(' '), ts: 1_700_000_000_000 + i * 1000 });
}
return docs;
}
// ---- measurement machinery (same conventions as bench.ts) -------------------
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 {
count: sorted.length,
p50: percentileOf(sorted, 50),
p95: percentileOf(sorted, 95),
p99: percentileOf(sorted, 99),
max: sorted[sorted.length - 1],
};
}
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 finite = (n) => (Number.isFinite(n) ? Math.round(n * 1000) / 1000 : 0);
/** Simulated input latency: how long a fresh macrotask (a keypress handler's
* setImmediate) waits while the measured operation holds the event loop. */
async function probeInputLatency(stop) {
const samples = [];
while (!stop.done) {
const t = performance.now();
await new Promise((r) => setImmediate(r));
samples.push(performance.now() - t);
}
return samples;
}
const results = [];
/** Measure one operation (an open, or a degradedready wait): wall time,
* event-loop delay, input-latency probes, memory peaks. */
async function measure(name, op, extra) {
if (global.gc) global.gc();
const eld = monitorEventLoopDelay();
const mem = new MemSampler();
const stop = { done: false };
eld.enable();
mem.start();
const probe = probeInputLatency(stop);
const t0 = performance.now();
const out = (await op()) || {};
const durationMs = performance.now() - t0;
stop.done = true;
const inputSamples = await probe;
const memPeaks = mem.stop();
eld.disable();
const row = {
name,
durationMs: Math.round(durationMs * 1000) / 1000,
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),
},
inputLatencyMs: latencySummary(inputSamples.map(finite)),
peakRssBytes: memPeaks.peakRssBytes,
peakHeapUsedBytes: memPeaks.peakHeapUsedBytes,
extra: { ...extra, ...out.extra },
};
results.push(row);
const input = row.inputLatencyMs;
console.log(
` ${name.padEnd(52)} ${durationMs.toFixed(1).padStart(9)} ms` +
` [eld p99 ${row.eventLoopDelayMs.p99.toFixed(1)} / max ${row.eventLoopDelayMs.max.toFixed(1)} ms` +
(input ? `, input p99 ${input.p99.toFixed(1)} / max ${input.max.toFixed(1)} ms` : '') +
`, rss ${(row.peakRssBytes / MIB).toFixed(0)} MiB]`,
);
return row;
}
/** The lifecycle fields every open row reports (rounded for a stable JSON). */
function lifecycleExtra(db) {
const status = db.lifecycleStatus();
const phases = Object.fromEntries(Object.entries(status.phases).map(([k, v]) => [k, finite(v)]));
return {
state: status.state,
path: status.path,
textIndexes: status.textIndexes,
pendingTextIndexes: status.pendingTextIndexes,
phases,
generationLoads: db.stats.generationLoads,
generationLoadFallbacks: db.stats.generationLoadFallbacks,
walDeltaAppliedOps: db.recoveryInfo?.walDeltaAppliedOps ?? 0,
recoveryBytes: db.stats.recoveryBytes,
recoveryFrames: db.stats.recoveryFrames,
indexRebuildDecoded: db.stats.indexRebuildDecoded,
};
}
// ---- scenario helpers ---------------------------------------------------------
/** Populate a corpus with word + ngram text indexes and publish a generation.
* The runtime wal-growth trigger is suppressed so exactly ONE generation
* (the explicit rebuild below) is ever published the corrupt scenario's
* candidate set stays deterministic at any corpus size. */
async function populateWithGeneration(dir, docs) {
const db = await MiniDb.open({ dir, valueCodec: 'json', fsyncPolicy: 'no', autoCompact: false });
db.genBuildKickMinIntervalMs = Number.POSITIVE_INFINITY;
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' });
await db.rebuildGeneration();
await db.close();
}
const OPEN_OPTS = { valueCodec: 'json', fsyncPolicy: 'everysec', autoCompact: false };
/** Scenario 1: a healthy generation attach over a small corpus. */
async function smallCorpusScenario({ docs }) {
const dir = await tmpDir();
await populateWithGeneration(dir, docs);
let db;
await measure(`open small corpus, healthy generation, N=${fmt(docs.length)}`, async () => {
db = await MiniDb.open({ dir, ...OPEN_OPTS });
return { extra: { docs: docs.length, ...lifecycleExtra(db) } };
});
await db.close();
await fs.rm(dir, { recursive: true, force: true });
}
/** Scenario 2: a published generation plus a WAL delta just below the
* close-time republish threshold the open is generation-load + wal-catch-up. */
async function largeWalScenario({ docs, deltaBytes }) {
const dir = await tmpDir();
await populateWithGeneration(dir, docs);
// Append a WAL delta past the checkpoint WITHOUT crossing
// GEN_BUILD_WAL_DELTA_BYTES, so neither the wal-growth trigger nor the
// close-time publish refresh the generation: the next open must replay it.
const target = Math.min(deltaBytes, Math.floor(GEN_BUILD_WAL_DELTA_BYTES * 0.8));
let deltaOps = 0;
let closePublished = false;
{
const db = await MiniDb.open({ dir, valueCodec: 'json', fsyncPolicy: 'no', autoCompact: false });
const generation = db.getIndexGeneration()?.id ?? null;
const writtenBase = db.stats.walBytesWritten;
let i = 0;
while (db.stats.walBytesWritten - writtenBase < target) {
const ops = [];
for (let k = 0; k < 100; k++) {
ops.push({ op: 'set', key: `d${i * 100 + k}`, value: { body: `delta ${i} ${k} ${'y'.repeat(200)}`, ts: 1_700_100_000_000 + i * 100 + k } });
}
await db.batch(ops);
i++;
}
deltaOps = i * 100;
await db.close();
// A close-time republish would invalidate the scenario (nothing left to
// replay): verify the WAL delta survived below the threshold.
closePublished = db.getIndexGeneration()?.id !== generation;
}
let db;
await measure(`open with large WAL delta (${fmt(target)} B), N=${fmt(docs.length)}`, async () => {
db = await MiniDb.open({ dir, ...OPEN_OPTS });
return { extra: { docs: docs.length, deltaBytes: target, deltaOps, closePublishedGeneration: closePublished, ...lifecycleExtra(db) } };
});
await db.close();
await fs.rm(dir, { recursive: true, force: true });
}
/** Scenario 3: a large full-text generation store image + text images +
* the whole-file postings crc dominate the open. */
async function largeGenerationScenario({ docs }) {
const dir = await tmpDir();
await populateWithGeneration(dir, docs);
let db;
await measure(`open large full-text generation, N=${fmt(docs.length)}`, async () => {
db = await MiniDb.open({ dir, ...OPEN_OPTS });
return { extra: { docs: docs.length, ...lifecycleExtra(db) } };
});
await db.close();
await fs.rm(dir, { recursive: true, force: true });
}
/** Scenario 4: a corrupt generation the open falls back to the legacy full
* recovery, returns 'degraded' (the corpus-scale text rebuild is deferred),
* and reaches 'ready' when the background bounded build commits. */
async function corruptGenerationScenario({ docs, readyTimeoutMs }) {
const dir = await tmpDir();
await populateWithGeneration(dir, docs);
// Flip one byte inside EVERY generation's store image: the manifest crc
// rejects every candidate and the next open must take the legacy full
// recovery (retention keeps a previous generation around as a fallback
// candidate — corrupting only the newest would measure THAT path instead).
{
const gens = path.join(dir, 'generations');
for (const entry of await fs.readdir(gens)) {
if (entry.includes('.tmp-')) continue;
const storeImage = path.join(gens, entry, 'store');
const raw = await fs.readFile(storeImage);
raw[Math.floor(raw.length / 2)] ^= 0xff;
await fs.writeFile(storeImage, raw);
}
}
let db;
await measure(`open with corrupt generation (full-recovery fallback), N=${fmt(docs.length)}`, async () => {
db = await MiniDb.open({ dir, ...OPEN_OPTS });
return { extra: { docs: docs.length, ...lifecycleExtra(db) } };
});
await measure(`deferred text rebuild (degraded -> ready), N=${fmt(docs.length)}`, async () => {
const t0 = Date.now();
while (db.lifecycleStatus().state !== 'ready') {
if (Date.now() - t0 > readyTimeoutMs) throw new Error(`timed out waiting for ready (state=${db.lifecycleStatus().state})`);
await new Promise((r) => setTimeout(r, 5));
}
return {
extra: {
docs: docs.length,
...lifecycleExtra(db),
textDeferredBuilds: db.stats.textDeferredBuilds,
textDeferredBuildErrors: db.stats.textDeferredBuildErrors,
textWorkerBuilds: db.stats.textWorkerBuilds,
textWorkerFallbacks: db.stats.textWorkerFallbacks,
},
};
});
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 sizes = quick
? { small: 2_000, walCorpus: 5_000, walDeltaBytes: 1.5 * MIB, largeGen: 8_000, corrupt: 8_000, readyTimeoutMs: 120_000 }
: { small: 20_000, walCorpus: 50_000, walDeltaBytes: 3 * MIB, largeGen: 200_000, corrupt: 100_000, readyTimeoutMs: 600_000 };
console.log(`\nminidb open-lifecycle baseline (seed=${SEED}${quick ? ', QUICK' : ''}, node ${process.version})\n`);
await smallCorpusScenario({ docs: makeMessages(sizes.small, SEED) });
await largeWalScenario({ docs: makeMessages(sizes.walCorpus, SEED), deltaBytes: sizes.walDeltaBytes });
await largeGenerationScenario({ docs: makeMessages(sizes.largeGen, SEED) });
await corruptGenerationScenario({ docs: makeMessages(sizes.corrupt, SEED), readyTimeoutMs: sizes.readyTimeoutMs });
const report = {
schemaVersion: 1,
tool: 'minidb/bench/open-lifecycle',
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');
}
main().catch((e) => {
console.error(e);
process.exit(1);
});

View file

@ -49,6 +49,7 @@
"test": "vitest run",
"bench": "node --import tsx bench/bench.ts",
"bench:cluster": "node --import tsx bench/cluster.ts",
"bench:open": "node --import tsx bench/open-lifecycle.ts",
"clean": "rm -rf dist"
}
}

View file

@ -292,4 +292,33 @@ export class CompoundIndexManager {
entry.groups = groups;
entry.byPk = byPk;
}
/** Sliced variant of loadImage (the open-time main-thread path): identical
* resulting state, but the group/byPk map construction yields to the
* event loop every `sliceEvery` entries. The state swap itself stays one
* synchronous segment (the containers are detached until then), and the
* store is not published until open() returns, so a mid-load yield is
* never observable. */
async loadImageAsync(image: CompoundImageIndex, opts: { sliceEvery?: number } = {}): Promise<void> {
const sliceEvery = opts.sliceEvery ?? 32768;
const entry = this.indexes.get(image.name);
if (!entry) throw new Error(`no such compound index: ${image.name}`);
const groups = new Map<unknown, SkipList<unknown, string>>();
const byPk = new Map<string, { group: unknown; order: unknown }>();
let n = 0;
for (const g of image.groups) {
const list = await SkipList.bulkLoadAsync<unknown, string>(
g.entries.map((e) => ({ key: e.order, val: e.pk })),
{ compareKey: entry.cmp, compareVal: cmpString },
{ sliceEvery },
);
groups.set(g.group, list);
for (const e of g.entries) {
byPk.set(e.pk, { group: g.group, order: e.order });
if (++n % sliceEvery === 0) await new Promise((r) => setImmediate(r));
}
}
entry.groups = groups;
entry.byPk = byPk;
}
}

View file

@ -392,6 +392,8 @@ export async function readGenerationFileCheckedAsync(
return new ByteReader(buf.subarray(8, buf.length - 4));
}
const yieldToLoop = (): Promise<void> => new Promise((r) => setImmediate(r));
/** 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,
@ -417,6 +419,36 @@ export function verifyFileIntegritySync(path: string, expected: { bytes: number;
}
}
/** The read/verify chunk size of verifyFileIntegrityAsync: one crc32 slice per
* chunk, then a yield a large postings file verifies in bounded ~ms
* slices instead of one synchronous pass. */
const VERIFY_CHUNK_BYTES = 1 << 20;
/** Async sliced variant of verifyFileIntegritySync (the open-time main-thread
* path): bounded 1 MiB positioned reads, a crc32 slice and an event-loop
* yield per chunk, so verifying a large postings file never blocks the loop
* for the whole pass. Identical error semantics. */
export async function verifyFileIntegrityAsync(path: string, expected: { bytes: number; crc32: number }): Promise<void> {
const fh = await fs.open(path, 'r');
try {
const st = await fh.stat();
if (st.size !== expected.bytes) throw new GenerationCorruptError('file size does not match manifest record');
let crc = 0;
const buf = Buffer.allocUnsafe(Math.min(VERIFY_CHUNK_BYTES, Math.max(st.size, 1)));
let pos = 0;
while (pos < st.size) {
const { bytesRead } = await fh.read(buf, 0, Math.min(buf.length, st.size - pos), pos);
if (bytesRead === 0) throw new GenerationCorruptError('file shrank during integrity check');
crc = crc32(buf.subarray(0, bytesRead), crc);
pos += bytesRead;
await yieldToLoop();
}
if ((crc >>> 0) !== expected.crc32) throw new GenerationCorruptError('file crc does not match manifest record');
} finally {
await fh.close().catch(() => {});
}
}
// ---- store image ------------------------------------------------------------
const STORE_MAGIC = 'MDGS';
@ -670,6 +702,52 @@ export function readSecondaryIndexImage(r: ByteReader): SecondaryImageIndex[] {
return out;
}
/** Sliced variant of readSecondaryIndexImage (stage 6): identical result,
* with event-loop yields every `yieldEvery` parsed entries so a large image
* never parses in one synchronous run. */
export async function readSecondaryIndexImageAsync(r: ByteReader, yieldEvery = 32768): Promise<SecondaryImageIndex[]> {
const count = r.u32();
const out: SecondaryImageIndex[] = [];
let n = 0;
const tick = async (): Promise<void> => {
if (++n % yieldEvery === 0) await yieldToLoop();
};
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());
await tick();
}
equality.push({ scalarKey, pks });
}
} else {
range = [];
const m = r.u64();
for (let j = 0; j < m; j++) {
range.push({ value: r.f64(), pk: r.key() });
await tick();
}
}
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';
@ -781,6 +859,41 @@ export function readCompoundIndexImage(r: ByteReader): CompoundImageIndex[] {
return out;
}
/** Sliced variant of readCompoundIndexImage (stage 6): identical result, with
* event-loop yields every `yieldEvery` parsed entries. */
export async function readCompoundIndexImageAsync(r: ByteReader, yieldEvery = 32768): Promise<CompoundImageIndex[]> {
const count = r.u32();
const out: CompoundImageIndex[] = [];
let n = 0;
const tick = async (): Promise<void> => {
if (++n % yieldEvery === 0) await yieldToLoop();
};
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 m = r.u64();
const entries: { order: number | string; pk: string }[] = [];
for (let j = 0; j < m; j++) {
const order = orderType === 'string' ? r.text() : r.f64();
entries.push({ order, pk: r.key() });
await tick();
}
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';
@ -923,3 +1036,51 @@ export function readTextDocsImage(r: ByteReader): TextDocsImage {
if (!r.done) throw new GenerationCorruptError('text docs image: trailing bytes');
return { keys, docLens, liveCount, removed, delta };
}
/** Sliced variant of readTextDocsImage (stage 6): identical result, with
* event-loop yields every `yieldEvery` parsed entries so a large doc table
* or delta never parses in one synchronous run. */
export async function readTextDocsImageAsync(r: ByteReader, yieldEvery = 32768): Promise<TextDocsImage> {
const docCount = r.u64();
const liveCount = r.u64();
const removedCount = r.u64();
const deltaCount = r.u64();
let n = 0;
const tick = async (): Promise<void> => {
if (++n % yieldEvery === 0) await yieldToLoop();
};
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}`);
}
await tick();
}
const removed: number[] = [];
for (let i = 0; i < removedCount; i++) {
removed.push(r.u32());
await tick();
}
const delta: TextDocsImage['delta'] = [];
for (let i = 0; i < deltaCount; i++) {
const term = r.term();
const m = r.u64();
const docs: { docID: number; freq: number }[] = [];
for (let j = 0; j < m; j++) {
docs.push({ docID: r.u32(), freq: r.u32() });
await tick();
}
delta.push({ term, docs });
}
if (!r.done) throw new GenerationCorruptError('text docs image: trailing bytes');
return { keys, docLens, liveCount, removed, delta };
}

View file

@ -76,11 +76,12 @@ import { TextRegistry } from './text-registry.js';
import type { TextIndexDef } from './text-registry.js';
import { ValueReader } from './value-reader.js';
import type { RecoveryMode, RecoveryInfo, ValueMode, RecoveredOp } from './recovery.js';
import type { LifecycleTracker } from './lifecycle-status.js';
import { fsyncDir } from './compaction.js';
import { defaultWorkerSlots } from './maintenance.js';
import { defaultWorkerSlots, MaintenanceCancelledError } from './maintenance.js';
import type { MaintenanceContext, MaintenanceScheduler } from './maintenance.js';
import { startWorkerTextBuild, textBuildWorkerAvailable, verifyFileCrcAsync, WorkerTextBuildError } from './worker/text-build.js';
import type { WorkerTextBuildHandle, TextBuildCheckpoint } from './worker/text-build.js';
import type { WorkerTextBuildFallbackReason, WorkerTextBuildHandle, TextBuildCheckpoint } from './worker/text-build.js';
import { readBaseDocsImageAsync, BASE_DOCS_MAGIC, BASE_DOCS_VERSION } from './worker/text-build-core.js';
import type { TextBuildCoreResult } from './worker/text-build-core.js';
import { TYPE_SET } from './codec.js';
@ -186,11 +187,18 @@ export interface GenerationBuilderDeps<V> {
* disables the worker path for the rest of the instance. */
disableTextWorker: () => void;
textBuildMemoryBytes: () => number;
/** TUI-safe worker-slot policy: how long the worker build queues for a
* process-wide slot before the bounded inline core is allowed as the last
* resort (see MiniDb.textBuildSlotWaitMs). */
textBuildSlotWaitMs: () => number;
dt: DtIndex;
indexes: IndexManager;
compound: CompoundIndexManager;
textRegistry: TextRegistry<V>;
stats: GenerationStats;
/** Per-open lifecycle telemetry, shared with the loader facet (the open
* flow's state machine + phase timings; the build path does not feed it). */
lifecycle: LifecycleTracker;
decode: (b: Buffer | undefined) => V | undefined;
indexable: (v: unknown) => v is Record<string, unknown>;
/** Live records (decoded values), for per-index rebuilds. */
@ -295,9 +303,10 @@ export class GenerationBuilder<V> {
* thread verifies the worker's output (sanity + streaming crc) and swaps
* the live base in via commitRebase, and the stage-5 atomic publish stays
* the safety boundary the worker only ever writes inside the tmp
* generation directory. Custom-tokenizer indexes, small corpora, worker
* slot pressure, and deployments without the worker file stay on the
* in-thread staged build. */
* generation directory. Custom-tokenizer indexes and small corpora stay
* on the in-thread staged build; a deployment without the worker file
* or a worker-slot drought that outlasts the bounded queue wait hosts
* the SAME bounded core inline instead. */
private async runGenerationBuild(ctx: MaintenanceContext): Promise<void> {
const t0 = performance.now();
const gens = generationsDir(this.deps.dir());
@ -483,11 +492,26 @@ export class GenerationBuilder<V> {
}
// Host the bounded build in a worker thread when its entry file
// exists; otherwise run the SAME bounded core inline on the main
// thread (worker-slot pressure, or a bundled single-file deployment
// without the worker file). Inline is still memory-bounded — the
// unbounded staged aggregation is NOT the fallback here.
// thread (a bundled single-file deployment without the worker file,
// or a persisted slot drought). Inline is still memory-bounded — the
// unbounded staged aggregation is NOT the fallback here. TUI-safe
// slot policy: queue for a process-wide slot first (bounded by
// textBuildSlotWaitMs and the build's abort signal) instead of
// dropping the build onto the main thread the moment every slot is
// busy.
const workerAvailable = textBuildWorkerAvailable();
workerSlotRelease = workerAvailable ? defaultWorkerSlots.tryAcquire() : null;
let inlineReason: WorkerTextBuildFallbackReason | undefined;
if (workerAvailable) {
try {
workerSlotRelease = await defaultWorkerSlots.acquireBounded(this.deps.textBuildSlotWaitMs(), aborter.signal);
} catch (e) {
if (e instanceof MaintenanceCancelledError) throw new GenerationBuildAborted('worker slot wait cancelled');
throw e;
}
if (workerSlotRelease === null) inlineReason = 'slot-pressure';
} else {
inlineReason = 'runtime-unavailable';
}
const inline = workerSlotRelease === null;
workerHandle = startWorkerTextBuild(
{
@ -512,7 +536,7 @@ export class GenerationBuilder<V> {
signal: aborter.signal,
shouldAbort: () => gb.aborted || this.deps.wal() !== gb.wal || this.deps.state() !== 'open',
inline,
inlineReason: workerAvailable ? 'slot-pressure' : 'runtime-unavailable',
inlineReason,
onFallback: (reason) => {
this.deps.stats.textWorkerFallbacks++;
this.deps.stats.lastTextWorkerFallback = reason;

View file

@ -28,26 +28,28 @@ import { generationDir, listGenerations, readCurrent, readManifest } from './gen
import {
GenerationCorruptError,
STORE_VERSION,
readGenerationFileChecked,
readGenerationFileCheckedAsync,
readStoreImage,
readDtIndexImage,
readSecondaryIndexImage,
readCompoundIndexImage,
readTextDictionaryImage,
readTextDocsImage,
verifyFileIntegritySync,
readSecondaryIndexImageAsync,
readCompoundIndexImageAsync,
readTextDictionaryImageAsync,
readTextDocsImageAsync,
verifyFileIntegrityAsync,
} from './gen-codec.js';
import type { StoreImageRecord } from './gen-codec.js';
import { IndexManager } from './index-manager.js';
import type { IndexInfo } from './index-manager.js';
import { CompoundIndexManager } from './compound-index.js';
import type { CompoundIndexInfo } from './compound-index.js';
import type { LifecycleTracker } from './lifecycle-status.js';
import { TextRegistry } from './text-registry.js';
import type { TextIndexDef } from './text-registry.js';
import type { TextIndex } from './text-index/index.js';
import { yieldToLoop } from './text-index/tokenize.js';
import type { TextBuildCheckpoint } from './worker/text-build.js';
import { ValueReader } from './value-reader.js';
import { frameToOps } from './recovery.js';
import { frameToOps, walApplySlicer } from './recovery.js';
import type { RecoveryMode, RecoveryInfo, ValueMode, RecoveredOp } from './recovery.js';
import { scanFrameRefsFdAsync } from './codec.js';
import { toKStr } from './value-codec.js';
@ -57,6 +59,10 @@ import type { DtIndex } from './dt-index.js';
import type { ValueCodecName } from './types.js';
import type { GenerationStats } from './generation-builder.js';
/** Store-image parse/filter records per event-loop slice on the open path
* (the bulk load's own slicing lives in Store.bulkLoadRefsAsync). */
const STORE_IMAGE_RECORDS_PER_SLICE = 8192;
/** The owner-injected surface the load path needs (a structural subset of
* GenerationBuilderDeps the builder passes its own deps through). */
export interface GenerationLoaderDeps<V> {
@ -75,6 +81,8 @@ export interface GenerationLoaderDeps<V> {
compound: CompoundIndexManager;
textRegistry: TextRegistry<V>;
stats: GenerationStats;
/** Per-open lifecycle telemetry (state machine + phase timings). */
lifecycle: LifecycleTracker;
indexable: (v: unknown) => v is Record<string, unknown>;
/** Live records (decoded values), for per-index rebuilds. */
liveRecords: () => Generator<{ key: Buffer; value: V | undefined; dt: Record<string, number> | null }>;
@ -163,13 +171,18 @@ export class GenerationLoader<V> {
// 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).
// The payload verify, the parse/filter walk, and the bulk load are all
// sliced with event-loop yields (safe mid-load: nothing is published
// until open() returns).
const tStore = performance.now();
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 storePayload = await readGenerationFileCheckedAsync(path.join(genDir, STORE_IMAGE_FILE), 'MDGS', STORE_VERSION, storeInfo);
const now = Date.now();
const droppedExpired: string[] = [];
const records: StoreImageRecord[] = [];
let imageCount = 0;
let parsed = 0;
for (const rec of readStoreImage(storePayload)) {
imageCount++;
if (rec.expireAt && rec.expireAt <= now) {
@ -180,17 +193,21 @@ export class GenerationLoader<V> {
throw new GenerationCorruptError('store image carries disk refs for a memory-mode open');
}
records.push(rec);
if (++parsed % STORE_IMAGE_RECORDS_PER_SLICE === 0) await yieldToLoop();
}
this.deps.store().bulkLoadRefs(records);
await this.deps.store().bulkLoadRefsAsync(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})`);
}
this.deps.lifecycle.time('storeImageLoadMs', performance.now() - tStore);
// Derived-index images. Every failure here is LOCAL: a corrupt or missing
// image rebuilds exactly the affected index(es) from the loaded store.
const tNonText = performance.now();
await this.loadDtImage(genDir, manifest);
await this.loadSecondaryImages(genDir, manifest);
await this.loadCompoundImages(genDir, manifest);
this.deps.lifecycle.time('nonTextImageLoadMs', performance.now() - tNonText);
await this.loadTextImages(genDir, manifest);
// Reconcile the expired-at-load drops out of the loaded index states.
@ -204,6 +221,7 @@ export class GenerationLoader<V> {
// 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).
this.deps.lifecycle.transition('wal-catch-up');
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
@ -258,32 +276,38 @@ export class GenerationLoader<V> {
* candidate loaded (the caller runs the legacy full recovery). */
async tryLoadGeneration(mode: RecoveryMode): Promise<boolean> {
const t0 = performance.now();
const candidates: string[] = [];
const lifecycle = this.deps.lifecycle;
try {
const current = await readCurrent(this.deps.dir());
if (current) candidates.push(current);
for (const g of await listGenerations(this.deps.dir())) {
if (!g.tmp && g.id !== current && candidates.length < 3) candidates.push(g.id);
}
} catch (e) {
this.deps.stats.generationLoadFallbacks++;
this.deps.stats.lastGenerationFallback = `list: ${(e as Error).message}`;
return false;
}
for (const id of candidates) {
const candidates: string[] = [];
try {
await this.loadOneGeneration(id, mode);
this.deps.stats.generationLoads++;
this.deps.stats.generationLoadDurationMs += performance.now() - t0;
return true;
const current = await readCurrent(this.deps.dir());
if (current) candidates.push(current);
for (const g of await listGenerations(this.deps.dir())) {
if (!g.tmp && g.id !== current && candidates.length < 3) candidates.push(g.id);
}
} catch (e) {
if (!(e instanceof GenerationCorruptError) && (e as NodeJS.ErrnoException).code !== 'ENOENT') throw e;
this.deps.stats.generationLoadFallbacks++;
this.deps.stats.lastGenerationFallback = `${id}: ${(e as Error).message}`;
this.resetAfterFailedGenerationLoad();
this.deps.stats.lastGenerationFallback = `list: ${(e as Error).message}`;
return false;
}
for (const id of candidates) {
try {
lifecycle.transition('generation-load');
await this.loadOneGeneration(id, mode);
this.deps.stats.generationLoads++;
this.deps.stats.generationLoadDurationMs += performance.now() - t0;
return true;
} catch (e) {
if (!(e instanceof GenerationCorruptError) && (e as NodeJS.ErrnoException).code !== 'ENOENT') throw e;
this.deps.stats.generationLoadFallbacks++;
this.deps.stats.lastGenerationFallback = `${id}: ${(e as Error).message}`;
this.resetAfterFailedGenerationLoad();
}
}
return false;
} finally {
lifecycle.time('generationCandidateLoadMs', performance.now() - t0);
}
return false;
}
/** Load the dt image; rebuild the (cheap, metadata-only) dt index from the
@ -292,7 +316,7 @@ export class GenerationLoader<V> {
const info = manifest.files[DT_INDEX_FILE];
if (info) {
try {
const payload = await readGenerationFileChecked(path.join(genDir, DT_INDEX_FILE), 'MDGD', 1, info);
const payload = await readGenerationFileCheckedAsync(path.join(genDir, DT_INDEX_FILE), 'MDGD', 1, info);
this.deps.dt.loadImage(readDtIndexImage(payload));
return;
} catch (e) {
@ -310,16 +334,17 @@ export class GenerationLoader<V> {
/** 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). */
* index is rebuilt, never the whole registry). The payload verify, the
* parse, and each image's map construction are event-loop sliced. */
private async loadSecondaryImages(genDir: string, manifest: GenerationManifest): Promise<void> {
const live = this.deps.indexes.list();
if (live.length === 0) return;
let images: Map<string, ReturnType<typeof readSecondaryIndexImage>[number]> | null = null;
let images: Map<string, Awaited<ReturnType<typeof readSecondaryIndexImageAsync>>[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]));
const payload = await readGenerationFileCheckedAsync(path.join(genDir, SECONDARY_INDEX_FILE), 'MDSI', 1, info);
images = new Map((await readSecondaryIndexImageAsync(payload)).map((i) => [i.name, i]));
} catch (e) {
if (!(e instanceof GenerationCorruptError)) throw e;
}
@ -328,7 +353,7 @@ export class GenerationLoader<V> {
const image = images?.get(def.name);
if (image && manifest.indexDefs.secondary[def.name] === indexDefHash(def)) {
try {
this.deps.indexes.loadImage(image);
await this.deps.indexes.loadImageAsync(image);
continue;
} catch {
/* shape mismatch: rebuild below */
@ -348,16 +373,17 @@ export class GenerationLoader<V> {
this.deps.indexes.indexes.set(def.name, fresh.indexes.get(def.name)!);
}
/** Load compound-index images (same per-index discipline as secondary). */
/** Load compound-index images (same per-index discipline and the same
* event-loop slicing as secondary). */
private async loadCompoundImages(genDir: string, manifest: GenerationManifest): Promise<void> {
const live = this.deps.compound.list();
if (live.length === 0) return;
let images: Map<string, ReturnType<typeof readCompoundIndexImage>[number]> | null = null;
let images: Map<string, Awaited<ReturnType<typeof readCompoundIndexImageAsync>>[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]));
const payload = await readGenerationFileCheckedAsync(path.join(genDir, COMPOUND_INDEX_FILE), 'MDCI', 1, info);
images = new Map((await readCompoundIndexImageAsync(payload)).map((i) => [i.name, i]));
} catch (e) {
if (!(e instanceof GenerationCorruptError)) throw e;
}
@ -366,7 +392,7 @@ export class GenerationLoader<V> {
const image = images?.get(def.name);
if (image && manifest.indexDefs.compound[def.name] === indexDefHash(def)) {
try {
this.deps.compound.loadImage(image);
await this.deps.compound.loadImageAsync(image);
continue;
} catch {
/* shape mismatch: rebuild below */
@ -401,33 +427,29 @@ export class GenerationLoader<V> {
let attached = false;
if (dictInfo && docsInfo && postingsInfo && manifest.indexDefs.text[def.name] === indexDefHash(TextRegistry.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);
const tImage = performance.now();
const dictPayload = await readGenerationFileCheckedAsync(path.join(genDir, textDictionaryFile(def.name)), 'MDTD', 1, dictInfo);
const docsPayload = await readGenerationFileCheckedAsync(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).
// (its per-record CRCs would only trip on the first read). The
// verify is chunked with event-loop yields — a large postings file
// no longer pins the main thread for the whole pass.
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<number, number>();
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]))])),
});
const tCrc = performance.now();
await verifyFileIntegrityAsync(postingsPath, postingsInfo);
this.deps.lifecycle.time('postingsIntegrityCheckMs', performance.now() - tCrc);
// The map constructions (dictionary, doc table, delta) are built
// inside the sliced attach — no intermediate wholesale Maps here.
const dictEntries = await readTextDictionaryImageAsync(dictPayload);
const docs = await readTextDocsImageAsync(docsPayload);
await ti.attachImageAsync({ postingsPath, dictEntries, docs });
// 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 };
this.deps.lifecycle.time('textImageLoadMs', performance.now() - tImage);
this.deps.lifecycle.noteTextIndexSource(def.name, 'image');
attached = true;
} catch (e) {
if (!(e instanceof GenerationCorruptError) && (e as NodeJS.ErrnoException).code !== 'ENOENT') throw e;
@ -441,6 +463,7 @@ export class GenerationLoader<V> {
// The bounded build is discardable: on ANY failure the staged
// rebuild below is the fallback (it reads the already-loaded store,
// so opens always complete).
const tRebuild = performance.now();
let hosted: 'worker' | 'inline' | null = null;
try {
hosted = await this.deps.boundedTextBuild(def.name, ti, def, manifest.checkpoint);
@ -448,6 +471,8 @@ export class GenerationLoader<V> {
/* fall through to the staged rebuild */
}
if (hosted === null) await ti.build(this.deps.textRecords());
this.deps.lifecycle.time('textRebuildMs', performance.now() - tRebuild);
this.deps.lifecycle.noteTextIndexSource(def.name, hosted ?? 'staged');
}
}
}
@ -469,15 +494,25 @@ export class GenerationLoader<V> {
const fd = fsSync.openSync(this.deps.walPath(), 'r');
try {
const st = fsSync.fstatSync(fd);
const tScan = performance.now();
const r = await scanFrameRefsFdAsync(fd, { onCorrupt: mode, startOffset });
this.deps.lifecycle.time('walScanMs', performance.now() - tScan);
const tApply = performance.now();
let corruptBatches = 0;
let appliedOps = 0;
// Cooperative slicing by PRIMITIVE op count and elapsed time (a batch
// frame can unroll into thousands of ops, so frame-granular yielding
// could not bound an apply slice — the open-time freeze this removes).
// Safe mid-apply: the store is not published until open() returns.
const slice = walApplySlicer();
for (const f of r.frames) {
for (const op of frameToOps(f, 'wal', fd, this.deps.valueMode(), () => corruptBatches++)) {
this.deps.applyRecoveredOp(op);
appliedOps++;
if (slice()) await yieldToLoop();
}
}
this.deps.lifecycle.time('walApplyMs', performance.now() - tApply);
let truncatedWal = false;
const last = r.corruptRanges[r.corruptRanges.length - 1];
if (last && last[1] === st.size && !this.deps.readOnly()) {

View file

@ -542,4 +542,54 @@ export class IndexManager {
throw new Error(`index "${image.name}" image payload missing`);
}
}
/** Sliced variant of loadImage (the open-time main-thread path): identical
* resulting state, but the forward/reverse map construction yields to the
* event loop every `sliceEvery` entries. The state swap itself (assigning
* the freshly built containers) stays one synchronous segment, so a
* mid-load yield can never expose a half-built index. Safe to yield while
* building: the containers are detached until the swap, and the store is
* not published until open() returns. */
async loadImageAsync(image: SecondaryImageIndex, opts: { sliceEvery?: number } = {}): Promise<void> {
const sliceEvery = opts.sliceEvery ?? 32768;
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`);
let n = 0;
const tick = async (): Promise<void> => {
if (++n % sliceEvery === 0) await new Promise((r) => setImmediate(r));
};
if (idx.type === 'range' && image.range) {
const list = await SkipList.bulkLoadAsync<number, string>(
image.range.map((e) => ({ key: e.value, val: e.pk })),
{ compareKey: cmpNumber, compareVal: cmpString },
{ sliceEvery },
);
const byPk = new Map<string, number[]>();
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]);
await tick();
}
idx.list = list;
idx.byPk = byPk;
} else if (idx.type === 'equality' && image.equality) {
const map = new Map<string, Set<string>>();
const byPk = new Map<string, string[]>();
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]);
await tick();
}
}
idx.map = map;
idx.byPk = byPk;
} else {
throw new Error(`index "${image.name}" image payload missing`);
}
}
}

View file

@ -20,7 +20,13 @@ export { OpTracker } from './op-tracker.js';
export { TextIndexBuildingError } from './text-index/index.js';
export { normalizeLiteral, createNgramTokenizer } from './trigram.js';
export { tokenize } from './text-index/index.js';
export type { RecoveryInfo } from './recovery.js';
export type { RecoveryInfo, RecoveryPhaseTimings } from './recovery.js';
export type {
MiniDbLifecycleState,
MiniDbLifecycleStatus,
OpenPhaseTimings,
OpenTextIndexSource,
} from './lifecycle-status.js';
export type { IndexDef, IndexInfo, IndexType } from './index-manager.js';
export type { CompoundIndexDef, CompoundIndexInfo } from './compound-index.js';
export type { TextIndexTokenizerName } from './trigram.js';

View file

@ -0,0 +1,179 @@
// src/lifecycle-status.ts
//
// Structured per-open lifecycle telemetry: the state machine one open() walks
// through and the wall-clock cost of every lifecycle phase, so operators and
// benches can tell a published-generation attach apart from a legacy full
// recovery WITHOUT inferring it from the cumulative stats counters. One
// tracker instance lives exactly one open (a MiniDb instance opens once);
// lifecycle.ts, generation-loader.ts, recovery.ts (through a sink) and the
// deferred text-build task feed it, and the public read model is
// MiniDb.lifecycleStatus().
/** The lifecycle states one open() walks through. */
export type MiniDbLifecycleState =
/** Nothing has been loaded: the initial state, and the state of an open
* that found no published generation to even attempt (a fresh/legacy
* database, or generations disabled). */
| 'no-generation'
/** Loading a published generation's images (store + derived indexes). */
| 'generation-load'
/** Replaying the WAL delta past the loaded generation's checkpoint. */
| 'wal-catch-up'
/** The legacy full recovery + open-time index rebuild is running (no usable
* generation, or every candidate failed validation). */
| 'full-rebuild'
/** open() completed and every index is servable. */
| 'ready'
/** open() completed but at least one text index's base is still being built
* by the deferred background task searches on it raise
* TextIndexBuildingError until the build commits. */
| 'degraded';
/** Wall-clock milliseconds per lifecycle phase of the LAST open (0 for a
* phase the open never entered). Phases are NOT strictly nested:
* generationCandidateLoadMs contains the image-load / postings-check /
* wal-catch-up phases of the generation path, fullRecoveryMs contains the
* full-recovery path's walScan/walApply (and its staged text commit also
* lands in textRebuildMs), and a deferred text build's textRebuildMs is
* recorded after open() returned, while the instance sits in 'degraded'. */
export interface OpenPhaseTimings {
/** The whole open() call. */
openMs: number;
/** tryLoadGeneration across every candidate (manifest validation, image
* loads, WAL-delta replay, failed-candidate resets). */
generationCandidateLoadMs: number;
/** Store image read + decode + bulk load (generation path). */
storeImageLoadMs: number;
/** dt / secondary / compound image loads, including the per-index rebuild
* of a corrupt or definition-mismatched image (generation path). */
nonTextImageLoadMs: number;
/** Text dictionary/docs image reads + base attach (generation path). */
textImageLoadMs: number;
/** Whole-file postings crc32 verification (generation path). */
postingsIntegrityCheckMs: number;
/** Frame scanning: the generation path's WAL-delta scan, or the full
* recovery's snapshot + WAL scans (the async scanner's time only). */
walScanMs: number;
/** Applying scanned frames to the store and the derived indexes. */
walApplyMs: number;
/** The legacy full recovery + open-time index rebuild block (recover() +
* rebuildAllIndexes()); 0 when a generation served the open. */
fullRecoveryMs: number;
/** Corpus tokenization work: per-index rebuilds during a generation load
* (bounded worker/inline or staged), the full-rebuild walk's staged text
* commit, and after open() returned, under 'degraded' the deferred
* background base builds. 0 on a healthy generation attach. */
textRebuildMs: number;
}
/** How one text index's base was served by the last open. */
export type OpenTextIndexSource =
/** Attached from the generation's images — no tokenization at all. */
| 'image'
/** Rebuilt by the bounded engine hosted in a worker thread. */
| 'worker'
/** Rebuilt by the bounded engine hosted inline on this thread. */
| 'inline'
/** Rebuilt by the staged in-thread build (the unbounded small-corpus path). */
| 'staged'
/** Still building in the deferred background task when open() returned. */
| 'deferred';
/** The public read model (MiniDb.lifecycleStatus()). */
export interface MiniDbLifecycleStatus {
state: MiniDbLifecycleState;
/** The states the last open walked through, in order (deduped transitions;
* always starts at 'no-generation' nothing is loaded before the first
* attempt). */
path: MiniDbLifecycleState[];
/** Epoch ms when open() completed; null while an open is still in flight
* (or after a failed open a failed open never reaches ready/degraded). */
openedAt: number | null;
phases: OpenPhaseTimings;
/** Per text index: how its base was served (absent = the index did not
* exist at open). A 'deferred' entry flips to its hosting mode when the
* background build commits. */
textIndexes: Record<string, OpenTextIndexSource>;
/** Text index names whose base build was still pending when open()
* returned the reason for 'degraded'. */
pendingTextIndexes: string[];
}
/** The mutable tracker behind MiniDb.lifecycleStatus(); driven by the
* lifecycle facets through the LifecycleHost / GenerationLoaderDeps views
* (same shared-by-reference discipline as the stats object). */
export class LifecycleTracker {
private current: MiniDbLifecycleState = 'no-generation';
private readonly transitions: MiniDbLifecycleState[] = ['no-generation'];
private completedAt: number | null = null;
private readonly pending = new Set<string>();
private readonly textSources = new Map<string, OpenTextIndexSource>();
readonly phases: OpenPhaseTimings = {
openMs: 0,
generationCandidateLoadMs: 0,
storeImageLoadMs: 0,
nonTextImageLoadMs: 0,
textImageLoadMs: 0,
postingsIntegrityCheckMs: 0,
walScanMs: 0,
walApplyMs: 0,
fullRecoveryMs: 0,
textRebuildMs: 0,
};
get state(): MiniDbLifecycleState {
return this.current;
}
transition(next: MiniDbLifecycleState): void {
if (next === this.current) return;
this.current = next;
this.transitions.push(next);
}
/** Add `ms` to one phase (phases accumulate across candidates/passes). */
time(phase: keyof OpenPhaseTimings, ms: number): void {
this.phases[phase] += ms;
}
noteTextIndexSource(name: string, source: OpenTextIndexSource): void {
this.textSources.set(name, source);
}
/** A text index's base build was deferred to the background task: pending
* from here until the build commits (finishOpen maps this to 'degraded'). */
markTextIndexPending(name: string): void {
this.pending.add(name);
this.textSources.set(name, 'deferred');
}
/** The deferred build for one index committed (source = its hosting mode);
* the last pending clear flips a completed open back to 'ready'. A finally
* failed build stays pending the index keeps raising
* TextIndexBuildingError, which IS the degraded state. */
clearTextIndexPending(name: string, source: Exclude<OpenTextIndexSource, 'image' | 'deferred'>): void {
this.pending.delete(name);
this.textSources.set(name, source);
if (this.pending.size === 0 && this.completedAt !== null && this.current === 'degraded') {
this.transition('ready');
}
}
/** open() is about to return: servable ('ready') unless a deferred text
* build is still pending ('degraded'). */
finishOpen(): void {
this.completedAt = Date.now();
this.transition(this.pending.size > 0 ? 'degraded' : 'ready');
}
snapshot(): MiniDbLifecycleStatus {
return {
state: this.current,
path: [...this.transitions],
openedAt: this.completedAt,
phases: { ...this.phases },
textIndexes: Object.fromEntries(this.textSources),
pendingTextIndexes: [...this.pending],
};
}
}

View file

@ -25,6 +25,7 @@ import {
import { readManifest, sweepGenerationTemps } from './generation-files.js';
import { MaintenanceScheduler } from './maintenance.js';
import { LockFile, LockError } from './lockfile.js';
import type { LifecycleTracker } from './lifecycle-status.js';
import { ValueReader } from './value-reader.js';
import { Store } from './store.js';
import type { StoreRecord } from './store.js';
@ -92,6 +93,9 @@ export interface LifecycleHost<V> {
store: Store;
wal: WAL;
valueReader?: ValueReader;
/** Per-open lifecycle telemetry (state machine + phase timings), driven by
* this flow and the generation loader; read via MiniDb.lifecycleStatus(). */
lifecycle: LifecycleTracker;
recoveryInfo: RecoveryInfo | null;
stats: WalStats &
CompactionTarget['stats'] & {
@ -112,6 +116,7 @@ export interface LifecycleHost<V> {
* maintenance. On success the host instance is fully open; on failure every
* acquired resource is released before the error rethrows. */
export async function openMiniDb<V>(db: LifecycleHost<V>, opts: OpenOptions, hooks: LifecycleHooks): Promise<void> {
const openT0 = performance.now();
if (!opts || !opts.dir) throw new TypeError('MiniDb.open: opts.dir is required');
db.dir = opts.dir;
db.walPath = path.join(db.dir, WAL_FILE);
@ -190,6 +195,20 @@ export async function openMiniDb<V>(db: LifecycleHost<V>, opts: OpenOptions, hoo
} else {
throw new LockError(`database is locked by another process: ${db.dir}`);
}
} else {
// Report the held token BEFORE the heavy recovery work below: a
// supervisor (another thread orchestrating this open) learns the lock
// identity immediately and can reap the lock if this opener dies
// mid-recovery. The callback is purely observational — a throwing
// supervisor must not fail this open with the lock already held.
const heldToken = db.lock.heldToken;
if (heldToken !== undefined) {
try {
opts.onLockAcquired?.({ token: heldToken });
} catch {
// Intentionally swallowed — see above.
}
}
}
}
@ -254,13 +273,19 @@ export async function openMiniDb<V>(db: LifecycleHost<V>, opts: OpenOptions, hoo
if (db.indexGenerationsEnabled) generationLoaded = await hooks.tryLoadGeneration(opts.recovery ?? 'resync');
if (!generationLoaded) {
// The loader already recorded a 'generation-load' attempt when it tried
// candidates; none at all leaves the state at 'no-generation'. Either
// way the legacy full recovery now runs.
db.lifecycle.transition('full-rebuild');
const recT0 = performance.now();
const scanApply = { walScanMs: 0, walApplyMs: 0 };
db.recoveryInfo = await recover({
dir: db.dir,
store: db.store,
mode: opts.recovery ?? 'resync',
truncate: !db.readOnly,
valueMode: db.valueMode,
timings: scanApply,
// 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
@ -296,6 +321,8 @@ export async function openMiniDb<V>(db: LifecycleHost<V>, opts: OpenOptions, hoo
: undefined,
});
db.stats.recoveryDurationMs += performance.now() - recT0;
db.lifecycle.time('walScanMs', scanApply.walScanMs);
db.lifecycle.time('walApplyMs', scanApply.walApplyMs);
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;
@ -304,6 +331,7 @@ export async function openMiniDb<V>(db: LifecycleHost<V>, opts: OpenOptions, hoo
if (db.recoveryInfo.truncatedWal) await db.wal.refreshSize();
hooks.seedAccessFromStore();
await hooks.rebuildAllIndexes();
db.lifecycle.time('fullRecoveryMs', performance.now() - recT0);
}
// A read-only instance never compacts: rotation would rename the live
@ -331,6 +359,10 @@ export async function openMiniDb<V>(db: LifecycleHost<V>, opts: OpenOptions, hoo
void hooks.buildGeneration('open').catch(() => {});
}
}
// open() is about to return: 'ready', or 'degraded' while a deferred
// text-index base build is still pending in the background.
db.lifecycle.time('openMs', performance.now() - openT0);
db.lifecycle.finishOpen();
} catch (err) {
// A background open-time compaction may still be in flight: settle it
// before tearing down the WAL/store/handles it touches.

View file

@ -74,10 +74,18 @@ function hookExit(): void {
export class LockFile {
readonly path: string;
held = false;
/** Identity of the current acquire() attempt: minted fresh per attempt,
/** Token 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;
/** The held instance's ownership token (undefined unless currently held).
* Lets a host supervising this database from another thread learn WHO
* holds the lock file without parsing it worker threads share the main
* process pid, so the pid in the lock line cannot distinguish them. */
get heldToken(): string | undefined {
return this.held && this.token !== null ? this.token : undefined;
}
/** 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

View file

@ -349,6 +349,40 @@ export class WorkerSlots {
});
}
/** TUI-safe slot policy: queue for a slot up to `waitMs` (observing the
* optional abort signal) instead of dropping a large worker-eligible
* build onto the main thread the moment every slot is busy. Resolves null
* on timeout the caller decides whether its bounded inline core is an
* acceptable last resort. Rejects MaintenanceCancelledError on abort. */
async acquireBounded(waitMs: number, signal?: AbortSignal): Promise<(() => void) | null> {
const immediate = this.tryAcquire();
if (immediate) return immediate;
if (signal?.aborted) throw new MaintenanceCancelledError('generation-build');
return new Promise<(() => void) | null>((resolve, reject) => {
const cleanup = (): void => {
clearTimeout(timer);
signal?.removeEventListener('abort', onAbort);
const i = this.waiters.indexOf(grant);
if (i >= 0) this.waiters.splice(i, 1);
};
const timer = setTimeout(() => {
cleanup();
resolve(null);
}, waitMs);
timer.unref?.();
const onAbort = (): void => {
cleanup();
reject(new MaintenanceCancelledError('generation-build'));
};
const grant = (): void => {
cleanup();
resolve(() => this.release());
};
signal?.addEventListener('abort', onAbort, { once: true });
this.waiters.push(grant);
});
}
private release(): void {
const next = this.waiters.shift();
if (next) {
@ -363,3 +397,9 @@ export class WorkerSlots {
* capped at two one worker build already saturates a core for seconds,
* and the main thread must stay responsive. */
export const defaultWorkerSlots = new WorkerSlots(Math.max(1, Math.min(2, Math.floor(os.cpus().length / 2))));
/** Default budget of the TUI-safe slot queue (WorkerSlots.acquireBounded):
* a worker-eligible text build waits this long for a slot before falling
* back to the bounded inline core as the explicit last resort large
* full-text builds are never dropped onto the main thread UNCONDITIONALLY. */
export const TEXT_BUILD_SLOT_WAIT_MS = 30_000;

View file

@ -15,7 +15,7 @@ import { Store } from './store.js';
import type { StoreRecord } from './store.js';
import { WAL } from './wal.js';
import { ValueReader } from './value-reader.js';
import { catchUpWal } from './recovery.js';
import { catchUpWalAsync } from './recovery.js';
import { compact, shouldCompact } from './compaction.js';
import { OpTracker } from './op-tracker.js';
import { TEXT_INDEXES_FILE, SNAPSHOT_FILE, isPersistentFile, rootPostingsFile, textDictionaryFile, textDocsFile } from './generation.js';
@ -26,7 +26,7 @@ import { CompoundIndexManager } from './compound-index.js';
import { toKStr, writeFileAtomic } from './value-codec.js';
import { LockFile } from './lockfile.js';
import { createSerializer } from './serialize.js';
import { MaintenanceScheduler, defaultWorkerSlots } from './maintenance.js';
import { MaintenanceScheduler, defaultWorkerSlots, TEXT_BUILD_SLOT_WAIT_MS } from './maintenance.js';
import { MemoryGuard } from './memory-guard.js';
import { backup as runBackup, backupInProgressError as newBackupInProgressError } from './backup.js';
import type { BackupDeps } from './backup.js';
@ -39,6 +39,8 @@ import { openMiniDb, closeMiniDb, renewMiniDbLock, openOrRebuildMiniDb } from '.
import { IndexAdmin } from './index-admin.js';
import { ReadPath } from './read-path.js';
import { createMiniDbStats } from './stats.js';
import { LifecycleTracker } from './lifecycle-status.js';
import type { MiniDbLifecycleStatus } from './lifecycle-status.js';
import type { LifecycleHooks } from './lifecycle.js';
import { TextRegistry } from './text-registry.js';
import type { TextIndexDef } from './text-registry.js';
@ -52,7 +54,7 @@ import type { RangeOptions } from './skiplist.js';
import type { TextIndexTokenizerName } from './trigram.js';
import type { PostingEntry } from './text-postings.js';
import { startWorkerTextBuild, textBuildWorkerAvailable, verifyFileCrcAsync, WorkerTextBuildError } from './worker/text-build.js';
import type { TextBuildCheckpoint } from './worker/text-build.js';
import type { TextBuildCheckpoint, WorkerTextBuildFallbackReason } from './worker/text-build.js';
import { readBaseDocsImageAsync, BASE_DOCS_MAGIC, BASE_DOCS_VERSION } from './worker/text-build-core.js';
import type { TextBuildCoreResult } from './worker/text-build-core.js';
import { readGenerationFileCheckedAsync, readTextDictionaryImageAsync } from './gen-codec.js';
@ -112,6 +114,12 @@ export class MiniDb<V = unknown> {
* offset as advanced by the last successful catch-up (recoveryInfo's scan
* endpoint anchors the first call). */
private walTail: { dev: number; ino: number; size: number } | null = null;
/** Catch-up serializer: the sliced async apply yields to the event loop, so
* overlapping catch-ups would interleave op-by-op (the pre-async apply was
* atomic per call by virtue of being synchronous). Chaining restores that
* per-call atomicity; a caller queued behind another catch-up observes the
* advanced watermark and no-ops or falls back to a full reopen. */
private catchUpChain: Promise<unknown> = Promise.resolve();
readOnly = false;
/* Non-private (package-internal): lifecycle.ts reads/writes this through its LifecycleHost view. */ lock: LockFile | null = null;
@ -193,6 +201,10 @@ export class MiniDb<V = unknown> {
private textWorkerDisabled = false;
/** Stage 6: worker aggregation memory budget. */
/* Non-private (package-internal): lifecycle.ts reads/writes this through its LifecycleHost view. */ textBuildMemoryBytes = 128 * 1024 * 1024;
/** TUI-safe worker-slot policy: how long a worker-eligible text build
* queues for a process-wide slot before the bounded inline core is
* allowed as the last resort (never the unbounded staged aggregation). */
/* Non-private (package-internal): read through the GenerationBuilderDeps view. */ textBuildSlotWaitMs = TEXT_BUILD_SLOT_WAIT_MS;
/** Stage 6: maintenance I/O concurrency (snapshot grouped reads). */
/* Non-private (package-internal): lifecycle.ts reads/writes this through its LifecycleHost view. */ maintenanceIoConcurrency = 8;
/** Defer the open-time full text rebuild (no-generation fallback) to a background
@ -236,6 +248,10 @@ export class MiniDb<V = unknown> {
* this state the caller cannot assume a rejected write had no effect. */
writeDisabled: unknown = null;
readonly stats = createMiniDbStats();
/** Per-open lifecycle telemetry (the open() state machine + per-phase
* wall-clock timings): driven by lifecycle.ts and the generation loader,
* read through lifecycleStatus(). */
/* Non-private (package-internal): lifecycle.ts drives it through its LifecycleHost view. */ readonly lifecycle = new LifecycleTracker();
/** The text-index registry facet (text-registry.ts): owns the live TextIndex
* map, the persisted definition list, and the staged-drop marks (declared
@ -327,11 +343,13 @@ export class MiniDb<V = unknown> {
this.textWorkerDisabled = true;
},
textBuildMemoryBytes: () => this.textBuildMemoryBytes,
textBuildSlotWaitMs: () => this.textBuildSlotWaitMs,
dt: this.dt,
indexes: this.indexes,
compound: this.compound,
textRegistry: this.textRegistry,
stats: this.stats,
lifecycle: this.lifecycle,
decode: (b) => this.decode(b),
indexable: (v): v is Record<string, unknown> => this.indexable(v),
liveRecords: () => this.liveRecords(),
@ -561,7 +579,14 @@ export class MiniDb<V = unknown> {
deferred.add(name);
}
}
const textRebuildMsBefore = this.stats.textRebuildDurationMs;
await this.indexAdmin.rebuildAllIndexes({ skipTextIndex: (name) => deferred.has(name) });
this.lifecycle.time('textRebuildMs', this.stats.textRebuildDurationMs - textRebuildMsBefore);
// Every non-deferred text index was just rebuilt by the staged in-thread
// walk above (the full-recovery path leaves them empty).
for (const name of this.text.keys()) {
if (!deferred.has(name)) this.lifecycle.noteTextIndexSource(name, 'staged');
}
if (deferred.size > 0) this.deferOpenTextBuilds([...deferred]);
}
@ -590,6 +615,7 @@ export class MiniDb<V = unknown> {
ti.basePending = true;
ti.beginRebase();
armed.push({ name, ti, def });
this.lifecycle.markTextIndexPending(name);
}
if (armed.length === 0) return;
// The checkpoint the build covers: the recovery scan endpoint (see the
@ -644,6 +670,8 @@ export class MiniDb<V = unknown> {
const output = scratchDir !== null ? { dir: scratchDir, postingsPath: path.join(scratchDir, rootPostingsFile(name)) } : null;
let checkpoint = pin();
let committed = false;
let hostedMode: 'worker' | 'inline' | null = null;
const tBuild = performance.now();
for (let attempt = 1; attempt <= 3 && !committed; attempt++) {
if (attempt > 1) checkpoint = repin(ti);
try {
@ -652,13 +680,16 @@ export class MiniDb<V = unknown> {
// mid-task): no build will come — stop retrying.
if (hosted === null) break;
committed = true;
hostedMode = hosted;
} catch {
// Shutdown cancels the task: leave close() to finish teardown.
if (ctx.signal.aborted) return;
}
}
if (committed) {
this.lifecycle.time('textRebuildMs', performance.now() - tBuild);
if (committed && hostedMode !== null) {
this.stats.textDeferredBuilds++;
this.lifecycle.clearTextIndexPending(name, hostedMode);
} else {
// Every attempt failed: disarm and mark the base pending —
// searches keep the typed building signal instead of silently
@ -827,11 +858,21 @@ export class MiniDb<V = unknown> {
const postingsPath = tmpDir !== null ? path.join(tmpDir, rootPostingsFile(name)) : output!.postingsPath;
const dictionaryPath = path.join(artifactsDir, textDictionaryFile(name));
const baseDocsPath = path.join(artifactsDir, `${textDocsFile(name)}.base`);
// Worker thread when its entry file exists; the inline bounded core
// otherwise (slot pressure or a single-file deployment) — never the
// unbounded staged aggregation.
// Worker thread when its entry file exists; the inline bounded core is
// the last resort — never the unbounded staged aggregation. TUI-safe
// slot policy: queue for a process-wide worker slot first (bounded by
// textBuildSlotWaitMs and the caller's abort signal) instead of
// dropping a large build onto the main thread the moment every slot is
// busy; only a persisted slot drought (or a deployment without the
// worker file) hosts the SAME bounded core inline.
const workerAvailable = textBuildWorkerAvailable();
slotRelease = workerAvailable ? defaultWorkerSlots.tryAcquire() : null;
let inlineReason: WorkerTextBuildFallbackReason | undefined;
if (workerAvailable) {
slotRelease = await defaultWorkerSlots.acquireBounded(this.textBuildSlotWaitMs, signal);
if (slotRelease === null) inlineReason = 'slot-pressure';
} else {
inlineReason = 'runtime-unavailable';
}
const inline = slotRelease === null;
const handle = startWorkerTextBuild(
{
@ -857,7 +898,7 @@ export class MiniDb<V = unknown> {
{
shouldAbort: () => this.state !== 'open' || signal?.aborted === true,
inline,
inlineReason: workerAvailable ? 'slot-pressure' : 'runtime-unavailable',
inlineReason,
signal,
onFallback: (reason) => {
this.stats.textWorkerFallbacks++;
@ -1114,8 +1155,8 @@ export class MiniDb<V = unknown> {
* derived-index maintenance applyOp performs on the write path minus
* unique checks: the writer already validated, and intermediate frame
* states must apply literally (LWW). */
private applyRecoveredFrame(f: FrameRef, fd: number): void {
this.writePath.applyRecoveredFrame(f, fd);
private applyRecoveredFrameAsync(f: FrameRef, fd: number, slice: () => boolean): Promise<void> {
return this.writePath.applyRecoveredFrameAsync(f, fd, slice);
}
private applyRecoveredOp(op: RecoveredOp): void {
@ -1352,13 +1393,34 @@ export class MiniDb<V = unknown> {
* rotated file and a shrunken one all return null, meaning: reopen from
* scratch. A partial/torn tail left by a writer mid-writev is NOT an
* error: the scan stops at the last fully-valid frame; call again later
* and its CRC validates once the writev landed. */
* and its CRC validates once the writev landed.
*
* Cooperative: the scan runs through the windowed async scanner and the
* apply yields between primitive ops on the walApplySlicer budgets, so a
* replica that fell far behind does not block the host's event loop in
* one synchronous scan+apply. Calls are serialized per instance (see
* catchUpChain). */
async catchUpFromWal(offset: number): Promise<{ offset: number; appliedFrames: number } | null> {
this.ensureOpen();
const run = this.catchUpChain.then(() => this.doCatchUpFromWal(offset));
// The chain itself must survive a caller's rejection (treated as "reopen
// from scratch" by every caller): swallow for the chain, not for the caller.
this.catchUpChain = run.then(
() => undefined,
() => undefined,
);
return run;
}
private async doCatchUpFromWal(offset: number): Promise<{ offset: number; appliedFrames: number } | null> {
// Re-check on dequeue: the instance may have been closed while this call
// waited on the chain behind another catch-up (both production callers
// gate close externally; this is the library-contract backstop).
this.ensureOpen();
const ri = this.recoveryInfo;
const anchor = this.walTail ?? (ri && ri.walIno ? { dev: ri.walDev, ino: ri.walIno, size: ri.walScanEnd } : null);
if (!anchor || offset !== anchor.size) return null;
const res = catchUpWal(this.walPath, offset, anchor, (f, fd) => this.applyRecoveredFrame(f, fd));
const res = await catchUpWalAsync(this.walPath, offset, anchor, (f, fd, slice) => this.applyRecoveredFrameAsync(f, fd, slice));
if (res) this.walTail = { dev: anchor.dev, ino: anchor.ino, size: res.offset };
return res;
}
@ -1376,6 +1438,16 @@ export class MiniDb<V = unknown> {
return this.maintenance.status();
}
/** The last open()'s lifecycle read model: which path served the open
* ('generation-load' + 'wal-catch-up' vs 'full-rebuild'), whether the
* instance is 'ready' or still 'degraded' (a deferred text-index base
* build in flight), the per-phase wall-clock timings, and how every text
* index's base was served (generation image vs worker/inline/staged
* rebuild). Diagnostics only the cumulative counters stay in `stats`. */
lifecycleStatus(): MiniDbLifecycleStatus {
return this.lifecycle.snapshot();
}
async close(): Promise<void> {
return closeMiniDb(this, this.lifecycleHooks);
}

View file

@ -6,7 +6,7 @@
//
// 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.
// catch-up (catchUpWalAsync) can never drift apart.
//
// GENERATION PAIRING (stat-pairing — the LEGACY fallback path). MiniDb's
// disk state is a compound document: a compaction rotation swaps db.snapshot
@ -35,7 +35,7 @@
import fs from 'node:fs/promises';
import fsSync from 'node:fs';
import path from 'node:path';
import { scanFrameRefsFd, scanFrameRefsFdAsync, scanBatchOpRefs, TYPE_SET, TYPE_DEL, TYPE_BATCH, MAGIC } from './codec.js';
import { scanFrameRefsFdAsync, scanBatchOpRefs, TYPE_SET, TYPE_DEL, TYPE_BATCH, MAGIC } from './codec.js';
import type { FrameRef } from './codec.js';
import { SNAPSHOT_FILE, WAL_FILE } from './generation.js';
import { yieldToLoop } from './text-index/tokenize.js';
@ -44,6 +44,16 @@ import type { Store, ValueLoc, ValueRef } from './store.js';
export type RecoveryMode = 'resync' | 'strict';
export type ValueMode = 'memory' | 'disk';
/** Optional wall-clock sink for the two recovery sub-phases (scan vs apply),
* accumulated across every pass (generation-churn retries included). Fed by
* the open-time lifecycle telemetry; recover() works unchanged without it. */
export interface RecoveryPhaseTimings {
/** Async frame scanning of the snapshot and the WAL. */
walScanMs: number;
/** Applying the scanned frames to the store. */
walApplyMs: number;
}
export interface RecoveryInfo {
snapshotFrames: number;
walFrames: number;
@ -176,8 +186,30 @@ export function* frameToOps(
}
}
/** Yield to the event loop every this many applied frames (see applyFrames). */
const APPLY_YIELD_FRAMES = 4096;
/** Budgets for the cooperative slicing of recovered-op apply loops. A BATCH
* frame unrolls into thousands of primitive ops, so frame-granular yielding
* cannot bound an apply slice primitive-op count and elapsed time can. */
export const WAL_APPLY_OPS_PER_SLICE = 512;
export const WAL_APPLY_SLICE_MS = 8;
/** Cooperative slicing for the recovered-op apply loops: reports true when
* either budget (primitive ops, or wall-clock since the last yield) is
* exhausted and the loop should yieldToLoop(). Yielding mid-apply is safe on
* both consumers: at open the store is not published until open() returns,
* so nothing observes a half-applied pass; during replica catch-up
* (catchUpWalAsync) concurrent readers can observe intermediate apply
* states, which the replica's eventual-consistency contract permits (the
* caller's watermark only advances once the whole delta has applied). */
export function walApplySlicer(): () => boolean {
let ops = 0;
let sliceStart = performance.now();
return () => {
if (++ops < WAL_APPLY_OPS_PER_SLICE && performance.now() - sliceStart < WAL_APPLY_SLICE_MS) return false;
ops = 0;
sliceStart = performance.now();
return true;
};
}
/** Apply recovered frames to the store. This is pure in-memory bookkeeping
* one synchronous pass per file would be a noticeable event-loop stall on a
@ -193,13 +225,13 @@ async function applyFrames(
valueMode: ValueMode,
onCorruptBatch?: () => void,
): Promise<void> {
let applied = 0;
const slice = walApplySlicer();
for (const f of frames) {
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);
if (slice()) await yieldToLoop();
}
if (++applied % APPLY_YIELD_FRAMES === 0) await yieldToLoop();
}
}
@ -272,6 +304,7 @@ export async function recover({
maxGenerationRetries = 4,
attachValueReader,
signal,
timings,
}: {
dir: string;
store: Store;
@ -292,6 +325,8 @@ export async function recover({
/** Cancellation for the async frame scans (stage 6): an aborted scan
* throws an 'AbortError' and leaves the pass's partial state discarded. */
signal?: AbortSignal;
/** Optional scan/apply wall-clock sink (see RecoveryPhaseTimings). */
timings?: RecoveryPhaseTimings;
}): Promise<RecoveryInfo> {
const snapPath = path.join(dir, SNAPSHOT_FILE);
const walPath = path.join(dir, WAL_FILE);
@ -299,7 +334,7 @@ export async function recover({
for (let attempt = 0; ; attempt++) {
let pass: RecoverPassResult;
try {
pass = await recoverPass({ snapPath, walPath, store, mode, truncate, valueMode, signal });
pass = await recoverPass({ snapPath, walPath, store, mode, truncate, valueMode, signal, timings });
} catch (e) {
// A cancelled scan may have applied a prefix of the pass's frames:
// discard the partial application so the error never carries state
@ -336,6 +371,7 @@ async function recoverPass({
truncate,
valueMode,
signal,
timings,
}: {
snapPath: string;
walPath: string;
@ -344,6 +380,7 @@ async function recoverPass({
truncate: boolean;
valueMode: ValueMode;
signal?: AbortSignal;
timings?: RecoveryPhaseTimings;
}): Promise<RecoverPassResult> {
let corruptBatches = 0;
const countCorruptBatch = (): void => {
@ -362,8 +399,12 @@ async function recoverPass({
// Stage 6: the scan itself is async (sequential window reads, CRC
// slices, periodic yields, abortable); only the in-memory apply below
// runs on the event loop.
const snapScanT0 = performance.now();
const r = await scanFrameRefsFdAsync(fd, { onCorrupt: mode, signal });
if (timings) timings.walScanMs += performance.now() - snapScanT0;
const snapApplyT0 = performance.now();
await applyFrames(r.frames, 'snapshot', fd, store, valueMode, countCorruptBatch);
if (timings) timings.walApplyMs += performance.now() - snapApplyT0;
snapshotFrames = r.frames.length;
snapshotCorrupt = r.corruptRanges;
} finally {
@ -388,8 +429,12 @@ async function recoverPass({
walScanned = { dev: st.dev, ino: st.ino, size: st.size };
walSizeFloor = st.size;
walBytes = st.size;
const walScanT0 = performance.now();
const r = await scanFrameRefsFdAsync(fd, { onCorrupt: mode, signal });
if (timings) timings.walScanMs += performance.now() - walScanT0;
const walApplyT0 = performance.now();
await applyFrames(r.frames, 'wal', fd, store, valueMode, countCorruptBatch);
if (timings) timings.walApplyMs += performance.now() - walApplyT0;
walFrames = r.frames.length;
walCorrupt = r.corruptRanges;
walScanEnd = r.eofOffset;
@ -443,7 +488,7 @@ async function recoverPass({
}
/** Continue a replica from a WAL watermark: strictly scan frames in
* [offset, EOF) of `walPath` and hand each to `apply(f, fd)` in order.
* [offset, EOF) of `walPath` and hand each to `apply(f, fd, slice)` in order.
*
* Returns the new continuation offset (end of the last fully-valid frame)
* and how many frames were applied. An invalid/partial frame anywhere stops
@ -453,13 +498,24 @@ async function recoverPass({
* cannot be a clean frame-boundary continuation (negative, beyond EOF,
* pointing at bytes that start no frame, or the file is not the `anchor`
* inode rotation swapped it in the microseconds between the caller's stat
* and this open): the caller must fully reopen. */
export function catchUpWal(
* and this open): the caller must fully reopen.
*
* Cooperative (stage 5 follow-up of the open-path slicing): the frame scan
* runs through the windowed async scanner and the apply loop yields between
* primitive ops on the shared walApplySlicer budgets, so a replica that fell
* far behind no longer blocks the host loop in one synchronous scan+apply.
* `apply` receives the slicer and MUST await-yield when it fires a BATCH
* frame unrolls into thousands of primitive ops, so frame-granular yielding
* could never bound a slice. Yielding mid-catch-up lets concurrent readers
* observe intermediate apply states; that is the documented replica contract
* (a replica is eventually consistent the caller's watermark only advances
* once the whole delta has applied). */
export async function catchUpWalAsync(
walPath: string,
offset: number,
anchor: { dev: number; ino: number },
apply: (f: FrameRef, fd: number) => void,
): { offset: number; appliedFrames: number } | null {
apply: (f: FrameRef, fd: number, slice: () => boolean) => Promise<void>,
): Promise<{ offset: number; appliedFrames: number } | null> {
let fd: number;
try {
fd = fsSync.openSync(walPath, 'r');
@ -472,7 +528,7 @@ export function catchUpWal(
if (st.dev !== anchor.dev || st.ino !== anchor.ino) return null;
const size = st.size;
if (offset < 0 || offset > size) return null;
const r = scanFrameRefsFd(fd, { onCorrupt: 'strict', startOffset: offset });
const r = await scanFrameRefsFdAsync(fd, { onCorrupt: 'strict', startOffset: offset });
if (r.frames.length === 0 && r.eofOffset < size) {
// Bytes at `offset` start no valid frame. An append-only file grows
// whole frames sequentially, so a torn tail is always a frame PREFIX
@ -483,7 +539,8 @@ export function catchUpWal(
if (!MAGIC.subarray(0, n).equals(head)) return null;
return { offset, appliedFrames: 0 };
}
for (const f of r.frames) apply(f, fd);
const slice = walApplySlicer();
for (const f of r.frames) await apply(f, fd, slice);
return { offset: r.eofOffset, appliedFrames: r.frames.length };
} finally {
fsSync.closeSync(fd);

View file

@ -123,6 +123,63 @@ export class SkipList<K = number, V = string> {
return list;
}
/** Sliced variant of bulkLoad (the open-time main-thread load paths):
* builds the exact same balanced tower, but yields to the event loop every
* `sliceEvery` source entries in the two O(N) passes (node creation,
* level linking), so a large image load never runs as one synchronous
* slice. */
static async bulkLoadAsync<K, V>(
entries: readonly RangeEntry<K, V>[],
opts: SkipListOptions<K, V> = {},
slice: { sliceEvery?: number } = {},
): Promise<SkipList<K, V>> {
const sliceEvery = slice.sliceEvery ?? 65536;
const yieldToLoop = (): Promise<void> => new Promise((r) => setImmediate(r));
const list = new SkipList<K, V>(opts);
const n = entries.length;
if (n === 0) return list;
const nodes: SkipNode<K, V>[] = [];
for (let i = 0; i < n; i++) {
if (i > 0 && i % sliceEvery === 0) await yieldToLoop();
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<K, V>(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<K, V>; index: number }[] = [];
for (let l = 0; l < list.level; l++) lastAt.push({ node: list.header, index: -1 });
for (let i = 0; i < count; i++) {
if (i > 0 && i % sliceEvery === 0) await yieldToLoop();
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<K, V>, 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);

View file

@ -132,6 +132,16 @@ export class Store {
* expiry backlog), making the next ticks aggressive until the storm drains
* like Redis's aggressive expire cycle. */
private expireAggressive = false;
/** In-flight async bulk load (bulkLoadRefsAsync only the sync bulk load
* yields nothing, so no timer can fire mid-load there). Active expiry is
* paused for the load's duration: a mid-load reap would delete the key
* from the map (and from the about-to-be-replaced OLD ordered index, a
* no-op) while the load still rebuilds `order` from its accumulated
* entries resurrecting the reaped key in the ordered index, where a
* later re-set would duplicate it in ordered scans. Whatever elapsed
* during the load is reaped by the first tick after it, which is exactly
* the sync bulkLoadRefs behavior. */
private bulkLoading = false;
private timer: ReturnType<typeof setInterval> | null = null;
private readonly onExpire?: (key: string, record: StoreRecord) => void;
private readonly readValue?: ValueReader;
@ -389,7 +399,48 @@ export class Store {
this.order = SkipList.bulkLoad(orderEntries, { compareKey: cmpString });
}
/** Sliced variant of bulkLoadRefs (the open-time main-thread path):
* identical resulting state, but the per-record map insertions yield to
* the event loop every `sliceEvery` records, so a large store image never
* loads in one synchronous run. The final ordered-index bulk build is a
* single O(N) pass over the sorted entries and is likewise sliced via
* SkipList.bulkLoadAsync. Safe to yield mid-load: the store is not
* published until open() returns, and active expiry is paused for the
* load's duration (see `bulkLoading`) so a mid-load reap cannot diverge
* the map from the not-yet-rebuilt ordered index. */
async bulkLoadRefsAsync(
records: Iterable<{ kstr: string; ref: ValueRef; expireAt: number; dt: Record<string, number> | null; metaBytes?: number }>,
opts: { sliceEvery?: number } = {},
): Promise<void> {
const sliceEvery = opts.sliceEvery ?? 8192;
const orderEntries: RangeEntry<string, string>[] = [];
this.bulkLoading = true;
try {
let n = 0;
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 });
if (++n % sliceEvery === 0) await new Promise((r) => setImmediate(r));
}
this.order = await SkipList.bulkLoadAsync(orderEntries, { compareKey: cmpString }, { sliceEvery });
} finally {
this.bulkLoading = false;
}
}
private activeExpire(): void {
// A sliced bulk load holds the map/order consistency contract: reaping
// mid-load would drop the key from the map while the load still rebuilds
// `order` from its accumulated entries. Skip this tick; the next one
// reaps whatever elapsed meanwhile (identical to the sync bulk load,
// during which no timer can fire at all).
if (this.bulkLoading) return;
const now = Date.now();
// Normal ticks stay within the small budget; a tick that still finds a
// full quota of expired keys flips to aggressive mode (larger budget, like

View file

@ -150,6 +150,92 @@ export function attachImage(
s.baseEpoch++;
}
/** The raw parsed images attachImageAsync consumes (the generation loader's
* readTextDictionaryImageAsync / readTextDocsImageAsync output, structurally
* avoiding a gen-codec import here). */
export interface AttachImageRaw {
postingsPath: string;
dictEntries: Iterable<{ term: string; off: number; len: number; df: number }>;
docs: {
keys: (string | undefined)[];
docLens: (number | undefined)[];
liveCount: number;
removed: number[];
delta: { term: string; docs: { docID: number; freq: number }[] }[];
};
}
/** Sliced variant of attachImage (the open-time main-thread path): identical
* resulting state and the same side-effect order, but every O(terms/docs)
* map construction happens here, in slices with event-loop yields, so
* attaching a large generation's base never stalls the loop for the whole
* pass. Safe to yield mid-attach: the index is not serving until open()
* returns, and the readonly-bound containers (delta, deltaDocs, removed
* their bindings are stable by class invariant) are cleared-then-refilled
* in place exactly like the sync attach, only with yields interleaved. */
export async function attachImageAsync(
s: TextIndexImageState,
args: AttachImageRaw,
opts: { sliceEvery?: number } = {},
): Promise<void> {
const sliceEvery = opts.sliceEvery ?? 32768;
let n = 0;
const tick = async (): Promise<void> => {
if (++n % sliceEvery === 0) await yieldToLoop();
};
s.close(); // release any previous postings handle
s.memBase = null;
const postings = new Map<string, PostingEntry>();
for (const e of args.dictEntries) {
postings.set(e.term, { off: e.off, len: e.len, df: e.df });
await tick();
}
s.postings = postings;
s.pf = PostingsFile.open(args.postingsPath);
const keys = args.docs.keys; // adopted (fresh from the parser — see commitRebase's containers)
const docLen = new Map<number, number>();
for (let i = 0; i < keys.length; i++) {
const len = args.docs.docLens[i];
if (len !== undefined) docLen.set(i, len);
await tick();
}
const keyToId = new Map<string, number>();
for (let i = 0; i < keys.length; i++) {
const k = keys[i];
if (k !== undefined) keyToId.set(k, i);
await tick();
}
s.docLen = docLen;
s.keys = keys;
s.keyToId = keyToId;
s.delta.clear();
s.deltaDocs.clear();
s.deltaCount = 0;
for (const d of args.docs.delta) {
const m = new Map<number, number>();
s.delta.set(d.term, m);
for (const doc of d.docs) {
m.set(doc.docID, doc.freq);
s.deltaCount++;
let set = s.deltaDocs.get(doc.docID);
if (!set) s.deltaDocs.set(doc.docID, (set = new Set()));
set.add(d.term);
await tick();
}
}
s.removed.clear();
for (const id of args.docs.removed) {
s.removed.add(id);
await tick();
}
s.clearCache();
s.N = args.docs.liveCount;
// A committed base is now attached — the index no longer owes a build, and
// any async read in flight must re-read from the attached base.
s.basePending = false;
s.baseEpoch++;
}
/** 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

View file

@ -51,15 +51,16 @@ import type {
import { feedBuild, StagedBuild } from './builder.js';
import {
attachImage as attachImageImpl,
attachImageAsync as attachImageAsyncImpl,
exportImageState as exportImageStateImpl,
exportImageStateAsync as exportImageStateAsyncImpl,
repointPostings as repointPostingsImpl,
} from './image.js';
import type { TextIndexImage } from './image.js';
import type { AttachImageRaw, TextIndexImage } from './image.js';
export * from './tokenize.js';
export * from './types.js';
export type { TextIndexImage } from './image.js';
export type { AttachImageRaw, TextIndexImage } from './image.js';
/**
* Raised by text searches while the index's base is (re)building. The
@ -931,6 +932,15 @@ export class TextIndex {
attachImageImpl(this, args);
}
/** Sliced variant of attachImage (the open-time main-thread path):
* identical resulting state, but every O(terms/docs) map construction is
* built from the raw parsed images in slices with event-loop yields, so
* attaching a large generation's base never stalls the loop for the whole
* pass. */
async attachImageAsync(args: AttachImageRaw, opts: { sliceEvery?: number } = {}): Promise<void> {
await attachImageAsyncImpl(this, args, opts);
}
/** 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

View file

@ -31,6 +31,15 @@ export interface OpenOptions {
recovery?: RecoveryMode;
readOnly?: boolean;
onLockFail?: 'readonly';
/**
* Writer opens only: invoked synchronously right after the exclusive write
* lock is acquired BEFORE any recovery/replay work runs. Hosts that
* supervise the open from another thread (e.g. kap-server's search worker,
* whose threads share the main process pid so pid-liveness alone can never
* reclaim the lock) use it to learn the lock token immediately and reap
* the lock after a mid-open crash.
*/
onLockAcquired?: (info: { readonly token: string }) => void;
/** Where to keep value bulk. 'memory' keeps values in RAM; 'disk' keeps only
* value pointers in RAM and reads values from the snapshot/WAL on demand. */
valueMode?: ValueModeSetting;

View file

@ -19,6 +19,7 @@ import { encodeFrame, encodeBatchOps, scanBatchOpRefs, HEADER_SIZE, TYPE_SET, TY
import type { BatchOp as EncodedBatchOp, FrameRef } from './codec.js';
import { frameToOps } from './recovery.js';
import type { ValueMode, RecoveredOp } from './recovery.js';
import { yieldToLoop } from './text-index/tokenize.js';
import { toBuf, toKStr, normDt, MAX_KEY_LEN } from './value-codec.js';
import type { Store, StoreRecord, ValueLoc } from './store.js';
import type { WAL } from './wal.js';
@ -643,9 +644,15 @@ export class WritePath<V> {
* open-time recovery derives from it (frameToOps), plus the incremental
* derived-index maintenance applyOp performs on the write path minus
* unique checks: the writer already validated, and intermediate frame
* states must apply literally (LWW). */
applyRecoveredFrame(f: FrameRef, fd: number): void {
for (const op of frameToOps(f, 'wal', fd, this.deps.valueMode())) this.applyRecoveredOp(op);
* states must apply literally (LWW). Cooperative: yields between primitive
* ops when the caller's slicer (walApplySlicer budgets) fires a BATCH
* frame unrolls into thousands of ops, so per-op yielding is what bounds a
* catch-up slice on the host's event loop. */
async applyRecoveredFrameAsync(f: FrameRef, fd: number, slice: () => boolean): Promise<void> {
for (const op of frameToOps(f, 'wal', fd, this.deps.valueMode())) {
this.applyRecoveredOp(op);
if (slice()) await yieldToLoop();
}
}
applyRecoveredOp(op: RecoveredOp): void {

View file

@ -97,3 +97,84 @@ test('bench --quick emits a JSON report with a stable schema', async () => {
await fs.rm(dir, { recursive: true, force: true });
}
}, 300_000);
test('open-lifecycle bench --quick emits the phase-1 baseline report', async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'minidb-open-bench-json-'));
try {
const reportPath = path.join(dir, 'report.json');
await promisify(execFile)(
process.execPath,
['--import', 'tsx', path.join(pkgDir, 'bench', 'open-lifecycle.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/open-lifecycle');
assert.ok(Array.isArray(report.scenarios));
const byName = (re) => report.scenarios.find((s) => re.test(s.name));
// Every row carries the event-loop-delay + input-latency baselines.
for (const s of report.scenarios) {
assert.equal(typeof s.durationMs, 'number', `${s.name}.durationMs`);
for (const k of ['mean', 'p50', 'p95', 'p99', 'max']) {
assert.equal(typeof s.eventLoopDelayMs[k], 'number', `${s.name}.eventLoopDelayMs.${k}`);
}
assert.ok(s.inputLatencyMs, `${s.name}.inputLatencyMs`);
for (const k of ['count', 'p50', 'p95', 'p99', 'max']) {
assert.equal(typeof s.inputLatencyMs[k], 'number', `${s.name}.inputLatencyMs.${k}`);
}
}
// The four scenario families exist with their lifecycle phase breakdown.
const PHASES = [
'openMs',
'generationCandidateLoadMs',
'storeImageLoadMs',
'nonTextImageLoadMs',
'textImageLoadMs',
'postingsIntegrityCheckMs',
'walScanMs',
'walApplyMs',
'fullRecoveryMs',
'textRebuildMs',
];
const small = byName(/small corpus, healthy generation/);
const wal = byName(/large WAL delta/);
const large = byName(/large full-text generation/);
const corrupt = byName(/corrupt generation \(full-recovery fallback\)/);
const deferred = byName(/deferred text rebuild \(degraded -> ready\)/);
for (const [name, s] of Object.entries({ small, wal, large, corrupt, deferred })) {
assert.ok(s, `${name} scenario exists`);
for (const k of PHASES) assert.equal(typeof s.extra.phases[k], 'number', `${name}.extra.phases.${k}`);
}
// Healthy generation opens: no full recovery, no corpus tokenization.
for (const [name, s] of Object.entries({ small, wal, large })) {
assert.equal(s.extra.state, 'ready', name);
assert.deepEqual(s.extra.path, ['no-generation', 'generation-load', 'wal-catch-up', 'ready'], name);
assert.equal(s.extra.phases.fullRecoveryMs, 0, name);
assert.equal(s.extra.phases.textRebuildMs, 0, name);
assert.equal(s.extra.generationLoads, 1, name);
assert.equal(s.extra.indexRebuildDecoded, 0, name);
}
// The WAL-delta open really replayed the delta (and close did NOT
// republish it into a fresh generation — the scenario would be void).
assert.ok(wal.extra.walDeltaAppliedOps > 0, 'wal delta replayed');
assert.equal(wal.extra.closePublishedGeneration, false, 'close left the generation untouched');
assert.ok(wal.extra.phases.walApplyMs > 0, 'wal apply measured');
// The corrupt generation fell back to a full recovery and returned
// degraded; the background deferred build then brought it to ready.
assert.equal(corrupt.extra.state, 'degraded');
assert.deepEqual(corrupt.extra.path, ['no-generation', 'generation-load', 'full-rebuild', 'degraded']);
assert.ok(corrupt.extra.phases.fullRecoveryMs > 0, 'full recovery measured');
assert.equal(corrupt.extra.generationLoadFallbacks, 1);
assert.equal(deferred.extra.state, 'ready');
assert.ok(deferred.extra.textDeferredBuilds >= 1, 'deferred build committed');
} finally {
await fs.rm(dir, { recursive: true, force: true });
}
}, 300_000);

View file

@ -14,6 +14,7 @@ import fsSync from 'node:fs';
import path from 'node:path';
import { afterEach, describe, expect, test, vi } from 'vitest';
import { MiniDb } from '../src/index.js';
import { TextIndex } from '../src/text-index/index.js';
import { SkipList, cmpNumber, cmpString } from '../src/skiplist.js';
import {
GenerationCorruptError,
@ -23,6 +24,11 @@ import {
readSecondaryIndexImage,
readCompoundIndexImage,
readTextDocsImage,
readSecondaryIndexImageAsync,
readCompoundIndexImageAsync,
readTextDocsImageAsync,
verifyFileIntegritySync,
verifyFileIntegrityAsync,
writeStoreImage,
writeDtIndexImage,
writeSecondaryIndexImage,
@ -30,6 +36,10 @@ import {
writeTextDocsImage,
} from '../src/gen-codec.js';
import type { StoreImageRecord } from '../src/gen-codec.js';
import { crc32 } from '../src/crc32.js';
import { Store } from '../src/store.js';
import { IndexManager } from '../src/index-manager.js';
import { CompoundIndexManager } from '../src/compound-index.js';
import { readManifest, listGenerations } from '../src/generation-files.js';
import { tmpDir, rmrf, waitFor, deferred } from './helpers.js';
@ -156,6 +166,32 @@ describe('skiplist bulkLoad', () => {
{ key: 1, val: 'b' },
]);
});
test('bulkLoadAsync builds the identical tower, sliced (incl. duplicates)', async () => {
const entries: { key: number; val: string }[] = [];
for (let i = 0; i < 5000; i++) {
entries.push({ key: i * 2, val: `v${i}` });
if (i % 997 === 0) entries.push({ key: i * 2, val: `v${i}` }); // exact duplicates
}
const sync = SkipList.bulkLoad(entries, { compareKey: cmpNumber, compareVal: cmpString });
const asyncList = await SkipList.bulkLoadAsync(entries, { compareKey: cmpNumber, compareVal: cmpString }, { sliceEvery: 1 });
expect(asyncList.length).toBe(sync.length);
expect(asyncList.toArray()).toEqual(sync.toArray());
for (const probe of [0, 1, 42, 2499, 4999]) {
const e = entries[probe]!;
expect(asyncList.getRank(e.key, e.val)).toBe(sync.getRank(e.key, e.val));
}
expect(asyncList.getByRank(0)).toEqual(sync.getByRank(0));
expect(asyncList.getByRank(asyncList.length - 1)).toEqual(sync.getByRank(sync.length - 1));
expect(asyncList.range({ gte: 100, lte: 200, count: 7 })).toEqual(sync.range({ gte: 100, lte: 200, count: 7 }));
// Mutations after the sliced bulk load keep every invariant.
asyncList.insert(3, 'v-new');
asyncList.delete(100, 'v50');
sync.insert(3, 'v-new');
sync.delete(100, 'v50');
expect(asyncList.toArray()).toEqual(sync.toArray());
expect(asyncList.getRank(3, 'v-new')).toBe(sync.getRank(3, 'v-new'));
});
});
// ---- unit: gen-codec round-trips --------------------------------------------
@ -285,6 +321,217 @@ describe('gen-codec', () => {
});
});
// ---- unit: the sliced (event-loop-friendly) open-path variants ---------------
describe('sliced open-path variants', () => {
test('verifyFileIntegrityAsync streams a chunked verify with the sync error semantics', async () => {
const dir = await openTmp('verify-async');
const p = path.join(dir, 'raw.bin');
const payload = Buffer.alloc(2 * 1024 * 1024 + 123); // spans multiple 1 MiB chunks
for (let i = 0; i < payload.length; i++) payload[i] = (i * 31 + 7) & 0xff;
await fs.writeFile(p, payload);
const good = { bytes: payload.length, crc32: crc32(payload) >>> 0 };
// Same verdict as the sync verifier, on accept and on both rejections.
await verifyFileIntegrityAsync(p, good);
expect(() => verifyFileIntegritySync(p, good)).not.toThrow();
const sizeErr = await verifyFileIntegrityAsync(p, { bytes: good.bytes + 1, crc32: good.crc32 }).catch((e) => e);
expect(sizeErr).toBeInstanceOf(GenerationCorruptError);
expect((sizeErr as Error).message).toBe('file size does not match manifest record');
const crcErr = await verifyFileIntegrityAsync(p, { bytes: good.bytes, crc32: (good.crc32 ^ 1) >>> 0 }).catch((e) => e);
expect(crcErr).toBeInstanceOf(GenerationCorruptError);
expect((crcErr as Error).message).toBe('file crc does not match manifest record');
expect(() => verifyFileIntegritySync(p, { bytes: good.bytes, crc32: (good.crc32 ^ 1) >>> 0 })).toThrow(
'file crc does not match manifest record',
);
// An empty file is a valid edge: zero chunks, crc of nothing.
const empty = path.join(dir, 'empty.bin');
await fs.writeFile(empty, Buffer.alloc(0));
await verifyFileIntegrityAsync(empty, { bytes: 0, crc32: 0 });
});
test('async image parsers match the sync parsers entry-for-entry', async () => {
const dir = await openTmp('parse-async');
const secImage = [
{
name: 'byKind',
field: 'kind',
type: 'equality' as const,
unique: false,
sparse: true,
equality: [
{ scalarKey: 'string:t1', pks: ['a', 'b'] },
{ scalarKey: 'number:7', pks: ['c'] },
],
range: null,
},
{
name: 'byScore',
field: 'score',
type: 'range' as const,
unique: false,
sparse: true,
equality: null,
range: [
{ value: 1.5, pk: 'a' },
{ value: 2, pk: 'b' },
{ value: 2, pk: 'c' },
],
},
];
await writeSecondaryIndexImage(path.join(dir, 'sec'), secImage);
const secPayload = parseGenerationBuffer(await fs.readFile(path.join(dir, 'sec')), 'MDSI', 1).payload;
const secSync = readSecondaryIndexImage(secPayload);
const secAsync = await readSecondaryIndexImageAsync(parseGenerationBuffer(await fs.readFile(path.join(dir, 'sec')), 'MDSI', 1).payload, 1);
expect(secAsync).toEqual(secSync);
const cmpImage = [
{
name: 'g',
groupBy: 'kind',
orderBy: 'ts',
orderType: 'number' as const,
groups: [
{
group: 't1',
entries: [
{ order: 5, pk: 'a' },
{ order: 6, pk: 'b' },
],
},
{ group: null, entries: [] },
{ group: true, entries: [{ order: 2, pk: 'y' }] },
],
},
];
await writeCompoundIndexImage(path.join(dir, 'cmp'), cmpImage);
const cmpSync = readCompoundIndexImage(parseGenerationBuffer(await fs.readFile(path.join(dir, 'cmp')), 'MDCI', 1).payload);
const cmpAsync = await readCompoundIndexImageAsync(parseGenerationBuffer(await fs.readFile(path.join(dir, 'cmp')), 'MDCI', 1).payload, 1);
expect(cmpAsync).toEqual(cmpSync);
const docsImage = {
keys: ['a', undefined, 'c', 'd'] as (string | undefined)[],
docLens: [10, undefined, 3, 40] as (number | undefined)[],
liveCount: 3,
removed: [1],
delta: [
{ term: 'hello', docs: [{ docID: 2, freq: 4 }] },
{
term: 'world',
docs: [
{ docID: 0, freq: 1 },
{ docID: 3, freq: 2 },
],
},
],
};
await writeTextDocsImage(path.join(dir, 'docs'), docsImage);
const docsSync = readTextDocsImage(parseGenerationBuffer(await fs.readFile(path.join(dir, 'docs')), 'MDTC', 1).payload);
const docsAsync = await readTextDocsImageAsync(parseGenerationBuffer(await fs.readFile(path.join(dir, 'docs')), 'MDTC', 1).payload, 1);
expect(docsAsync).toEqual(docsSync);
expect(docsAsync).toEqual(docsImage);
});
test('Store.bulkLoadRefsAsync builds the same store as bulkLoadRefs', async () => {
const records: StoreImageRecord[] = [];
for (let i = 0; i < 300; i++) {
const kstr = `k${String(i).padStart(4, '0')}`; // already ascending
records.push({
kstr,
ref: { kind: 'memory', value: Buffer.from(`v${i}`) },
expireAt: i % 10 === 0 ? 9999999999999 : 0,
dt: i % 3 === 0 ? { ts: i } : null,
metaBytes: i % 3 === 0 ? Buffer.byteLength(JSON.stringify({ dt: { ts: i } }), 'utf8') : 0,
});
}
const syncStore = new Store({ activeExpireIntervalMs: 0 });
const asyncStore = new Store({ activeExpireIntervalMs: 0 });
try {
syncStore.bulkLoadRefs(records);
await asyncStore.bulkLoadRefsAsync(records, { sliceEvery: 1 });
expect([...asyncStore.rawKeys()]).toEqual([...syncStore.rawKeys()]);
expect(asyncStore.size).toBe(syncStore.size);
expect(asyncStore.bytes).toBe(syncStore.bytes);
expect(asyncStore.get('k0007')).toEqual(syncStore.get('k0007'));
expect(asyncStore.getRecord('k0150')).toMatchObject({ expireAt: 9999999999999, dt: { ts: 150 } });
} finally {
syncStore.close();
asyncStore.close();
}
});
test('loadImageAsync (secondary + compound) builds the same queryable state', async () => {
const docs = [];
for (let i = 0; i < 200; i++) docs.push({ pk: `k${i}`, doc: { kind: `t${i % 7}`, score: i % 100, ts: 1700000000000 + i } });
const secSrc = new IndexManager();
secSrc.create('byKind', { field: 'kind' });
secSrc.create('byScore', { field: 'score', type: 'range' });
const cmpSrc = new CompoundIndexManager();
cmpSrc.create('byKindTime', { groupBy: 'kind', orderBy: 'ts' });
for (const { pk, doc } of docs) {
secSrc.add(pk, doc);
cmpSrc.add(pk, doc, null);
}
const secDst = new IndexManager();
secDst.create('byKind', { field: 'kind' });
secDst.create('byScore', { field: 'score', type: 'range' });
for (const image of secSrc.exportImage()) await secDst.loadImageAsync(image, { sliceEvery: 1 });
expect(secDst.findEq('byKind', 't3').sort()).toEqual(secSrc.findEq('byKind', 't3').sort());
expect(secDst.findRange('byScore', { min: 10, max: 42 })).toEqual(secSrc.findRange('byScore', { min: 10, max: 42 }));
const cmpDst = new CompoundIndexManager();
cmpDst.create('byKindTime', { groupBy: 'kind', orderBy: 'ts' });
for (const image of cmpSrc.exportImage().images) await cmpDst.loadImageAsync(image, { sliceEvery: 1 });
expect(cmpDst.range('byKindTime', 't2', { limit: 5 })).toEqual(cmpSrc.range('byKindTime', 't2', { limit: 5 }));
expect(cmpDst.range('byKindTime', 't5', { limit: 50 })).toEqual(cmpSrc.range('byKindTime', 't5', { limit: 50 }));
});
test('TextIndex.attachImageAsync attaches the same base as attachImage', async () => {
const dir = await openTmp('attach-async');
const postingsPath = path.join(dir, 'ft.postings');
const src = new TextIndex({ name: 'ft', fields: ['text'], postingsPath });
const syncTi = new TextIndex({ name: 'ft', fields: ['text'] });
const asyncTi = new TextIndex({ name: 'ft', fields: ['text'] });
try {
const corpus = [];
for (let i = 0; i < 300; i++) corpus.push({ key: `k${i}`, value: { text: `hello world ${i} 持久化 ${i % 7}` } });
await src.build(corpus);
src.add('late1', { text: 'late write walrus' }); // lands in the delta
src.remove('k5'); // tombstone
const image = src.exportImageState();
syncTi.attachImage({ postingsPath, ...image });
const docsImage = {
keys: image.keys,
docLens: (() => {
const out: (number | undefined)[] = [];
for (let i = 0; i < image.keys.length; i++) out.push(image.docLens.get(i));
return out;
})(),
liveCount: image.liveCount,
removed: [...image.removed],
delta: [...image.delta].map(([term, m]) => ({ term, docs: [...m].map(([docID, freq]) => ({ docID, freq })) })),
};
const dictEntries = [...image.dict].map(([term, e]) => ({ term, off: e.off, len: e.len, df: e.df }));
await asyncTi.attachImageAsync({ postingsPath, dictEntries, docs: docsImage }, { sliceEvery: 1 });
expect(asyncTi.exportImageState()).toEqual(syncTi.exportImageState());
expect(asyncTi.N).toBe(syncTi.N);
expect(asyncTi.search('walrus').length).toBe(syncTi.search('walrus').length);
expect(asyncTi.search('持久化').length).toBeGreaterThan(0);
} finally {
src.close();
syncTi.close();
asyncTi.close();
}
});
});
// ---- integration: build / publish / load ------------------------------------
describe('generation build + load', () => {
@ -1043,3 +1290,120 @@ describe('generation availability triggers (wal-growth + close publish)', () =>
await db.close();
}, 60000);
});
describe('open lifecycle status (phase timings + state machine)', () => {
test('a healthy generation open reports generation-load with zero corpus work', async () => {
const dir = await openTmp('lifecycle-healthy');
let db = await MiniDb.open<Record<string, unknown>>({ dir, valueCodec: 'json' });
await seedIndexedDb(db, 2000);
await db.rebuildGeneration();
const gen = db.getIndexGeneration()!;
await db.close();
// Hard proof that no full-corpus tokenization runs on the attach path:
// the staged builder (the only in-open corpus tokenizer besides the
// bounded rebuild) must not be entered at all.
const buildSpy = vi.spyOn(TextIndex.prototype, 'build');
try {
db = await MiniDb.open<Record<string, unknown>>({ dir, valueCodec: 'json' });
expect(buildSpy).not.toHaveBeenCalled();
} finally {
buildSpy.mockRestore();
}
expect(db.stats.generationLoads).toBe(1);
expect(db.stats.generationIndexRebuilds).toBe(0);
expect(db.stats.indexRebuildDecoded).toBe(0); // no value re-decode
expect(db.stats.textRebuildDurationMs).toBe(0); // no tokenization
const status = db.lifecycleStatus();
expect(status.state).toBe('ready');
expect(status.path).toEqual(['no-generation', 'generation-load', 'wal-catch-up', 'ready']);
expect(status.openedAt).not.toBeNull();
expect(status.phases.openMs).toBeGreaterThan(0);
expect(status.phases.generationCandidateLoadMs).toBeGreaterThan(0);
expect(status.phases.storeImageLoadMs).toBeGreaterThan(0);
expect(status.phases.postingsIntegrityCheckMs).toBeGreaterThan(0);
expect(status.phases.textImageLoadMs).toBeGreaterThan(0);
expect(status.phases.walScanMs).toBeGreaterThanOrEqual(0);
expect(status.phases.fullRecoveryMs).toBe(0);
expect(status.phases.textRebuildMs).toBe(0);
expect(status.textIndexes).toEqual({ ft: 'image', tri: 'image' });
expect(status.pendingTextIndexes).toEqual([]);
expect(db.recoveryInfo?.indexGeneration?.id).toBe(gen.id);
expect(db.recoveryInfo?.walDeltaAppliedOps).toBe(0);
assertSeededDb(db, 2000);
await db.close();
});
test('a corrupt generation falls back to full-rebuild (only then is the corpus re-tokenized)', async () => {
const dir = await openTmp('lifecycle-corrupt');
let db = await MiniDb.open<Record<string, unknown>>({ dir, valueCodec: 'json' });
await seedIndexedDb(db, 2000);
await db.rebuildGeneration();
const gen = db.getIndexGeneration()!;
await db.close();
// Flip one byte inside the generation's store image: the manifest crc
// rejects the candidate and the open must fall back to the legacy full
// recovery — the ONLY path allowed to re-tokenize the corpus.
const storeImage = path.join(dir, 'generations', gen.id, 'store');
const raw = await fs.readFile(storeImage);
raw[Math.floor(raw.length / 2)]! ^= 0xff;
await fs.writeFile(storeImage, raw);
db = await MiniDb.open<Record<string, unknown>>({ dir, valueCodec: 'json' });
expect(db.stats.generationLoads).toBe(0);
expect(db.stats.generationLoadFallbacks).toBe(1);
expect(db.stats.lastGenerationFallback).toContain(gen.id);
expect(db.recoveryInfo?.indexGeneration).toBeUndefined();
expect(db.stats.indexRebuildDecoded).toBeGreaterThan(0); // corpus re-decoded
expect(db.stats.textRebuildDurationMs).toBeGreaterThan(0); // corpus re-tokenized
const status = db.lifecycleStatus();
// 2000 docs < TEXT_BUILD_WORKER_MIN_DOCS: the rebuild stays in-open
// (staged), so the instance is fully ready when open() returns.
expect(status.state).toBe('ready');
expect(status.path).toEqual(['no-generation', 'generation-load', 'full-rebuild', 'ready']);
expect(status.phases.fullRecoveryMs).toBeGreaterThan(0);
expect(status.phases.walScanMs).toBeGreaterThan(0);
expect(status.phases.walApplyMs).toBeGreaterThan(0);
expect(status.phases.textRebuildMs).toBeGreaterThan(0);
expect(status.textIndexes).toEqual({ ft: 'staged', tri: 'staged' });
assertSeededDb(db, 2000);
await db.close();
});
test('a missing generation with a deferrable corpus reports degraded until the background text build commits', async () => {
const dir = await openTmp('lifecycle-degraded');
let db = await MiniDb.open<Record<string, unknown>>({ dir, valueCodec: 'json' });
await seedIndexedDb(db, 5000); // >= TEXT_BUILD_WORKER_MIN_DOCS: deferrable
await db.rebuildGeneration();
await db.close();
// No candidate at all: the open starts from 'no-generation'.
await fs.rm(path.join(dir, 'generations'), { recursive: true, force: true });
await fs.rm(path.join(dir, 'CURRENT'), { force: true });
db = await MiniDb.open<Record<string, unknown>>({ dir, valueCodec: 'json' });
expect(db.stats.generationLoads).toBe(0);
const building = db.lifecycleStatus();
expect(building.state).toBe('degraded');
expect(building.path).toEqual(['no-generation', 'full-rebuild', 'degraded']);
expect(building.pendingTextIndexes.sort()).toEqual(['ft', 'tri']);
expect(building.textIndexes).toEqual({ ft: 'deferred', tri: 'deferred' });
expect(db.textIndexBuilding('ft')).toBe(true);
expect(() => db.search('ft', 'hello')).toThrowError(/still building/);
await waitFor(() => db.lifecycleStatus().state === 'ready', 'deferred text builds');
const ready = db.lifecycleStatus();
expect(ready.path).toEqual(['no-generation', 'full-rebuild', 'degraded', 'ready']);
expect(ready.pendingTextIndexes).toEqual([]);
expect(['worker', 'inline']).toContain(ready.textIndexes['ft']);
expect(['worker', 'inline']).toContain(ready.textIndexes['tri']);
expect(ready.phases.textRebuildMs).toBeGreaterThan(0);
expect(db.stats.textDeferredBuilds).toBe(2);
expect(db.stats.textDeferredBuildErrors).toBe(0);
assertSeededDb(db, 5000);
await db.close();
}, 60000);
});

View file

@ -312,3 +312,40 @@ test('close() waits out a failing in-flight compaction and still cleans up every
await fs.rm(dir, { recursive: true, force: true });
}
});
test('onLockAcquired fires with the held token; read-only fallback opens skip it', async () => {
const dir = await tmpDir();
let reported;
const db1 = await MiniDb.open({
dir,
valueCodec: 'string',
onLockAcquired: (info) => {
reported = info.token;
},
});
try {
// Fired before open() resolved, and matches the published lock line —
// a supervisor learns the lock identity before any heavy recovery ran.
assert.ok(reported);
const line = JSON.parse(await fs.readFile(path.join(dir, 'db.lock'), 'utf8'));
assert.equal(line.token, reported);
assert.equal(line.pid, process.pid);
// A read-only fallback open (the lock is busy) never fires the callback.
let second;
const ro = await MiniDb.open({
dir,
valueCodec: 'string',
onLockFail: 'readonly',
onLockAcquired: (info) => {
second = info.token;
},
});
assert.equal(ro.readOnly, true);
assert.equal(second, undefined);
await ro.close();
} finally {
await db1.close();
await fs.rm(dir, { recursive: true, force: true });
}
});

View file

@ -199,6 +199,138 @@ for (const valueMode of ['memory', 'disk'] as const) {
});
}
test('catchUpFromWal: a torn tail pauses the catch-up and a later call completes it', async () => {
const dir = await tmpDir();
try {
const writer = await MiniDb.open({ dir, valueCodec: 'json', fsyncPolicy: 'no', autoCompact: false });
for (let i = 0; i < 10; i++) await writer.set(`k${i}`, { n: i });
const reader = await MiniDb.open({ dir, valueCodec: 'json', readOnly: true });
const ri = reader.recoveryInfo!;
// Simulate a writer mid-writev: only a PREFIX of the next frame is on
// disk. The catch-up must stop at the last fully-valid frame without
// erroring, and must not advance the watermark past it.
const frame = encodeFrame({
type: TYPE_SET,
key: Buffer.from('torn', 'utf8'),
value: Buffer.from(JSON.stringify({ n: 99 }), 'utf8'),
});
const half = Math.floor(frame.length / 2);
const walPath = path.join(dir, 'db.wal');
await fs.appendFile(walPath, frame.subarray(0, half));
const paused = await reader.catchUpFromWal(ri.walScanEnd);
assert.deepEqual(paused, { offset: ri.walScanEnd, appliedFrames: 0 });
assert.equal(reader.get('torn'), undefined);
// The writev lands: the tail's CRC now validates and the frame applies.
await fs.appendFile(walPath, frame.subarray(half));
const done = await reader.catchUpFromWal(ri.walScanEnd);
assert.deepEqual(done, { offset: ri.walScanEnd + frame.length, appliedFrames: 1 });
assert.deepEqual(reader.get('torn'), { n: 99 });
await reader.close();
await writer.close();
} finally {
await fs.rm(dir, { recursive: true, force: true });
}
});
test('catchUpFromWal: a compaction rotation swaps the WAL inode and the catch-up declines', async () => {
const dir = await tmpDir();
try {
const writer = await MiniDb.open({ dir, valueCodec: 'json', fsyncPolicy: 'no', autoCompact: false });
for (let i = 0; i < 100; i++) await writer.set(`k${i}`, { n: i });
const reader = await MiniDb.open({ dir, valueCodec: 'json', readOnly: true });
const ri = reader.recoveryInfo!;
assert.ok(ri.walScanEnd > 0);
// Rotation: compaction swaps db.snapshot/db.wal in two renames, so the
// WAL the reader anchored on is gone — incremental catch-up must decline
// (null) and leave the reopen decision to the caller.
await writer.compact();
assert.equal(await reader.catchUpFromWal(ri.walScanEnd), null);
// A fresh read-only reopen sees every write, including post-rotation ones.
await writer.set('post-rotation', { n: 101 });
await reader.close();
const reopened = await MiniDb.open({ dir, valueCodec: 'json', readOnly: true });
assert.equal(reopened.size, 101);
assert.deepEqual(reopened.get('post-rotation'), { n: 101 });
await reopened.close();
await writer.close();
} finally {
await fs.rm(dir, { recursive: true, force: true });
}
});
test('catchUpFromWal: concurrent calls are serialized instead of interleaving mid-apply', async () => {
const dir = await tmpDir();
try {
const writer = await MiniDb.open({ dir, valueCodec: 'json', fsyncPolicy: 'no', autoCompact: false });
for (let i = 0; i < 50; i++) await writer.set(`k${i}`, { n: i });
const reader = await MiniDb.open({ dir, valueCodec: 'json', readOnly: true });
const ri = reader.recoveryInfo!;
for (let i = 50; i < 150; i++) await writer.set(`k${i}`, { n: i });
// The sliced async apply yields to the event loop, so two overlapping
// catch-ups at the same watermark would interleave op-by-op without the
// per-instance chain. The second call runs after the first advanced the
// watermark and declines (the caller then falls back to a reopen).
const [first, second] = await Promise.all([
reader.catchUpFromWal(ri.walScanEnd),
reader.catchUpFromWal(ri.walScanEnd),
]);
assert.ok(first && first.appliedFrames === 100);
assert.equal(second, null);
assert.equal(reader.size, 150);
await reader.close();
await writer.close();
} finally {
await fs.rm(dir, { recursive: true, force: true });
}
});
test('catchUpFromWal: a large tail applies in cooperative slices (event loop stays live)', { timeout: 60_000 }, async () => {
const dir = await tmpDir();
try {
const writer = await MiniDb.open({ dir, valueCodec: 'json', fsyncPolicy: 'no', autoCompact: false });
for (let i = 0; i < 100; i++) await writer.set(`pre:${i}`, { n: i, t: `alpha ${i}` });
await writer.createTextIndex('t', { fields: ['t'] });
const reader = await MiniDb.open({ dir, valueCodec: 'json', readOnly: true });
const ri = reader.recoveryInfo!;
// 8000 single-op frames: WAL_APPLY_OPS_PER_SLICE (512) alone forces ~15
// yields mid-apply even when every op is cheap.
for (let i = 0; i < 8000; i++) await writer.set(`s:${i}`, { n: i, t: `alpha beta ${i}` });
// Count event-loop turns observed while the catch-up runs. The async
// scan contributes about one (a sub-window file fills in one read); an
// UNSLICED apply would add none, the sliced one adds one per 512 ops.
let loopTurns = 0;
let catchUpDone = false;
const probe = (async () => {
for (;;) {
await new Promise((r) => setImmediate(r));
if (catchUpDone) return;
loopTurns++;
}
})();
const res = await reader.catchUpFromWal(ri.walScanEnd);
catchUpDone = true;
await probe;
assert.ok(res && res.appliedFrames === 8000);
assert.ok(loopTurns >= 3, `expected the sliced apply to yield repeatedly, observed ${loopTurns} loop turn(s)`);
assert.equal(reader.size, 8100);
assert.ok(reader.search('t', 'beta').length > 0);
await reader.close();
await writer.close();
} finally {
await fs.rm(dir, { recursive: true, force: true });
}
});
// ---- generation pairing (stat-pairing; review #15) --------------------------
//
// Fault-injection tests for recover()'s snapshot/WAL generation pairing. The
@ -643,3 +775,65 @@ test('async scanner: resync candidate budget bounds fake-magic storms', async ()
await fs.rm(dir, { recursive: true, force: true });
}
});
// ---- phase 3: cooperative slicing of the recovered-op apply loops -----------
import { walApplySlicer, WAL_APPLY_OPS_PER_SLICE, WAL_APPLY_SLICE_MS } from '../src/recovery.js';
test('walApplySlicer trips on the op budget, then resets; the time budget trips independently', () => {
const slice = walApplySlicer();
for (let i = 1; i < WAL_APPLY_OPS_PER_SLICE; i++) assert.equal(slice(), false);
assert.equal(slice(), true); // the op budget trips exactly at the cap
assert.equal(slice(), false); // counters reset for the next slice
// The time budget trips below the op cap once the slice ran long enough.
const timed = walApplySlicer();
assert.equal(timed(), false); // one op, well under the op cap
const t0 = performance.now();
while (performance.now() - t0 < WAL_APPLY_SLICE_MS + 5) {
// busy-wait: keep this slice over its time budget with ops to spare
}
assert.equal(timed(), true);
});
test('a generation WAL delta of large batch frames replays every op through the sliced apply loop', async () => {
const dir = await tmpDir();
try {
let db = await MiniDb.open({ dir, valueCodec: 'json', fsyncPolicy: 'no', autoCompact: false });
db.genBuildKickMinIntervalMs = Number.POSITIVE_INFINITY; // exactly one explicit generation
for (let base = 0; base < 2000; base += 500) {
await db.batch(Array.from({ length: 500 }, (_, i) => ({ op: 'set' as const, key: `b${base + i}`, value: { n: base + i } })));
}
await db.rebuildGeneration();
await db.close();
// A WAL delta of batch frames past the checkpoint: thousands of primitive
// ops in a handful of frames — frame-granular yielding could never slice
// this; the op/time budgets can. Small values keep the delta far below
// the close-time republish threshold.
db = await MiniDb.open({ dir, valueCodec: 'json', fsyncPolicy: 'no', autoCompact: false });
const gen = db.getIndexGeneration()?.id;
for (let base = 0; base < 3000; base += 500) {
await db.batch(Array.from({ length: 500 }, (_, i) => ({ op: 'set' as const, key: `d${base + i}`, value: { n: base + i } })));
}
await db.close();
assert.equal(db.getIndexGeneration()?.id, gen); // no close-time republish: the delta must be replayed
db = await MiniDb.open({ dir, valueCodec: 'json', autoCompact: false });
try {
assert.ok(db.recoveryInfo?.indexGeneration);
assert.equal(db.recoveryInfo?.walDeltaAppliedOps, 3000);
assert.equal(db.size, 5000);
assert.deepEqual(db.get('d0'), { n: 0 });
assert.deepEqual(db.get('d2999'), { n: 2999 });
assert.deepEqual(db.get('b1999'), { n: 1999 });
const status = db.lifecycleStatus();
assert.equal(status.state, 'ready');
assert.deepEqual(status.path, ['no-generation', 'generation-load', 'wal-catch-up', 'ready']);
assert.ok(status.phases.walApplyMs > 0);
} finally {
await db.close();
}
} finally {
await fs.rm(dir, { recursive: true, force: true });
}
});

View file

@ -205,3 +205,40 @@ test('reapExpiredDue falls back to a full scan when the heap diverged from the m
assert.equal(s.map.size, 0);
s.close();
});
test('bulkLoadRefsAsync pauses active expiry: a TTL expiring mid-load cannot diverge the ordered index', async () => {
// A TTL that elapses DURING the sliced load: with the active-expire tick
// firing mid-load, the reaped key would vanish from the map but stay in
// the rebuilt ordered index — and a later re-set would insert a SECOND
// ordered entry, producing duplicate scan results. Pausing expiry for the
// load keeps the resulting state identical to the sync bulk load.
const short = 'a-short-ttl'; // sorts before every kNNNNN
const records = [
{ kstr: short, ref: { kind: 'memory' as const, value: B('v0') }, expireAt: Date.now() + 1, dt: null },
...Array.from({ length: 20000 }, (_, i) => ({
kstr: `k${String(i).padStart(5, '0')}`,
ref: { kind: 'memory' as const, value: B(`v${i}`) },
expireAt: 0,
dt: null,
})),
];
const s = new Store({ activeExpireIntervalMs: 1 }); // a tick lands within the first yields
try {
await s.bulkLoadRefsAsync(records, { sliceEvery: 50 }); // 400 yields: the tick fires mid-load
// The divergence repro: re-set the key — a stale ordered entry left by a
// mid-load reap would now show it twice in ordered scans.
s.set(short, B('v1'));
const scanned = [...s.scan()].map((e) => e.key.toString());
assert.equal(scanned.length, new Set(scanned).size, 'ordered scan must not contain duplicates');
assert.equal(scanned.length, 20001);
// The ordered index and the map agree exactly (no stale, no missing).
assert.deepEqual([...s.rawKeys()], [...s.map.keys()].sort());
// And the next tick after the load reaps normally (expiry only deferred).
s.set(short, B('v2'), Date.now() - 1); // already expired
await sleep(20);
assert.equal(s.map.has(short), false);
assert.deepEqual([...s.rawKeys()], [...s.map.keys()].sort());
} finally {
s.close();
}
});

View file

@ -22,6 +22,7 @@ import {
import { buildTextArtifacts } from '../src/worker/text-build-core.js';
import type { TextBuildCoreSpec } from '../src/worker/text-build-core.js';
import { startWorkerTextBuild, WorkerTextBuildError, verifyFileCrcAsync } from '../src/worker/text-build.js';
import { WorkerSlots, MaintenanceCancelledError, defaultWorkerSlots } from '../src/maintenance.js';
import { tmpDir, rmrf, waitFor } from './helpers.js';
const cleanups: (() => Promise<void> | void)[] = [];
@ -897,3 +898,101 @@ describe('single-file deployment (worker entry absent)', () => {
}
}, 120000);
});
describe('worker slot queue (TUI-safe slot pressure policy)', () => {
test('acquireBounded grants a freed slot, times out to null, and rejects on abort', async () => {
const slots = new WorkerSlots(1);
// Queued: the waiter is granted the slot the holder releases.
const first = slots.tryAcquire();
expect(first).not.toBeNull();
let granted = false;
const waiting = slots.acquireBounded(5000).then((r) => {
granted = r !== null;
return r;
});
await new Promise((r) => setTimeout(r, 20));
expect(granted).toBe(false);
first!();
const release = await waiting;
expect(granted).toBe(true);
release!();
// Timeout: a drought outlasting the wait resolves null (the caller's
// inline-last-resort decision point).
const held = slots.tryAcquire();
expect(held).not.toBeNull();
expect(await slots.acquireBounded(20)).toBeNull();
// Abort: a queued waiter rejects with MaintenanceCancelledError.
const controller = new AbortController();
const aborted = slots.acquireBounded(5000, controller.signal);
controller.abort();
await expect(aborted).rejects.toBeInstanceOf(MaintenanceCancelledError);
held!();
});
test('a worker-eligible build queues for a slot under pressure instead of going inline', async () => {
const dir = await openTmp('slot-queue');
const db = await MiniDb.open<Record<string, unknown>>({ dir, valueCodec: 'json' });
await seedDataOnly(db, 5000);
// Hold every process-wide slot: the build cannot start a worker NOW, and
// the TUI-safe policy must QUEUE it, not drop it onto the main thread.
const held: (() => void)[] = [];
for (let i = 0; i < defaultWorkerSlots.total; i++) {
const r = defaultWorkerSlots.tryAcquire();
assert(r !== null);
held.push(r);
}
db.textBuildSlotWaitMs = 60_000; // the grant, not the timeout, must end the wait
let settled = false;
const createP = db
.createTextIndex('ft', { fields: ['text'] })
.then(() => {
settled = true;
})
.catch((e) => {
settled = true;
throw e;
});
// startInline records its fallback synchronously, so after a settle-free
// window with no fallback stat the build provably did NOT go inline.
await new Promise((r) => setTimeout(r, 200));
expect(settled).toBe(false);
expect(db.stats.textWorkerFallbacks).toBe(0);
expect(db.stats.textWorkerBuilds).toBe(0);
// Free a slot: the queued build takes it and completes through the worker.
held[0]!();
try {
await createP;
} finally {
for (const r of held.slice(1)) r();
}
expect(db.stats.textWorkerBuilds).toBe(1);
expect(db.stats.textWorkerFallbacks).toBe(0);
expect(db.search('ft', 'hello').length).toBeGreaterThan(0);
await db.close();
}, 60000);
test('a persisted slot drought hosts the bounded inline core (explicit last resort)', async () => {
const dir = await openTmp('slot-drought');
const db = await MiniDb.open<Record<string, unknown>>({ dir, valueCodec: 'json' });
await seedDataOnly(db, 5000);
db.textBuildSlotWaitMs = 50; // the drought outlasts the queue wait
const held: (() => void)[] = [];
for (let i = 0; i < defaultWorkerSlots.total; i++) {
const r = defaultWorkerSlots.tryAcquire();
assert(r !== null);
held.push(r);
}
try {
await db.createTextIndex('ft', { fields: ['text'] });
} finally {
for (const r of held) r();
}
// The bounded inline core built the index (never the unbounded staged
// aggregation), and the fallback is accounted explicitly.
expect(db.stats.textWorkerBuilds).toBe(0);
expect(db.stats.textWorkerFallbacks).toBe(1);
expect(db.stats.lastTextWorkerFallback).toBe('slot-pressure');
expect(db.search('ft', 'hello').length).toBeGreaterThan(0);
await db.close();
}, 60000);
});

View file

@ -158,6 +158,8 @@ import {
applyPromptMetadataUpdate,
bootstrap,
DEFAULT_AGENT_PROFILE_NAME,
drainQueryStoreDisposals,
drainSessionIndexMirror,
ensureKimiHome,
ensureMainAgent,
IAgentActivityView,
@ -187,6 +189,7 @@ import {
ISessionCronService,
ISessionExportService,
ISessionIndex,
ISessionIndexMirror,
ISessionInitService,
ISessionMcpHandle,
ISessionMetadata,
@ -487,7 +490,14 @@ export class SDKRpcClientV2 extends SDKRpcClientBase {
subscription.dispose();
}
await this.klient.close();
// Same shutdown order as kap-server: drain the session-index mirror while
// the query store is still open, then await the asynchronous closes that
// disposal fires — a host that removes homeDir right after close() must
// not race an in-flight shard close (ENOTEMPTY on teardown).
await this.app.accessor.get(ISessionIndexMirror).drain();
this.app.dispose();
await drainSessionIndexMirror();
await drainQueryStoreDisposals();
}
/**

View file

@ -9,9 +9,15 @@ import { mkdir, mkdtemp, readFile, rm, utimes, writeFile } from 'node:fs/promise
import { tmpdir } from 'node:os';
import { basename, dirname, join } from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { createKimiHarness } from '#/index';
import {
drainQueryStoreDisposals,
drainSessionIndexMirror,
ISessionIndex,
} from '@moonshot-ai/agent-core-v2';
import { createKimiHarness, SDKRpcClientV2 } from '#/index';
import type { KimiError } from '#/index';
import {
@ -430,3 +436,74 @@ describe('KimiHarness.listSessions', () => {
}
});
});
describe('SDKRpcClientV2 search-index separation', () => {
// The global full-text search database (`<homeDir>/search-index`) belongs
// to the kap-server search surface. The TUI-side chain (rpc client →
// klient → `ISessionIndex`) must list, resume and continue sessions without
// ever opening it — including while the session read model is still
// preparing.
it('listSessions / resumeSession never open the global search index (read model off)', async () => {
vi.stubEnv('KIMI_CODE_EXPERIMENTAL_FLAG', '0');
vi.stubEnv('KIMI_CODE_EXPERIMENTAL_PERSISTENCE_MINIDB_READMODEL', '0');
const homeDir = await makeTempDir();
const workDir = await makeTempDir();
const client = new SDKRpcClientV2({ homeDir, identity: TEST_IDENTITY });
try {
const created = await client.createSession({ id: 'ses_search_sep_off', workDir });
await client.closeSession({ sessionId: created.id });
const sessions = await client.listSessions({ workDir });
expect(sessions.map((item) => item.id)).toEqual([created.id]);
const resumed = await client.resumeSession({ id: created.id });
expect(resumed.id).toBe(created.id);
expect(existsSync(join(homeDir, 'search-index'))).toBe(false);
// With the read model off, the session query-store is never opened either.
expect(existsSync(join(homeDir, 'cache', 'query-store'))).toBe(false);
} finally {
await client.close();
vi.unstubAllEnvs();
}
});
it('listSessions / resumeSession never open the global search index (read model on)', async () => {
vi.stubEnv('KIMI_CODE_EXPERIMENTAL_FLAG', '0');
vi.stubEnv('KIMI_CODE_EXPERIMENTAL_PERSISTENCE_MINIDB_READMODEL', '1');
const homeDir = await makeTempDir();
const workDir = await makeTempDir();
const client = new SDKRpcClientV2({ homeDir, identity: TEST_IDENTITY });
try {
const created = await client.createSession({ id: 'ses_search_sep_on', workDir });
await client.closeSession({ sessionId: created.id });
// The read model is still preparing here: the first list kicks the
// background projection and answers from authoritative metadata, the
// resume reads the authoritative document — neither waits for, nor
// opens, any full-text index.
const sessions = await client.listSessions({ workDir });
expect(sessions.map((item) => item.id)).toEqual([created.id]);
const resumed = await client.resumeSession({ id: created.id });
expect(resumed.id).toBe(created.id);
expect(existsSync(join(homeDir, 'search-index'))).toBe(false);
// Settle the kicked projection before close so teardown never races it,
// and prove the read model really did engage (the flag took effect).
const status = await client.engineAccessor.get(ISessionIndex).prepare();
expect(status.state).toBe('ready');
expect(existsSync(join(homeDir, 'cache', 'query-store'))).toBe(true);
expect(existsSync(join(homeDir, 'search-index'))).toBe(false);
} finally {
await client.close();
// Dispose fired the mirror/query-store async closes; await them before
// the shared afterEach removes the temp home.
await drainSessionIndexMirror();
await drainQueryStoreDisposals();
vi.unstubAllEnvs();
}
});
});

View file

@ -23,7 +23,11 @@ import {
type KimiConfig,
} from '#/index';
import { foldAgentWireReplay } from '#/v2/resume-replay';
import { IHostRequestHeaders } from '@moonshot-ai/agent-core-v2';
import {
drainQueryStoreDisposals,
drainSessionIndexMirror,
IHostRequestHeaders,
} from '@moonshot-ai/agent-core-v2';
import { TEST_IDENTITY } from './test-identity';
import { recordingTelemetry, type TelemetryRecord } from './telemetry';
@ -31,6 +35,10 @@ import { recordingTelemetry, type TelemetryRecord } from './telemetry';
const tempDirs: string[] = [];
afterEach(async () => {
// The read-model mirror/query-store close asynchronously on dispose; await
// the drains so the rm below never races their final flush (ENOTEMPTY).
await drainSessionIndexMirror();
await drainQueryStoreDisposals();
for (const dir of tempDirs.splice(0)) {
await rm(dir, { recursive: true, force: true });
}