Skip to content

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

Merged
sailist merged 11 commits into
MoonshotAI:mainfrom
sailist:feat/search-index-separation
Aug 6, 2026
Merged

feat: isolate the full-text search index from the session index and the main thread#2701
sailist merged 11 commits into
MoonshotAI:mainfrom
sailist:feat/search-index-separation

Conversation

@sailist

@sailist sailist commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator

Related Issue

No linked issue — the problem is explained below.

Problem

The global full-text search index (<home>/search-index MiniDb) shares the Node main process — and its event loop — with the TUI. Opening a large search generation, replaying a large WAL delta, or rebuilding postings ran as long synchronous slices on that main thread: a repeatable bench measured event-loop delays of 331 ms while loading a 200k-doc generation, 492 ms recovering a corrupt one, and 734 ms replaying a 3 MiB WAL delta, which users feel as typing/render freezes at startup or while idle. Session listing (listSessions, --resume, --continue) was entangled with the same process lifecycle, and the MiniDB open path had no structured timing to tell a snapshot load apart from a WAL catch-up or a full rebuild.

What changed

1. Observability baseline (minidb)

  • MiniDb.lifecycleStatus(): an explicit no-generation → generation-load → wal-catch-up → full-rebuild → ready/degraded state machine plus per-phase timings (candidate load, store/non-text/text image load, postings CRC, WAL scan/apply, full recovery, text rebuild hosting).
  • A repeatable open-lifecycle bench (pnpm -C packages/minidb bench:open) over four scenarios (small corpus, large WAL delta, large full-text generation, corrupt generation) reporting wall time, event-loop delay, and input-latency percentiles.
  • Fixtures proving a healthy generation open performs zero full-corpus tokenization; only corrupt or missing generations fall back to a rebuild.

2. Session index isolation (agent-core-v2)

  • The session query-store is pinned as a structural-only read model: text index definitions are rejected at definition level, and its generation is asserted to carry no dictionary/docs/postings artifacts.
  • The first list and the initial projection share one single-flight authoritative scan instead of scanning twice; reads only join in-flight scans and fold the mirror's pending queue, so read-your-writes holds while the read model is preparing.
  • listSessions / --resume / --continue never open the global search DB; a corrupt or unopenable search index never fails session list/create/resume/replay, and only real full-text search requests report building/stale/degraded. persistence_minidb_readmodel is now on by default (rollback via env/config).

3. Cooperative open path (minidb)

  • Chunked async CRC verification with the exact error semantics of the sync originals, a primitive-op + wall-clock budget for the WAL-delta apply (a batch frame unrolls into thousands of ops, so frame-granular yielding never bounded a slice), and sliced store/text/secondary/compound image attachment.
  • Worker-slot pressure now queues for a bounded wait instead of falling back to an unbounded inline text build.
  • Result: event-loop delay max across the four bench scenarios dropped from 45/734/331/492 ms to ~16–22 ms, with wall time flat or better.

4. Search worker isolation (kap-server + CLI packaging)

  • The whole search-index lifecycle (open, generation load, WAL replay, sync, rebuild, compaction) runs in a dedicated worker_threads host behind a versioned request/response protocol. The host-agnostic SearchIndexCore also backs an inline host kept as the explicit diagnostic rollback (search_worker flag, default ON; KIMI_CODE_EXPERIMENTAL_SEARCH_WORKER=false).
  • Lock safety: the write-lock token is reported at acquire time (new OpenOptions.onLockAcquired hook) and reaped on dirty exit; an orphan-lock detector 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 semantics: in-flight requests fail 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.
  • Queries keep reading the published generation and never wait for background sync/rebuild; live transcript search for the active session stays in-process.
  • Packaging: self-contained worker bundles for the npm dist and the SEA asset manifest/installer/smoke check, plus a dev runtime (type-stripping + .ts resolve hook) scoped to worker execArgv.

5. Explicit lifecycles and consistency

  • The search lifecycle (stopped/opening/building/ready/degraded/closing) is surfaced end to end through a never-throwing status() and a synchronous lifecycleReport() that neither kicks the open nor spawns the worker; corrupt-index rebuilds get a dedicated warn log, keeping building, stale, degraded, corrupt, and worker-unavailable distinguishable in diagnostics.
  • Read-only replica WAL catch-up is fully cooperative (windowed async scan + per-op yields, serialized per instance so each caller keeps its atomic watermark advance).
  • Tests pin the dependency direction (session lifecycle never depends on global search), the absence of duplicate open/projection/replay, and worker/main-process exit ordering with lock release.

6. Validation

  • Full suites green: minidb 551, agent-core-v2 4760, kap-server 1005, node-sdk 343, klient 91, CLI 2567 tests; oxlint --type-aware 0 errors; typecheck clean on all touched packages.
  • Worker-mode probes assert the main thread stays under 20 ms p99 event-loop delay while the worker opens/syncs/reindexes a corpus.

Checklist

  • I have read the CONTRIBUTING document.
  • I have linked a related issue, or explained the problem above.
  • I have added tests that prove my feature works.
  • Ran gen-changesets skill, or this PR needs no changeset.
  • Ran gen-docs skill, or this PR needs no doc update. (Explicit decision: no user-doc change — the TUI does not consume the global search state, the docs have no global-search page to update, and session list/resume behavior is unchanged for end users.)

sailist added 6 commits August 6, 2026 13:44
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.
… 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.
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.
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.
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.
…he 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.
@changeset-bot

changeset-bot Bot commented Aug 6, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 6198f75

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@moonshot-ai/kimi-code Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

…aration

# Conflicts:
#	packages/agent-core-v2/src/session/sessionMetadata/sessionMetadataService.ts
@pkg-pr-new

pkg-pr-new Bot commented Aug 6, 2026

Copy link
Copy Markdown
pnpm dlx https://pkg.pr.new/@moonshot-ai/kimi-code@e0805d7
npx https://pkg.pr.new/@moonshot-ai/kimi-code@e0805d7

commit: e0805d7

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 37a48325d9

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Clear pending summaries before merging session reads

When the read model is enabled, a session that is created or updated and then deleted before the 100ms mirror flush can still be returned by list/count reads: the authoritative scan no longer sees the deleted directory, but this pending fold re-adds the stale SessionSummary, while sessionLifecycle.delete() only calls FileSessionIndex.remove() and never clears mirror.pending(). Please clear/tombstone pending entries on delete before merging them into results so deleted sessions stop appearing immediately.

Useful? React with 👍 / 👎.

Comment thread packages/minidb/src/store.ts Outdated
Comment on lines +415 to +417
if (++n % sliceEvery === 0) await new Promise((r) => setImmediate(r));
}
this.order = await SkipList.bulkLoadAsync(orderEntries, { compareKey: cmpString }, { sliceEvery });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Pause active expiry during async bulk load

During generation open for a large store image with TTL records that expire while this sliced load is yielding, the Store active-expire timer can run before this.order is rebuilt: it removes the key from map and the old empty order, then the final bulk load rebuilds order from stale orderEntries. That leaves missing/expired keys in the ordered index and can produce duplicate scan results if the same key is later set again; disable active expiry during the bulk load or rebuild the order from the live map after the yields.

Useful? React with 👍 / 👎.

sailist added 4 commits August 6, 2026 20:46
…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.
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).
@sailist
sailist merged commit 7cd6476 into MoonshotAI:main Aug 6, 2026
13 checks passed
@github-actions github-actions Bot mentioned this pull request Aug 6, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant