Skip to content

fix(vectorstore): fresh index builds raced startup validation; id counter survives a missing metadata record - #491

Merged
Leo310 merged 2 commits into
mainfrom
fix/fresh-index-build-race
Sep 14, 2026
Merged

Leo310 merged 2 commits into
mainfrom
fix/fresh-index-build-race

Conversation

@Leo310

@Leo310 Leo310 commented Sep 13, 2026

Copy link
Copy Markdown
Member

Found during the live test of #490: after cancelling a build on a newly added index and reloading Obsidian, every note in the resumed run failed with Node with id N already exists.

Root cause

The live database had 450 graph nodes, 537 rows, a graph header, and no metadata record. Without it HNSWVectorStore.open left the id counter at 0, so the resumed run reallocated ids into the existing graph. Each failed insert had already overwritten the id mapping of the note that owned that id, so the index is corrupted beyond what a resume can repair.

Which run writes rows without metadata? The startup validation. "Add index" calls ensureIndex, which opens the instance; opening schedules validation on a zero-delay timer, and that timer fires before ensureIndex gets through its getMetadata and count worker round-trips. Validation sees an empty store, embeds everything through embedFilesInBatches, and the full build that ensureIndex then attempts bails with "Indexing already in progress". Only the full build calls setMetadata, and updateLastUpdated has no record to persist the counter into. The index also never gets a lastBuiltAt.

What changed

  • Store: open sets the counter to the max of the metadata high-water mark and the loaded id mappings, so a missing or stale record can never reset it below an id in use.
  • Service: validation on an empty store with vault files runs buildFullIndex instead of a repair. buildFullIndex keeps one build promise per instance; a second caller joins it, so the modal's ensureIndex and the scheduled validation no longer race or notice against each other.
  • Repair: validation writes the metadata record when a store has rows but none, so indexes built through the old path restore dimensions and counter normally on the next open.

Tests

  • HNSWVectorStore.resume.test.ts: rows and graph written with no metadata record, reopen, upsert succeeds and both old and new notes resolve to their own rows.
  • VectorStoreService.lazyOpen.test.ts: with the store's reads parked on a macrotask so validation fires first, a fresh index gets exactly one build, the metadata record exists, and lastBuiltAt is stamped.

Both fail on main and pass here. bun run check, format, lint, test (1677 passing) and a dev build are clean; the bundle loads in a live vault without errors.

Note for anyone who hit this: an index that already collided needs Rebuild index; the overwritten mappings cannot be reconstructed.

@greptile-apps

greptile-apps Bot commented Sep 13, 2026 •

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR prevents interrupted or startup-raced vector index builds from reusing persisted HNSW node IDs and coordinates concurrent requests for the same full build.

  • Restores the ID counter from metadata, ID mappings, and persisted graph nodes.
  • Routes validation of a fresh store through the full-build lifecycle.
  • Makes concurrent full-build callers share one per-instance promise.
  • Repairs missing metadata for nonempty stores, including vector dimensions.
  • Adds regression coverage for missing metadata, interrupted removal, and startup timing.

Confidence Score: 5/5

The PR appears safe to merge; no actionable correctness, security, or repository-rule issue remains.

Both previous findings are fully addressed in the current code: ID restoration includes persisted graph keys, and metadata repair runs for every nonempty metadata-less store. The new build promise also makes the startup validator and ensureIndex await the same full build.

Important Files Changed

Filename Overview
src/vectorstore/HNSWVectorStore.ts Restores the HNSW ID high-water mark from every persisted numeric-ID source and probes existing row dimensions during metadata repair.
src/vectorstore/VectorStoreService.ts Sends empty-store validation through a deduplicated full build and repairs missing metadata independently of embedding work.
test/vectorstore/HNSWVectorStore.resume.test.ts Covers reopening metadata-less stores and retaining graph IDs whose mappings were interrupted during removal.
test/vectorstore/VectorStoreService.lazyOpen.test.ts Covers startup validation winning the scheduling race while still producing exactly one complete build.

