Skip to content

fix(index-persistence): shard persisted search snapshots - #763

Closed
Rokurolize wants to merge 1 commit into
rohitg00:mainfrom
Rokurolize:roku-shard-index-persistence-762
Closed

Rokurolize wants to merge 1 commit into
rohitg00:mainfrom
Rokurolize:roku-shard-index-persistence-762

Conversation

@Rokurolize

@Rokurolize Rokurolize commented Jun 1, 2026 •

Copy link
Copy Markdown
Contributor

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 IndexPersistence path.

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 reports mergeable_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 in IndexPersistence.

Summary

Large BM25/vector search snapshots are currently saved as monolithic strings in the mem:index:bm25 state 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 serialized vectors value 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 with kv.set(KV.bm25Index, "data", ...) and kv.set(KV.bm25Index, "vectors", ...).
  • KV.bm25Index is the mem:index:bm25 state scope.

Current upstream source at fd9e3bd: src/state/index-persistence.ts lines 31-40, src/state/search-index.ts lines 194-210, src/state/vector-index.ts lines 124-136, and src/state/schema.ts lines 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::set timeout 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

  • Split BM25 and vector snapshots into bounded string shards.
  • Write shards under generation-specific shard scopes before changing the active manifest.
  • Publish the new snapshot with a compact manifest that records exact shard scopes and character counts.
  • Load legacy monolithic snapshots when no manifest exists.
  • Load any valid manifest that records exact shard scopes, even if optional metadata is absent.
  • Delete partial new shards if a save fails before the manifest is published.
  • Leave new shards in place if the manifest write reports failure after it was attempted, because state persistence may have committed the manifest before returning an error.
  • Delete legacy keys and old shards only after the new manifest is published; cleanup failure is nonfatal.
  • Return null and log a warning if a published shard is missing or has a length mismatch.

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 indexes wraps the StateKV used by IndexPersistence so 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 oversized data string and one oversized vectors string. 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 scope
  • saves vector index shards outside the BM25 scope
  • loads legacy monolithic BM25 and vector snapshots
  • loads sharded manifests that omit optional generation metadata
  • keeps the previous generation when a shard write fails before manifest commit
  • keeps the previous generation when manifest set rejects before commit
  • keeps a generation loadable when manifest set commits before rejecting
  • deletes a shard that committed before set rejected
  • loads the new generation even when old generation cleanup fails
  • keeps the previous vector generation when vector save fails after BM25 publish
  • fails closed when a manifest shard is missing
  • fails closed when a manifest shard length mismatches

Verification:

  • npm test -- test/index-persistence.test.ts
  • npm run build
  • HOME=/tmp/agentmemory-pr-verify-home-20260601 npm test on origin/main plus this two-file patch

Review 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

Release Notes

  • New Features

    • Added configurable options for index storage and persistence behavior customization.
  • Bug Fixes

    • Enhanced index storage reliability through comprehensive data integrity validation.
    • Implemented automatic recovery mechanisms to gracefully handle corrupted or missing index data during load operations.

@vercel

vercel Bot commented Jun 1, 2026

Copy link
Copy Markdown

@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.

@coderabbitai

coderabbitai Bot commented Jun 1, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

IndexPersistence now persists BM25 and vector indexes as bounded shards with manifest metadata instead of single monolithic values. Save writes per-generation shard chunks, publishes a manifest, and cleans up legacy/stale data. Load reads manifests with integrity validation and falls back to legacy single-key data when manifests are missing or invalid. The constructor accepts configurable shard chunk size and generation factory options.

Changes

Index Sharding Implementation

