Skip to content

Implement the shared derived-index transaction-log runtime - #2533

Closed
kylebernhardy wants to merge 5 commits into
mainfrom
codex/derived-index-runtime
Closed

kylebernhardy wants to merge 5 commits into
mainfrom
codex/derived-index-runtime

Conversation

@kylebernhardy

Copy link
Copy Markdown
Member

Outcome

Adds Harper’s shared post-commit delivery and exact-replay foundation for non-transactional derived indexes. Each backend has an independent lock-elected runner, cursor vector, bounded backlog, and failure state; record commits never await derived-index work.

What changed

  • exposes physical log identity and exact-resume failure metadata from transaction-log ranges
  • adds the DerivedIndexRuntime delivery, cursor, ownership, and backpressure contract
  • resolves projections from authoritative committed records rather than staged audit bodies
  • emits atomic, local-only cache-eviction markers only for registered derived-index tables
  • keeps internal eviction markers out of customer history, subscriptions, replication, and boot replay
  • documents the per-worker registration invariant and the remaining generation/schema work

Closes #2489

Validation

  • npm run build
  • npm run lint:required
  • 75 focused derived-index/eviction tests passed
  • 38 adjacent subscription/reload/audit tests passed; 9 pending
  • full resource gate reaches the existing native abort in Audit cleanup retirement, reproduced on unmodified main
  • local Claude and Gemini review completed; no accepted findings remain

Comment generated by kAIle (GPT-5)

@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 implements Stage 1 of the derived-index runtime, establishing committed log delivery and exact replay foundation. It introduces derivedIndexRuntime.ts and derivedIndexRegistry.ts to coordinate lock-elected, cursor-based delivery of committed RocksDB log mutations to derived-index backends. Additionally, it updates RocksTransactionLogStore.ts to support exact-start resume, tracks failed logs, and includes log names on returned audit records, while Table.ts is updated to stage local-only eviction markers. A critical bug was identified in RocksTransactionLogStore.ts where expectedExactStarts.splice(i--, 1) uses a post-decrement operator i-- inside a loop that already decrements i, which will cause an index to be skipped during log removal.

logs.splice(i, 1);
iterators.splice(i--, 1);
iterators.splice(i, 1);
expectedExactStarts.splice(i--, 1);

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.

critical

There appears to be a bug in this loop. The post-decrement operator i-- in splice(i--, 1) will cause the loop to skip an element after a removal. Since you are iterating backwards, the loop's own i-- is sufficient to correctly move to the next element. The extra decrement will cause an index to be skipped.

You've correctly fixed this for the iterators array on the previous line, and the same fix should be applied here.

Suggested change
expectedExactStarts.splice(i--, 1);
expectedExactStarts.splice(i, 1);

| { state: 'idle' | 'running' | 'deferred' | 'waiting-durable' | 'stopped'; ownerEpoch?: bigint }
| { state: 'needs-rebuild'; reason: string; ownerEpoch?: bigint };

const ELIGIBLE_ACTIONS = new Set(['put', 'patch', 'delete', 'invalidate', 'relocate', 'evict']);

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.

Blocker: two of six ELIGIBLE_ACTIONS are untested

ELIGIBLE_ACTIONS gates which audit entries turn into derived-index mutations. It includes 'invalidate' and 'relocate' alongside 'put' / 'patch' / 'delete' / 'evict', but none of the new or modified test files exercise either of those two:

  • unitTests/resources/derivedIndexRuntime.test.js only ever builds entries via a put-defaulted audit() helper.
  • unitTests/resources/derivedIndexRuntimeRocks.test.js exercises put/patch/delete/evict end-to-end, but never invalidate/relocate.
  • unitTests/resources/transactionLogRangeMetadata.test.js doesn't touch this set at all.

Both strings are live production audit-action types — Table.ts writes 'invalidate' on cache/data-loader invalidation and 'relocate' on record migration (matching EVENT_TYPES bytes INVALIDATE=4 / RELOCATE=6 in auditStore.ts). Since this is a plain string-literal allowlist iterated by value, a typo or an accidental drop of either entry would silently stop a derived index from ever seeing invalidate/relocate writes, with nothing in the suite catching the regression.

Suggested fix: add a case (e.g. in derivedIndexRuntime.test.js, using the existing fake-log harness) that emits an 'invalidate' and a 'relocate' audit entry and asserts the runtime still delivers a mutation for each, the same way put/delete/evict are already covered.

logs.splice(i, 1);
iterators.splice(i--, 1);
iterators.splice(i, 1);
expectedExactStarts.splice(i--, 1);

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.

Correction of the review comment above (gemini-code-assist): this is not a bug, and the suggested patch would introduce one.

