Repository navigation
fix(index-persistence): shard persisted search snapshots - #764
Conversation
Signed-off-by: Rokurolize <1701388+Rokurolize@users.noreply.github.com>
|
@Rokurolize is attempting to deploy a commit to the rohitg00's projects Team on Vercel. A member of the Team first needs to authorize it. |
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (1)
🚧 Files skipped from review as they are similar to previous changes (1)
📝 WalkthroughWalkthroughIndexPersistence now shards BM25 and vector snapshots into deterministic per-generation chunks, publishes a manifest of shard descriptors, validates and reconstructs shards on load, deletes legacy monolithic payloads after commit, and includes extensive tests for normal and failure scenarios. ChangesSharded Index Persistence
Estimated code review effort🎯 4 (Complex) | ⏱️ ~75 minutes Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 4
🧹 Nitpick comments (2)
src/state/index-persistence.ts (2)
243-265: ⚡ Quick winLoad shard chunks in parallel.
Each
kv.gethere is independent, but the serialawaitloop makes restore latency and timeout exposure scale with shard count. APromise.allover the validated shard list preserves ordering while following the repo's KV guidance. As per coding guidelines:src/**/*.ts: Use parallel operations where possible (Promise.allfor independent kv writes/reads).🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/state/index-persistence.ts` around lines 243 - 265, The loop over manifest.shards does serial awaits which slows restore; replace it by mapping shards to promises via this.kv.get and use Promise.all to fetch all chunks in parallel (preserving shard order), then iterate the results to perform the same type and length validations, logging via logger.warn and returning null if any shard is missing or length-mismatched; ensure you still populate chunks (in the original order) and update chars only after successful validation; reference manifest.shards, this.kv.get, chunks, chars, label, and logger.warn when making the change.
40-41: ⚡ Quick winUse the repo ID helper for shard generations.
generationis a unique ID, so building it withrandomUUID()bypasses the project-standard helper and its shared format/behavior. Please switch this togenerateId(). As per coding guidelines:src/**/*.ts: UsefingerprintId()for content-addressable dedup andgenerateId()for unique IDs.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/state/index-persistence.ts` around lines 40 - 41, Replace the ad-hoc randomUUID() usage in createIndexGeneration with the project helper generateId(): update the function to combine Date.now().toString(36) with generateId() (dropping the .replace(/-/g,"") step), and add the necessary import for generateId from the helper module while removing reliance on randomUUID so the generation follows the repo-standard format; keep the existing timestamp prefix `${Date.now().toString(36)}-` and append generateId().
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/state/index-persistence.ts`:
- Around line 73-75: The current branch only persists when this.vector.size > 0,
so if the in-memory index shrinks to 0 we must clear the previously persisted
index to avoid resurrecting deleted vectors in loadVectorData(); update the
logic around saveVectorIndex (and any related persistence API) so that when
this.vector exists but this.vector.size === 0 you explicitly remove or overwrite
the persisted index (e.g., call a delete/clear persistence method or call
saveVectorIndex with an empty/cleared manifest) and when size > 0 continue
saving this.vector.serialize(); reference saveVectorIndex, this.vector,
this.vector.serialize(), and loadVectorData when implementing the cleanup.
- Around line 145-192: The saveShardedIndex path writes shards, publishes the
manifest, deletes the legacy manifest, and cleans up old shards without calling
recordAudit(); add recordAudit() calls around each state-changing operation so
the audit trail captures: (1) each successful shard write inside the loop where
kv.set(scope, INDEX_SHARD_KEY, chunk) is called (include
shard.scope/shard.key/chunk length), (2) the manifest publication where
kv.set<IndexShardManifest>(KV.bm25Index, manifestKey, ...) is invoked (include
generation, manifestKey, total chars), (3) the legacy deletion
kv.delete(KV.bm25Index, legacyKey) (include legacyKey and result), and (4) each
deleteShards(...) call used for rollback and for cleaning previous.shards
(include the shard identifiers); use the existing recordAudit(...) helper in
this module/class so all these operations are recorded with the same metadata
format as other state changes.
- Around line 233-246: Validate each shard descriptor in the manifest before
calling kv.get: inside the loop that iterates manifest.shards (the code that
builds chunks and accumulates chars), check that shard.scope and shard.key are
non-empty strings and shard.chars is a finite non-negative integer (or >0 if
expected). If any shard fails validation, log a warning with the label and
return null (fail closed) instead of calling this.kv.get(shard.scope,
shard.key); otherwise proceed to await this.kv.get(...). This ensures corrupted
manifest entries never trigger KV reads.
- Around line 215-224: The manifest read currently swallows all errors via
catch(() => null) when calling this.kv.get for KV.bm25Index with manifestKey,
which can mask transient/backend failures and wrongly fall back to legacyKey;
update the logic to call this.kv.get(manifestKey) and only treat a confirmed
"not found" sentinel/error from the StateKV implementation as a miss (then call
loadManifestData(manifest, label) or continue to legacy), but for any other
error log the error (include context: KV.bm25Index, manifestKey) and return null
instead of falling back; inspect the StateKV.get contract to use its specific
sentinel type or error class/shape rather than a blanket catch, and apply the
same stricter handling for the legacyKey path.
---
Nitpick comments:
In `@src/state/index-persistence.ts`:
- Around line 243-265: The loop over manifest.shards does serial awaits which
slows restore; replace it by mapping shards to promises via this.kv.get and use
Promise.all to fetch all chunks in parallel (preserving shard order), then
iterate the results to perform the same type and length validations, logging via
logger.warn and returning null if any shard is missing or length-mismatched;
ensure you still populate chunks (in the original order) and update chars only
after successful validation; reference manifest.shards, this.kv.get, chunks,
chars, label, and logger.warn when making the change.
- Around line 40-41: Replace the ad-hoc randomUUID() usage in
createIndexGeneration with the project helper generateId(): update the function
to combine Date.now().toString(36) with generateId() (dropping the
.replace(/-/g,"") step), and add the necessary import for generateId from the
helper module while removing reliance on randomUUID so the generation follows
the repo-standard format; keep the existing timestamp prefix
`${Date.now().toString(36)}-` and append generateId().
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: ae7699af-dc04-4f80-838e-baf2444eb0d1
📒 Files selected for processing (2)
src/state/index-persistence.tstest/index-persistence.test.ts
| if (this.vector && this.vector.size > 0) { | ||
| await this.kv.set(KV.bm25Index, "vectors", this.vector.serialize()); | ||
| await this.saveVectorIndex(this.vector.serialize()); | ||
| } |
There was a problem hiding this comment.
Clear persisted vectors when the in-memory index becomes empty.
If a non-empty vector snapshot was saved earlier and later this.vector.size drops to 0, this branch skips any write or cleanup. After a restart, loadVectorData() will still load the previous manifest, so deleted vectors reappear.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/state/index-persistence.ts` around lines 73 - 75, The current branch only
persists when this.vector.size > 0, so if the in-memory index shrinks to 0 we
must clear the previously persisted index to avoid resurrecting deleted vectors
in loadVectorData(); update the logic around saveVectorIndex (and any related
persistence API) so that when this.vector exists but this.vector.size === 0 you
explicitly remove or overwrite the persisted index (e.g., call a delete/clear
persistence method or call saveVectorIndex with an empty/cleared manifest) and
when size > 0 continue saving this.vector.serialize(); reference
saveVectorIndex, this.vector, this.vector.serialize(), and loadVectorData when
implementing the cleanup.
Signed-off-by: Rokurolize <1701388+Rokurolize@users.noreply.github.com>
Signed-off-by: Rokurolize <1701388+Rokurolize@users.noreply.github.com>
There was a problem hiding this comment.
🧹 Nitpick comments (1)
src/state/index-persistence.ts (1)
183-206: 💤 Low valueOptional: Consider parallelizing shard writes with tracked rollback.
The sequential shard writes are correct and simplify rollback (failed shard means previous shards in
shardsarray need deletion). However, for large indexes with many shards, parallel writes could improve performance.Alternative approach using
Promise.allSettled:
- Write all shards in parallel
- Collect fulfilled/rejected results
- On any rejection, delete only fulfilled shards
This is optional since sequential writes are functionally correct and the current approach aligns with the rollback semantics. As per coding guidelines,
src/**/*.ts: Use parallel operations where possible (Promise.allfor independent kv writes/reads).🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/state/index-persistence.ts` around lines 183 - 206, The current loop writes shards sequentially which is safe but slow; change to parallel writes using Promise.allSettled: build an array of shard descriptors (using scopePrefix, generation, INDEX_SHARD_KEY, chunkChars and the computed scope and chunk from serialized.slice) and an array of promises that call this.kv.set(scope, INDEX_SHARD_KEY, chunk) followed by this.auditIndexPersistence("shard_write", [statePath(scope, INDEX_SHARD_KEY)], { scope, key: INDEX_SHARD_KEY, manifestKey, generation, chars: chunk.length }); run Promise.allSettled on those promises, collect the indices/scopes of fulfilled tasks and on any rejected result call this.deleteShards(filledShards, "shard_write_rollback") and rethrow the first error; ensure shards array reflects only successfully written shards so deleteShards targets the right scopes.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Nitpick comments:
In `@src/state/index-persistence.ts`:
- Around line 183-206: The current loop writes shards sequentially which is safe
but slow; change to parallel writes using Promise.allSettled: build an array of
shard descriptors (using scopePrefix, generation, INDEX_SHARD_KEY, chunkChars
and the computed scope and chunk from serialized.slice) and an array of promises
that call this.kv.set(scope, INDEX_SHARD_KEY, chunk) followed by
this.auditIndexPersistence("shard_write", [statePath(scope, INDEX_SHARD_KEY)], {
scope, key: INDEX_SHARD_KEY, manifestKey, generation, chars: chunk.length });
run Promise.allSettled on those promises, collect the indices/scopes of
fulfilled tasks and on any rejected result call this.deleteShards(filledShards,
"shard_write_rollback") and rethrow the first error; ensure shards array
reflects only successfully written shards so deleteShards targets the right
scopes.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 98a7cf1d-8925-407a-983c-33194fad3e78
📒 Files selected for processing (3)
src/state/index-persistence.tssrc/types.tstest/index-persistence.test.ts
🚧 Files skipped from review as they are similar to previous changes (1)
- test/index-persistence.test.ts
Signed-off-by: Rokurolize <1701388+Rokurolize@users.noreply.github.com>
|
Merged. Thanks @Rokurolize — sharded persistence + manifest commit/rollback semantics are exactly what #762 needed, and the regression coverage (oversized-payload guard, manifest-commits-after-error, length-mismatch fail-closed) is the highest-quality test addition the persistence path has seen. |
Bump to 0.9.25 across 9 files + CHANGELOG. Closes #778 #775 #783 (PR #791), #758 #726 (PR #773), #759 (PR #772), #752 (PR #774), #729 (PR #780), #781 (PR #782), #753 (PR #789), #771 (PR #786), #762 (PR #764). Files bumped: - package.json - packages/mcp/package.json - plugin/.claude-plugin/plugin.json - plugin/.codex-plugin/plugin.json - plugin/plugin.json - src/version.ts - src/types.ts (ExportData.version union) - src/functions/export-import.ts (supportedVersions Set) - test/export-import.test.ts (assertion) 125 test files / 1379 tests pass. npm audit (root + website): 0 vulns.
…ersistence fix(index-persistence): shard persisted search snapshots
Bump to 0.9.25 across 9 files + CHANGELOG. Closes rohitg00#778 rohitg00#775 rohitg00#783 (PR rohitg00#791), rohitg00#758 rohitg00#726 (PR rohitg00#773), rohitg00#759 (PR rohitg00#772), rohitg00#752 (PR rohitg00#774), rohitg00#729 (PR rohitg00#780), rohitg00#781 (PR rohitg00#782), rohitg00#753 (PR rohitg00#789), rohitg00#771 (PR rohitg00#786), rohitg00#762 (PR rohitg00#764). Files bumped: - package.json - packages/mcp/package.json - plugin/.claude-plugin/plugin.json - plugin/.codex-plugin/plugin.json - plugin/plugin.json - src/version.ts - src/types.ts (ExportData.version union) - src/functions/export-import.ts (supportedVersions Set) - test/export-import.test.ts (assertion) 125 test files / 1379 tests pass. npm audit (root + website): 0 vulns.
Note: This PR description was drafted with AI assistance. I personally experienced the underlying issue. If any AI-slop-like shortcomings remain, they are due to my prompting, and I welcome suggestions on how to communicate my requirements more clearly to Codex GPT-5.5 xhigh.
Fixes #762.
This PR does not close #309 because that issue is a broader, already-closed SQLite/FTS5 architecture discussion. It implements the narrower chunked-persistence stopgap discussed there for the existing
IndexPersistencepath.It also does not supersede #300. #300 is an open sqlite-vec vector-backend direction, opt-in through
VECTOR_BACKEND=sqlite-vec; as checked immediately before filing on 2026-06-01 at 22:56 UTC, it is still unmerged and reportsmergeable_state=dirty. If #300 lands, it may reduce vector pressure for that opt-in backend, but it does not make BM25 snapshot persistence bounded and does not remove the need for safe legacy/default snapshot publication semantics inIndexPersistence.Summary
Large BM25/vector search snapshots are currently saved as monolithic strings in the
mem:index:bm25state scope. On large corpora, that turns rebuild persistence into one very large state value for BM25 and one for vectors. The evidence is intentionally separated: upstream source shows both snapshots are monolithic; #309 contains a broad large-corpus persistence-timeout report after #204 made timeout failures nonfatal, plus a concrete vector report where the serializedvectorsvalue reached about 27 MB and timed out. This PR saves each snapshot as bounded shards and publishes a compact manifest only after all shards for that snapshot are written.What was happening
The current upstream save path is:
SearchIndex.serialize()returns one JSON string containing all BM25 entries, inverted terms, document term counts, and total document length.VectorIndex.serialize()returns one JSON string containing all vector entries and base64-encoded embedding data.IndexPersistence.save()writes those two strings withkv.set(KV.bm25Index, "data", ...)andkv.set(KV.bm25Index, "vectors", ...).KV.bm25Indexis themem:index:bm25state scope.Current upstream source at
fd9e3bd:src/state/index-persistence.tslines 31-40,src/state/search-index.tslines 194-210,src/state/vector-index.tslines 124-136, andsrc/state/schema.tslines 3-13.As the corpus grows, those two strings can become large enough that persistence fails even though the in-memory rebuild completed. Since
save()catches and logs persistence failures, the process keeps running, but the rebuilt snapshot is not durably published. A later start can therefore load no rebuilt snapshot or the last older snapshot that was saved successfully.That is the narrower problem this PR addresses. It is related to #204 / #209, but those changes only prevented
state::settimeout rejections from crashing the process; they did not reduce persisted value size or make search snapshot publication robust for larger serialized indexes.The strongest detailed public size evidence is vector-specific. BM25 is included because current source gives it the same single-value persistence boundary, its JSON grows with indexed entries and postings, and the guarded-KV regression test proves the storage-granularity failure for both generated snapshots. If maintainers prefer a narrower first step, the same approach can be split into vector-memory-backend sharding first, with BM25 snapshot sharding as a follow-up.
Fix
BM25 and vector snapshots are still saved independently, matching the previous save order. If vector saving fails after BM25 is published, the new BM25 snapshot remains active and the previous vector snapshot remains active.
Reproduction and tests
The regression test
avoids one oversized state::set string payload for persisted indexeswraps theStateKVused byIndexPersistenceso any string value over a small limit is rejected. It then saves generated BM25/vector indexes whose serialized forms are larger than that limit.That test reproduces the storage-granularity bug on current upstream behavior because
save()tries to write one oversizeddatastring and one oversizedvectorsstring. With this PR, the same logical indexes are written as bounded shards and load back successfully.Additional coverage:
saves BM25 index shards outside the BM25 metadata scopesaves vector index shards outside the BM25 scopeloads legacy monolithic BM25 and vector snapshotsloads sharded manifests that omit optional generation metadatakeeps the previous generation when a shard write fails before manifest commitkeeps the previous generation when manifest set rejects before commitkeeps a generation loadable when manifest set commits before rejectingdeletes a shard that committed before set rejectedloads the new generation even when old generation cleanup failskeeps the previous vector generation when vector save fails after BM25 publishfails closed when a manifest shard is missingfails closed when a manifest shard length mismatchesVerification:
npm test -- test/index-persistence.test.tsnpm run buildHOME=/tmp/agentmemory-pr-verify-home-20260601 npm testonorigin/mainplus this two-file patchReview notes
Please review the manifest publication order and failure handling carefully. The intended invariant is that failed writes do not corrupt or delete the last successfully published snapshot for that index, while invalid published manifests fail closed instead of silently loading partial or stale data.
Summary by CodeRabbit
Improvements
Tests