Layer / File(s) Summary
Sharding types and infrastructure
src/state/index-persistence.ts
Adds randomUUID import, sharding constants (chunk size, manifest/shard key patterns), manifest and shard-scope type definitions, configurable options object with shard chunk size and generation factory, and generation string builder (timestamp + UUID).
Constructor and API entry points
src/state/index-persistence.ts
Updates IndexPersistence constructor to accept IndexPersistenceOptions and refactors save/load public methods to call new sharded persistence helpers (saveBm25Index, saveVectorIndex, loadBm25Data, loadVectorData) instead of direct KV get/set.
Sharded save implementation
src/state/index-persistence.ts
Implements sharded save helpers: write index shards under per-generation scope keys, persist a shard manifest (version, generation, shard list, total chars), delete the legacy single-key entry, and remove stale shards from the previous generation when unreferenced.
Sharded load implementation with validation
src/state/index-persistence.ts
Implements sharded load helpers: read manifest with version/structure/shard integrity checks (missing shards, per-shard length mismatch, total length mismatch), reconstruct the full serialized string by concatenating shard chunks, fall back to legacy single-key data when manifest is missing/invalid, and return null on validation failure.
Test infrastructure and basic sharding tests
test/index-persistence.test.ts
Adds scope/legacy-key constants, typed manifest shape, helper functions to construct indexes and read manifests, and tests covering BM25/vector shard placement outside legacy keys, legacy snapshot loading, and loading when manifest omits optional generation metadata.
Oversized payload and commit/rollback failure tests
test/index-persistence.test.ts
Tests enforcing maximum persisted payload size and validates generation/manifest commit correctness across multiple failure modes: shard write failures, manifest write rejection before commit, manifest commit success followed by set rejection/timeout, post-commit set rejection with shard deletion, cleanup/delete failures, and mixed BM25/vector failure ordering, ensuring previously committed generations remain loadable.
Fail-closed validation tests
test/index-persistence.test.ts
Tests confirming that loading returns null when required shard data is missing from KV or when a shard's persisted length mismatches the manifest, ensuring the index fails closed on manifest/shard corruption.
Timer and debounce test updates
test/index-persistence.test.ts
Adjusts existing timer-debounce and stop-related tests to assert against BM25 manifest keys under the new scope naming convention, validating that delayed save and timer cancellation work correctly with sharded persistence.

🎯 3 (Moderate) | ⏱️ ~25 minutes


🐰 A rabbit hops through code with glee,
Shards of indexes dance wild and free,
No more monoliths blocking the way,
Chunks and manifests save the day!
Large corpora now persist without fray. 🌟

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'fix(index-persistence): shard persisted search snapshots' directly describes the main change: implementing sharded persistence for search snapshots instead of monolithic blobs.
Linked Issues check ✅ Passed All coding requirements from #762 are met: snapshots split into bounded shards, manifests record shard metadata, legacy loading preserved, proper failure/cleanup handling, and regression tests added.
Out of Scope Changes check ✅ Passed All changes relate directly to implementing sharded persistence for search snapshots as specified in #762; no unrelated modifications detected.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (2)
src/state/index-persistence.ts (2)

1-1: ⚡ Quick win

Use the repo-standard ID/timestamp helpers for generation strings.

This new path rolls its own generation format with randomUUID() and Date.now(). Please switch it to the shared helpers so persistence generations follow the same ID and timestamp conventions as the rest of src/**/*.ts.

As per coding guidelines, src/**/*.ts: Use generateId() for unique IDs, and capture timestamps once with new Date().toISOString() and reuse.

Also applies to: 40-42