The enclosing loop (line 423) iterates forwardfor (let i = 0; i < logs.length; i++) — not backwards. When a forward loop removes the current element via splice(i, 1), the next surviving element shifts down into index i, so the loop's own i++ would skip over it unless compensated. expectedExactStarts.splice(i--, 1) splices at the original i (post-decrement returns the pre-decrement value) and then decrements, cancelling the next i++. That's the standard fix for "remove while iterating forward," applied correctly here.

This is confirmable from the diff itself: before this PR the same single decrement lived on iterators.splice(i--, 1), which was then the last splice in the group. This PR added the new expectedExactStarts array and correctly relocated the decrement onto the new last splice call — exactly one decrement per removal is needed, and it still has exactly one. Applying the suggested change (dropping the decrement) would reintroduce the skip: whenever two logs needing removal are adjacent, the second would be missed and left stranded in logs/iterators/expectedExactStarts.

Recommend not applying the suggested change.

@claude

claude Bot commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Reviewed the derived-index runtime, RocksDB log-range changes, eviction-marker plumbing, and all new/modified tests. One blocker found (inline): ELIGIBLE_ACTIONS in derivedIndexRuntime.ts includes 'invalidate' and 'relocate', but neither action type is exercised by any test in this PR, so a typo there would silently drop derived-index updates for those write paths.

Also traced through gemini-code-assist's "critical" comment on RocksTransactionLogStore.ts:428 (the i-- in the splice loop) and left an inline reply — that loop iterates forward, the decrement is the correct fix for splicing during forward iteration (confirmed against the pre-PR diff, which had the same single decrement on the previous last splice call), and applying the suggested patch would reintroduce a skip bug. Recommend not applying that suggestion.

