Skip to content

feat(hnsw): versioned index nodes for VT-cached traversal + format migration - #1440

Merged
kriszyp merged 5 commits into
record-cachingfrom
kris/hnsw-versioned-index-nodes
Jul 2, 2026
Merged

feat(hnsw): versioned index nodes for VT-cached traversal + format migration#1440
kriszyp merged 5 commits into
record-cachingfrom
kris/hnsw-versioned-index-nodes

Conversation

@kriszyp

@kriszyp kriszyp commented Jun 22, 2026

Copy link
Copy Markdown
Member

Summary

Versions HNSW custom-index graph nodes so the record-caching Verification Table (VT) can track them, enabling cached, decode-free graph traversal — and adds a durable migration path for the storage format.

  • Versioned nodes (commits 1–2): openIndex initialises an HNSW object store as a versioned RocksDB store and the RecordEncoder gains an autoVersion branch that prepends the metadata header [8-byte BE float64 version][4-byte ACTION_32_BIT flags word] to each node. The flags word is mandatory — without it, decode mis-reads the struct's first byte as metadata flags. Measured (2000×768): search ~2.6×, build ~2.4×.
  • Format migration (commits 3–4): the storage format ('versioned'|'legacy') is decided once at index create and persisted on the attribute descriptor (indexFormat sibling field). resolveIndexFormat reads it back, so every worker and every reload agree — replacing the previous per-open empty-guard that re-derived the format on each open. The empty-guard is now only the first-time initializer. legacy → versioned happens only via an explicit reindex (rebuild-from-scratch). Kill-switch: HNSW_NO_AUTOVERSION.

Purpose

The VT cache is a large win for HNSW (deserialization dominates traversal), but it only caches entries with a non-null version — and HNSW nodes were un-versioned, so the cache was inert for them. Versioning the nodes makes the cache effective. The per-open empty-guard that the first cut used to decide the format was racy across workers (a store non-empty mid-backfill was mis-read as legacy → versioned data opened with the legacy decoder); persisting the decision closes that.

Where to focus

  • resolveIndexFormat + the persist gate in table() (resources/databases.ts). The correctness invariant is: a versioned store is never opened with the legacy decoder (or vice-versa). The format is persisted before the first node is written (attributesDbi.put precedes runIndexing), so by the time a store is non-empty its format is already on disk.
  • The autoVersion encode branch in RecordEncoder.ts — the metadata-prefix composition with struct mode (the flags word being mandatory is the subtle part).
  • Reindex-upgrade blocklegacy → versioned flips the persisted format and re-arms the encoder only on a full rebuild (lastIndexedKey === undefined), guarded to RocksDB; crash-recovery resumes keep their format.

Cross-model review

Reviewed with Codex + Gemini + a Harper-domain pass. Two blockers were found and fixed in commit 4 (persistence gap for a pre-feature descriptor lacking indexFormat; missing RocksDB guard on the reindex-upgrade block) — see commit history.

Open items for reviewer attention (deliberate calls, not yet code):

  1. Migration note for this branch only: any HNSW index built on the intermediate commits (f905284, 20ec565) was armed versioned without persisting indexFormat, so on this final code it resolves as legacy (unmarked non-empty → legacy) and reads its versioned nodes with the legacy decoder. This is a dev-branch artifact — the shipped feature always persists indexFormat from first create — but reindex any HNSW index built on those intermediate commits. Not a production concern (the feature ships atomically).
  2. Kill-switch is one-way: HNSW_NO_AUTOVERSION blocks new indexes from initializing versioned but never downgrades a persisted-versioned store (downgrading would open versioned nodes with the legacy decoder until a rebuild). A true "force legacy" would need its own explicit path.
  3. "Explicit reindex" = the existing rebuild-from-scratch path (index-option change / forced rebuild). There is no dedicated "upgrade index format" operation; happy to add one as a follow-up if wanted.