🤖 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` at line 1, The code currently imports and
uses randomUUID() and Date.now() to build persistence generation strings;
replace randomUUID() with the repo-standard generateId() helper and capture the
timestamp once using const ts = new Date().toISOString() and reuse ts wherever
Date.now() was used so IDs and timestamps follow project conventions; update the
import to remove randomUUID and import generateId from the shared helper module,
and change any occurrences that build generation strings to use generateId() and
ts (e.g., wherever generation or generationId is composed).

243-265: ⚡ Quick win

Fetch manifest shards in parallel.

These kv.get() calls are independent, but the current loop serializes them one-by-one. On larger manifests that directly inflates load latency even though Promise.all() can preserve shard order for the final join.

Suggested refactor
-    const chunks: string[] = [];
-    let chars = 0;
-    for (const shard of manifest.shards) {
-      const chunk = await this.kv
-        .get<string>(shard.scope, shard.key)
-        .catch(() => null);
+    const loadedShards = await Promise.all(
+      manifest.shards.map(async (shard) => ({
+        shard,
+        chunk: await this.kv.get<string>(shard.scope, shard.key).catch(() => null),
+      })),
+    );
+    const chunks: string[] = [];
+    let chars = 0;
+    for (const { shard, chunk } of loadedShards) {
       if (typeof chunk !== "string") {
         logger.warn(`index persistence: ${label} shard missing`, {
           scope: shard.scope,
           key: shard.key,
         });

As per coding guidelines, src/**/*.ts: Use parallel operations where possible (Promise.all for 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 kv reads (this.kv.get) causing unnecessary latency;
change to fetch shards in parallel using Promise.all over
manifest.shards.map(...) to call this.kv.get for each shard, preserve the
original shard order when awaiting results, then validate each returned chunk
(type string and length === shard.chars) and log the same warnings via
logger.warn for missing/mismatched shards, pushing valid chunks into chunks and
summing chars as before; reference manifest.shards, this.kv.get, chunks, chars,
and logger.warn when locating and updating the code.
🤖 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 31-37: The shardChars function currently accepts positive
fractions (e.g. 0.5) which get floored to 0 and causes save() to never advance
offset; update the validation in shardChars to reject any configured value less
than 1 before flooring (e.g. require Number.isFinite(configured) && configured
>= 1) and then return Math.floor(configured), otherwise fall back to
DEFAULT_INDEX_SHARD_CHARS; reference the shardChars function and the
save()/offset behavior so reviewers can verify the fix.
- Around line 175-180: Wrap the manifest write
(this.kv.set<IndexShardManifest>(KV.bm25Index, manifestKey, {...})) in a
try/catch; on catch, re-read the manifest via this.kv.get(KV.bm25Index,
manifestKey) and check whether the new generation was actually published
(compare returned manifest.generation or v/generation to the local generation).
If the manifest does not reflect the new generation, delete the freshly written
shards (iterate the shards array and remove each shard key via this.kv.delete or
the existing shard-delete helper) to roll back, then rethrow the original error;
if the manifest does reflect the new generation, leave shards intact and rethrow
or return as appropriate. Ensure you reference generation, shards, manifestKey
and IndexShardManifest in the fix.

---

Nitpick comments:
In `@src/state/index-persistence.ts`:
- Line 1: The code currently imports and uses randomUUID() and Date.now() to
build persistence generation strings; replace randomUUID() with the
repo-standard generateId() helper and capture the timestamp once using const ts
= new Date().toISOString() and reuse ts wherever Date.now() was used so IDs and
timestamps follow project conventions; update the import to remove randomUUID
and import generateId from the shared helper module, and change any occurrences
that build generation strings to use generateId() and ts (e.g., wherever
generation or generationId is composed).
- Around line 243-265: The loop over manifest.shards does serial kv reads
(this.kv.get) causing unnecessary latency; change to fetch shards in parallel
using Promise.all over manifest.shards.map(...) to call this.kv.get for each
shard, preserve the original shard order when awaiting results, then validate
each returned chunk (type string and length === shard.chars) and log the same
warnings via logger.warn for missing/mismatched shards, pushing valid chunks
into chunks and summing chars as before; reference manifest.shards, this.kv.get,
chunks, chars, and logger.warn when locating and updating the code.
🪄 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: 4bd46c34-2d1a-47ab-ba6d-26f1d1ab11b1

📥 Commits

Reviewing files that changed from the base of the PR and between fd9e3bd and 5fda803.

📒 Files selected for processing (2)
  • src/state/index-persistence.ts
  • test/index-persistence.test.ts

Comment on lines +31 to +37
function shardChars(options: IndexPersistenceOptions): number {
const configured = options.shardChars;
return typeof configured === "number" &&
Number.isFinite(configured) &&
configured > 0
? Math.floor(configured)
: DEFAULT_INDEX_SHARD_CHARS;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Reject fractional shard sizes below 1 before flooring.

A value like shardChars: 0.5 passes the current guard, gets floored to 0, and then Line 159 never advances offset. That turns save() into an infinite loop on a reachable misconfiguration path.

Suggested fix
 function shardChars(options: IndexPersistenceOptions): number {
   const configured = options.shardChars;
-  return typeof configured === "number" &&
-    Number.isFinite(configured) &&
-    configured > 0
-    ? Math.floor(configured)
-    : DEFAULT_INDEX_SHARD_CHARS;
+  if (typeof configured !== "number" || !Number.isFinite(configured)) {
+    return DEFAULT_INDEX_SHARD_CHARS;
+  }
+  const wholeChars = Math.floor(configured);
+  return wholeChars >= 1 ? wholeChars : DEFAULT_INDEX_SHARD_CHARS;
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
function shardChars(options: IndexPersistenceOptions): number {
const configured = options.shardChars;
return typeof configured === "number" &&
Number.isFinite(configured) &&
configured > 0
? Math.floor(configured)
: DEFAULT_INDEX_SHARD_CHARS;
function shardChars(options: IndexPersistenceOptions): number {
const configured = options.shardChars;
if (typeof configured !== "number" || !Number.isFinite(configured)) {
return DEFAULT_INDEX_SHARD_CHARS;
}
const wholeChars = Math.floor(configured);
return wholeChars >= 1 ? wholeChars : DEFAULT_INDEX_SHARD_CHARS;
}
🤖 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 31 - 37, The shardChars function
currently accepts positive fractions (e.g. 0.5) which get floored to 0 and
causes save() to never advance offset; update the validation in shardChars to
reject any configured value less than 1 before flooring (e.g. require
Number.isFinite(configured) && configured >= 1) and then return
Math.floor(configured), otherwise fall back to DEFAULT_INDEX_SHARD_CHARS;
reference the shardChars function and the save()/offset behavior so reviewers
can verify the fix.

Comment on lines +175 to +180
await this.kv.set<IndexShardManifest>(KV.bm25Index, manifestKey, {
v: 1,
generation,
shards,
chars: serialized.length,
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Roll back newly written shards when manifest publication fails before commit.

This path currently leaves the freshly written generation in KV whenever the manifest set() rejects before publishing. That breaks the PR's rollback invariant: only shard-write failures are cleaned up, but manifest-write failures leak unreachable shards.

A small try/catch here can preserve the intended behavior: after a manifest write error, re-read manifestKey; if the new generation is not published, delete shards and rethrow, otherwise leave them in place because the manifest may already have committed.

Suggested fix
-    await this.kv.set<IndexShardManifest>(KV.bm25Index, manifestKey, {
+    const nextManifest: IndexShardManifest = {
       v: 1,
       generation,
       shards,
       chars: serialized.length,
-    });
+    };
+    try {
+      await this.kv.set<IndexShardManifest>(
+        KV.bm25Index,
+        manifestKey,
+        nextManifest,
+      );
+    } catch (err) {
+      const published = await this.kv
+        .get<IndexShardManifest>(KV.bm25Index, manifestKey)
+        .catch(() => null);
+      const committed =
+        published?.v === 1 &&
+        published.generation === generation &&
+        published.chars === nextManifest.chars &&
+        published.shards.length === nextManifest.shards.length &&
+        published.shards.every((shard, index) => {
+          const expected = nextManifest.shards[index];
+          return (
+            shard.scope === expected?.scope &&
+            shard.key === expected.key &&
+            shard.chars === expected.chars
+          );
+        });
+      if (!committed) {
+        await this.deleteShards(shards);
+      }
+      throw err;
+    }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
await this.kv.set<IndexShardManifest>(KV.bm25Index, manifestKey, {
v: 1,
generation,
shards,
chars: serialized.length,
});
const nextManifest: IndexShardManifest = {
v: 1,
generation,
shards,
chars: serialized.length,
};
try {
await this.kv.set<IndexShardManifest>(
KV.bm25Index,
manifestKey,
nextManifest,
);
} catch (err) {
const published = await this.kv
.get<IndexShardManifest>(KV.bm25Index, manifestKey)
.catch(() => null);
const committed =
published?.v === 1 &&
published.generation === generation &&
published.chars === nextManifest.chars &&
published.shards.length === nextManifest.shards.length &&
published.shards.every((shard, index) => {
const expected = nextManifest.shards[index];
return (
shard.scope === expected?.scope &&
shard.key === expected.key &&
shard.chars === expected.chars
);
});
if (!committed) {
await this.deleteShards(shards);
}
throw err;
}
🤖 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 175 - 180, Wrap the manifest
write (this.kv.set<IndexShardManifest>(KV.bm25Index, manifestKey, {...})) in a
try/catch; on catch, re-read the manifest via this.kv.get(KV.bm25Index,
manifestKey) and check whether the new generation was actually published
(compare returned manifest.generation or v/generation to the local generation).
If the manifest does not reflect the new generation, delete the freshly written
shards (iterate the shards array and remove each shard key via this.kv.delete or
the existing shard-delete helper) to roll back, then rethrow the original error;
if the manifest does reflect the new generation, leave shards intact and rethrow
or return as appropriate. Ensure you reference generation, shards, manifestKey
and IndexShardManifest in the fix.

@Rokurolize

Copy link
Copy Markdown
Contributor Author

Note: This comment 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.

Closing this PR in favor of #764.

After opening this PR, I re-read CONTRIBUTING.md and found two compliance issues in this submission:

  • the branch name did not follow the requested fix/<issue-number>-<short-name> pattern;
  • the commit did not include the required DCO Signed-off-by trailer.

#764 replaces this PR with the same focused two-file change from a branch named fix/762-shard-index-persistence, and the commit includes the required sign-off.

@Rokurolize Rokurolize closed this Jun 1, 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

1 participant