@@ -0,0 +1,520 @@
# Derived-index runtime: committed log delivery and exact replay foundation

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Putting design docs for a PR into the repository goes against our coding conventions. Design information that needs to be persisted for future agents should go in DESIGN.md files (with tight budgeting, more words is extremely costly and degrading for future agent's context), or for the temporary purpose of PR design, it should go in the PR description (never lost, all PRs histories are retained). Most of this is the latter.

kriszyp added a commit that referenced this pull request Sep 9, 2026
Ordinary input could take a file-primary index down for hours. A stored vector whose length
differs from the plane's create-time dims, a component finite as a double but not as the f32
the plane stores, one whose square overflows f32 even though it does not (storing invMag 0,
which makes every distance involving that node NaN), a decoded BigInt, or a value with no
length at all: each reached the crate or Float32Array.from as an error neither the search
path nor reconstruction can attribute to the input. A record aborted every rebuild attempt at
the same entry; a query was read as plane corruption and unlinked a healthy file, costing a
reconstruction the design measures in hours at 16M nodes.

Validate the invariant once, in assertPlaneVector, and apply it at both entry points: the
committed projection and the search target. The dimension check runs again before the native
insert, where a worker that had no plane to compare against finally has one, and before the
old node is removed.

Rejecting bad input is not enough on its own: only the rebuild scan skipped records the
backend cannot index, while replay rethrew them, and rebuild finishes by replaying from the
oldest retained entry — so every record the scan skipped was met again and reconstruction
could never finish. Give replay the same skip-and-count guard.

Retiring a runtime now fences its in-flight rebuild and replay: neither checked `closed` at
any await, so on the table() redefine path the retired runtime's final `isIndexing = false`
landed after its replacement had set it and this worker served searches from the half-built
generation; a retired runtime no longer advances a durable cursor either. A failure in the
id-mapping reads that follow a successful traversal also no longer counts as a plane failure,
so a transient RocksDB read or a store closed under an in-flight search cannot unlink the
file.

hnsw-native-plane.md §13 records an integrated ingest benchmark and the convergence plan it
argues for: adopt #2533's runtime, add the coalesced delivery view, bounded collection and
delivery, an explicit durability cadence, a rebuild phase, generation fencing and shared
readiness, and reduce this PR to an HNSW backend. §13.6 retires the rebuild-anchor question
the first draft deferred to the storage layer: a committed read is bounded at one physical
offset and walks every byte up to it, so nothing sits behind the tail it yields and the tail
is already a safe anchor.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VhMkk4iZBE8zEQcnvu1syc
kriszyp added a commit that referenced this pull request Sep 9, 2026
Ordinary input could take a file-primary index down for hours. A stored vector whose length
differs from the plane's create-time dims, a component finite as a double but not as the f32
the plane stores, one whose square overflows f32 even though it does not (storing invMag 0,
which makes every distance involving that node NaN), a decoded BigInt, or a value with no
length at all: each reached the crate or Float32Array.from as an error neither the search
path nor reconstruction can attribute to the input. A record aborted every rebuild attempt at
the same entry; a query was read as plane corruption and unlinked a healthy file, costing a
reconstruction the design measures in hours at 16M nodes.

Validate the invariant once, in assertPlaneVector, and apply it at both entry points: the
committed projection and the search target. The dimension check runs again before the native
insert, where a worker that had no plane to compare against finally has one, and before the
old node is removed.

Rejecting bad input is not enough on its own: only the rebuild scan skipped records the
backend cannot index, while replay rethrew them, and rebuild finishes by replaying from the
oldest retained entry — so every record the scan skipped was met again and reconstruction
could never finish. Give replay the same skip-and-count guard.

Retiring a runtime now fences its in-flight rebuild and replay: neither checked `closed` at
any await, so on the table() redefine path the retired runtime's final `isIndexing = false`
landed after its replacement had set it and this worker served searches from the half-built
generation; a retired runtime no longer advances a durable cursor either. A failure in the
id-mapping reads that follow a successful traversal also no longer counts as a plane failure,
so a transient RocksDB read or a store closed under an in-flight search cannot unlink the
file.

hnsw-native-plane.md §13 records an integrated ingest benchmark and the convergence plan it
argues for: adopt #2533's runtime, add the coalesced delivery view, bounded collection and
delivery, an explicit durability cadence, a rebuild phase, generation fencing and shared
readiness, and reduce this PR to an HNSW backend. §13.6 retires the rebuild-anchor question
the first draft deferred to the storage layer: a committed read is bounded at one physical
offset and walks every byte up to it, so nothing sits behind the tail it yields and the tail
is already a safe anchor.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VhMkk4iZBE8zEQcnvu1syc
kriszyp added a commit that referenced this pull request Sep 9, 2026
Ordinary input could take a file-primary index down for hours. A stored vector whose length
differs from the plane's create-time dims, a component finite as a double but not as the f32
the plane stores, one whose square overflows f32 even though it does not (storing invMag 0,
which makes every distance involving that node NaN), a decoded BigInt, or a value with no
length at all: each reached the crate or Float32Array.from as an error neither the search
path nor reconstruction can attribute to the input. A record aborted every rebuild attempt at
the same entry; a query was read as plane corruption and unlinked a healthy file, costing a
reconstruction the design measures in hours at 16M nodes.

Validate the invariant once, in assertPlaneVector, and apply it at both entry points: the
committed projection and the search target. The dimension check runs again before the native
insert, where a worker that had no plane to compare against finally has one, and before the
old node is removed.

Rejecting bad input is not enough on its own: only the rebuild scan skipped records the
backend cannot index, while replay rethrew them, and rebuild finishes by replaying from the
oldest retained entry — so every record the scan skipped was met again and reconstruction
could never finish. Give replay the same skip-and-count guard.

Retiring a runtime now fences its in-flight rebuild and replay: neither checked `closed` at
any await, so on the table() redefine path the retired runtime's final `isIndexing = false`
landed after its replacement had set it and this worker served searches from the half-built
generation; a retired runtime no longer advances a durable cursor either. A failure in the
id-mapping reads that follow a successful traversal also no longer counts as a plane failure,
so a transient RocksDB read or a store closed under an in-flight search cannot unlink the
file.

hnsw-native-plane.md §13 records an integrated ingest benchmark and the convergence plan it
argues for: adopt #2533's runtime, add the coalesced delivery view, bounded collection and
delivery, an explicit durability cadence, a rebuild phase, generation fencing and shared
readiness, and reduce this PR to an HNSW backend. §13.6 retires the rebuild-anchor question
the first draft deferred to the storage layer: a committed read is bounded at one physical
offset and walks every byte up to it, so nothing sits behind the tail it yields and the tail
is already a safe anchor.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VhMkk4iZBE8zEQcnvu1syc
@kriszyp
kriszyp added this pull request to stack #2550 September 9, 2026 21:01
@kriszyp

kriszyp commented Sep 10, 2026

Copy link
Copy Markdown
Member

@kylebernhardy I am trying to optimize this runtime to be safe, efficient, and maximize simplicity with #2548. However, #2430 exists as the only consumer of the runtime to verify its functionality. Without any Tantivy consumer, the simplification work in 2548 is likely to make changes to this PR that will significantly impact (or break) Tantivy use of this. It seems like you would probably want to have a real consumer drive the contract of this PR rather than just self-assertion of the PR itself?

@kriszyp

kriszyp commented Sep 10, 2026

Copy link
Copy Markdown
Member

Superseded by #2548, which carries this branch's commits and retargets the whole derived-index stack at main for a single review and merge. Closing so the stack no longer blocks that retarget. — Claude Fable 5.1

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.

Implement the shared transaction-log runtime for derived indexes

2 participants