indexFormat is node-local (computed per-node from each node's own store, like indexingPID/lastIndexedKey) — it is not replicated, so a peer can't receive a foreign format that mismatches its physical store.

Tests

New unitTests/resources/vectorIndexFormat.test.js (5 cases: fresh→versioned+persist+arm; reload reads persisted not re-probed; kill-switch→legacy; legacy→versioned via reindex; persistence-gap regression). Existing HNSW / caching-rocks / index-canonical-options / index-restart-number suites pass.


🤖 Generated by an LLM (Claude Opus 4.8). Base is record-caching (#410) — this stacks on the VT caching work.

Kris Zyp and others added 4 commits June 21, 2026 09:43
…versal

HNSW graph nodes were written via plain put() with no version, so the
PrimaryRocksDatabase Verification-Table cache could never track them — every
node visited during a search or insert was re-read and re-deserialized from
RocksDB. At 768 dims a node decode (~8.6µs) dwarfs the cosine distance (~0.8µs),
so deserialization dominated query and build time.

For a FRESH custom-index object store (e.g. HNSW), initialise the index encoder
as a versioned RocksDB store and add a self-versioning `autoVersion` encode mode:
each value is prefixed with the record-encoder metadata header
[8-byte BE float64 monotonic version][4-byte ACTION_32_BIT flags word]. The
version is what the native VT extracts (first 8 bytes) to verify cache freshness;
the empty flags word is required so decode consumes a metadata word instead of
mis-reading the struct's first byte as flags. autoVersion never touches the shared
timestampNextEncoding/metadataInNextEncoding globals (those belong to the
enclosing primary write whose commit nests the index encode — harper#1307).

Cache coherence rides the proven, race-safe native VT path: cold reads
CAS-populate the slot from the value's version, warm reads verify via
verifyVersion→FRESH (no decode), and transaction commit locks→settles the slot,
invalidating stale cross-thread caches.

Migration-safe: only enabled for an empty index. A pre-existing index holds
un-prefixed values (incl. small-int id mappings a versioned decode would misread),
so it stays in legacy mode (correct, uncached) until rebuilt. Set
HNSW_NO_AUTOVERSION to disable.

Measured (2000×768, 300 queries): search 26.1→9.9 ms/q (2.6x), p99 82→17 ms,
build 23.3→9.7 s (2.4x). All 32 HNSW unit tests pass (recall, #1161 cold reads,
#386 concurrent multi-worker).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…epend copy

The autoVersion branch prepended the [version][flags] header into a freshly
allocated buffer on every index-node encode — an extra allocation plus a
full-payload memcpy per write, paid repeatedly during graph build as nodes
are rewritten on neighbor reconnection.

Switch to the same reserve-start mechanism the primary metadata path uses
(the 2048|valueStart encode option reserves the header bytes at the front of
the encode buffer; the header is written in place). The earlier belief that
reserve-start was incompatible with the forced randomAccessStructure/typed-
struct mode of custom-object indexes was a misdiagnosis: the real bug was the
missing mandatory flags word. With the flags word present, reserve-start round-
trips correctly — verified against the cold/frozen-read (#1161) and concurrent
multi-worker (#386) suites (32/32 green).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace the per-open empty-guard that decided versioned-vs-legacy on every
worker with a format (versioned|legacy) decided once at index create and
persisted on the attribute descriptor (indexFormat). openIndex now resolves
the format from the persisted descriptor via resolveIndexFormat, so every
worker and reload agree on it; the empty-guard is only the initializer for the
first open under this feature. table() persists the resolved format before the
first node is written, closing the multi-worker race where a store that is
non-empty mid-backfill was mis-read as legacy and opened with the wrong decoder.

A legacy index upgrades to versioned only via an explicit reindex (a full
rebuild from scratch), which flips the persisted format and re-arms the dbi
encoder. The HNSW_NO_AUTOVERSION kill-switch now only blocks a NEW index from
initializing versioned; an already-versioned store is still resolved versioned
so its reads stay correct.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…pgrade to RocksDB

Cross-model review (Gemini + Codex) caught two blockers in the indexFormat
migration:

1. The format resolved by the empty-guard initializer was only persisted when
   the schema otherwise changed. An index created before the indexFormat field
   existed (descriptor lacks it) over an empty store resolved 'versioned' but
   was never written to disk; once the store became non-empty a later load
   re-derived 'legacy' and opened the versioned nodes with the legacy decoder
   (silent corruption). Now persist the resolved format whenever it differs from
   the descriptor on disk (indexFormatNeedsPersist), before any node is written.

2. The legacy->versioned reindex-upgrade block was not guarded by
   'rootStore instanceof RocksDatabase', unlike openIndex's arming. The versioned
   format is RocksDB-only (the Verification Table is RocksDB-specific), so on an
   LMDB-backed HNSW index it would stamp a misleading 'versioned' format. Added
   the guard for symmetry with openIndex.

Adds a regression test for the persistence gap.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@kriszyp
kriszyp requested review from cb1kenobi, dawsontoth and kylebernhardy and removed request for kylebernhardy June 22, 2026 13:22

@gemini-code-assist gemini-code-assist 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.

Code Review

This pull request introduces a self-versioning mode for custom-index object stores (such as HNSW) in RocksDB, enabling race-safe cache verification during graph traversal. It adds format resolution logic to persist whether an index is 'versioned' or 'legacy' to prevent decoder mismatches on reload, and supports upgrading legacy indexes to versioned during a full reindex. Comprehensive unit tests are also added to verify these migration paths. The feedback recommends hardening the environment variable check for HNSW_NO_AUTOVERSION to prevent truthy strings like 'false' or '0' from being misinterpreted as disabling the feature.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread resources/databases.ts Outdated
Comment thread resources/databases.ts Outdated
@claude

This comment has been minimized.

PR #1440 review:
- Gemini: the kill-switch used a bare process.env truthiness check, so
  HNSW_NO_AUTOVERSION="false"/"0" (truthy strings) would enable it — the
  opposite of intent. Centralize in hnswAutoVersionDisabled() which treats
  ""/"0"/"false"/unset as not-set, used at both call sites. Adds a test.
- Claude: use plain node:assert (not node:assert/strict) per AGENTS.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@kriszyp
kriszyp marked this pull request as ready for review June 22, 2026 14:13
@kriszyp kriszyp added this to the v5.2 milestone Jun 22, 2026
@kriszyp
kriszyp merged commit e0a65a9 into record-caching Jul 2, 2026
51 checks passed
@kriszyp
kriszyp deleted the kris/hnsw-versioned-index-nodes branch July 2, 2026 15:41
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.

3 participants