Sequence Diagram

sequenceDiagram
    participant E as ensureIndex
    participant V as Startup validation
    participant B as buildFullIndex
    participant S as Vector store

    V->>S: Inspect rows and metadata
    E->>S: Inspect metadata and count
    V->>B: Request full build for empty store
    B->>B: Store buildPromise
    E->>B: Request same full build
    B-->>E: Return existing buildPromise
    B->>S: Embed rows, flush graph, write metadata
    B-->>V: Build completes
    B-->>E: Shared build completes
Loading

Reviews (3): Last reviewed commit: "fix(vectorstore): count persisted graph ..." | Re-trigger Greptile

Comment thread src/vectorstore/HNSWVectorStore.ts Outdated
Comment thread src/vectorstore/VectorStoreService.ts Outdated
Leo310 added a commit that referenced this pull request Sep 13, 2026
…r mark; repair metadata unconditionally

Review follow-ups for #491:
- `remove()` drops mappings before rows in separate transactions, so an
  interrupted removal leaves a row and a persisted graph node with no
  mapping, and `loadGraph` keeps a node whose row exists. The counter now
  also clears the graph store's largest key; each read is one reverse
  key-cursor step. `setMetadata` probes the row width when the store
  already holds rows, so a repair record restores dimensions on the next
  open.
- The metadata repair runs whenever a non-empty store lacks the record,
  not only when something needs embedding.
…nter survives a missing metadata record

A newly added index could be built by the startup validation instead of
the full build: "Add index" runs `ensureIndex`, whose opening of the
instance schedules validation on a zero-delay timer, and that timer wins
against `ensureIndex`'s two worker round-trips. The validation path never
writes the store's metadata record, so nothing persisted the id counter
or dimensions, and `lastBuiltAt` was never stamped. After a cancel and a
reload the store reopened with the counter at 0 and every new chunk hit
"Node with id N already exists" — with the id mapping of the note that
owned N already overwritten. Observed live: 450 graph nodes, no metadata
record, 87 rows written over live ids.

- `HNSWVectorStore.open` takes the counter as the max of the metadata
  high-water mark and the loaded id mappings, so no persisted state can
  reset it below an id in use.
- Validation on an empty store runs the full build (metadata, build
  date, report) instead of a repair; `buildFullIndex` keeps one build
  per instance and a second caller joins it, so `ensureIndex` no longer
  reports "already in progress" against its own twin.
- Validation writes the metadata record when a store has rows but none,
  repairing indexes built through the old path.
…r mark; repair metadata unconditionally

Review follow-ups for #491:
- `remove()` drops mappings before rows in separate transactions, so an
  interrupted removal leaves a row and a persisted graph node with no
  mapping, and `loadGraph` keeps a node whose row exists. The counter now
  also clears the graph store's largest key; each read is one reverse
  key-cursor step. `setMetadata` probes the row width when the store
  already holds rows, so a repair record restores dimensions on the next
  open.
- The metadata repair runs whenever a non-empty store lacks the record,
  not only when something needs embedding.
@Leo310
Leo310 force-pushed the fix/fresh-index-build-race branch from 0a17d2f to 73170f2 Compare September 13, 2026 15:11
@Leo310
Leo310 merged commit b034073 into main Sep 14, 2026
3 checks passed
@Leo310
Leo310 deleted the fix/fresh-index-build-race branch September 14, 2026 05:55
Leo310 added a commit that referenced this pull request Sep 14, 2026
Stability release for the Obsidian 1.13 installer (Electron 43 / Node 24):
the renderer crash on the first chat send (#492) and the transport
connection error (#501), plus the streaming render coalescing (#484), the
checkpoint callbacks strip (#483), and the indexing fixes since 2.1.0
(#486, #487, #490, #491, #493, #495, #497, #498, #499, #500).

Co-Authored-By: Claude <noreply@anthropic.com>
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