Skip to content

Shared derived-index runtime for native backends - #2567

Merged
kriszyp merged 42 commits into
mainfrom
feat/derived-index-native-backend-runtime
Sep 11, 2026
Merged

kriszyp merged 42 commits into
mainfrom
feat/derived-index-native-backend-runtime

Conversation

@kriszyp

@kriszyp kriszyp commented Sep 10, 2026

Copy link
Copy Markdown
Member

The shared post-commit derived-index runtime for RocksDB tables: lock-elected delivery of committed transaction-log mutations to a native index backend, exact cursor resume, rebuild from primary records, cross-worker readiness, and opt-in writer backpressure. This PR carries the whole stack — #2533 (runtime foundation), #2535 and the native-backend additions — so one review covers the derived index end to end and one merge lands it. #2430 (native HNSW) is rebuilt on this branch and is the first consumer; nothing here has a production caller until it lands.

The durable design is the § Derived-index runtime section of the root DESIGN.md (invariants, ownership, cursor semantics, backend contract, bounded delivery, cadence, rebuild, fencing, shared readiness, lag policy). This description holds what is transient: scope, decisions, measurements, verification.

What is here

Runtime (resources/derivedIndexRuntime.ts). One DerivedIndexRuntime per database wakes on the root store's committed event; each registered backend has its own process-wide lock, runner, cursor vector (log name → completed transaction timestamp) and readiness. The elected runner reads committed entries from every physical log, collects identities per transaction, resolves each changed record once against the primary store after its last occurrence, projects only the registered attributes, and delivers bounded batches. Resume proves every saved cursor with exactStart; a boundary the log cannot resolve, a corrupt frame, a removed log or a reload marker condemns the generation and rebuilds. Nothing on the commit path is added beyond the log entry that already exists and a local-only eviction marker for registered caching tables.

Backend contract. One interface: attach(host) (the owner-epoch fence), deliver(batch) (accept/defer/fail), flush(reason) (barrier request), shutdown(epoch) (quiescence), getDurableCursor(), onStateChange(), and optional reset(epoch). batch.records is the last-write-wins view over distinct keys; batch.through is the cursor vector the batch completes and is withheld until an oversized transaction's closing chunk. Every hook is required — a backend that completes inside deliver() implements them trivially — because work that survives a method return is what an ownership handoff has to fence.

Rebuild. rebuilding is published, the previous epoch is quiesced, reset(newEpoch) runs, the committed tail of every log is captured, primary records are scanned and delivered in bounded chunks, the final chunk carries the tail as through, and replay resumes after it. Bounded retry with backoff; unavailable after the budget, honoured by peers.

Shared readiness. State, reason code, attempt count, rebuild request, lag flag and the owner-epoch counter in one getUserSharedBuffer allocation per index, read with plain Atomics.loads on any worker (readDerivedIndexReadiness). Same dependency as primary-key allocation, blob holds and HNSW node ids already carry.

Lag policy (opt-in, maxLagMilliseconds). The owner measures cursor distance, time parked, unread-log age and undurable-work age; past the budget every worker rejects local user writes to the index's tables with a retryable 503 (DERIVED_INDEX_LAGGING) at the staging layer (_writeUpdate / _writeDelete / _writeInvalidate / _writeRelocate), never canonical-source applies, replay or replication notifications. Cleared with hysteresis once the owner proves catch-up, and on every park the runtime cannot leave on its own.

Simplifications made in review

The design was cut down before asking for review, each item verified against source rather than inferred:

  • One backend contract instead of a synchronous/asynchronous split with a discriminant, registration-time hook validation for one side, a runtime check that a "synchronous" flush returned no promise, and a quiescence path for that undeclared promise. No shipped or planned backend is synchronous.
  • Plain words instead of a sequence lock. Nothing read the free-form reason string the seqlock existed to publish; the shared record carries a DerivedIndexReadinessReason code, the epoch counter moved into the same buffer, and the 512-byte record became 32 bytes with no spin. This removed the only construct in the runtime that was novel with respect to Harper's existing use of Atomics over rocksdb-js shared buffers.
  • Committed-tail rebuild anchor instead of the oldest retained entry. rocksdb-js advances lastCommittedPosition only to the earliest still-uncommitted write (TransactionLogStore::commitFinished, uncommittedTransactionPositions.front()), so a committed read is a contiguous physical prefix and nothing committed after the capture can sit behind the tail. The whole-retention-window replay after every rebuild, the capture-time reload-marker suppression, its Date.now() comparison against transaction timestamps, the shared reload word and the backward-clock residual are gone; a reload marker is met exactly once.
  • Lag latch clears on every terminal park, not only on unavailable: a backend without reset, a runtime without scanRecords, a condemnation marker that could not be written, and entering a rebuild (no cursor to guard; readers act on rebuilding). Before this, three paths released ownership with the latch set and nothing scheduled to clear it, so writes stayed 503 indefinitely; one of them needed a commit wake to retry, and commits were what was being shed.
  • partial dropped from DerivedIndexTransaction (nothing consumed it; the withheld cursor already says what is certified), and the per-log cursor checks that could not fail after the whole-vector match removed.
  • RocksDerivedIndexStorage (from Add Harper RocksDB storage for derived indexes #2535) removed. It existed to back Tantivy's KvDirectory with a Harper-owned column family. The HNSW plane persists in its own mmap file, and Prepare the Harper full-text benchmark for native storage #2568's benchmark shows the hosted path failing its own gates (~26 GB through ~108k JS read callbacks for 500 searches; a root-wide flush per publication) while Tantivy's native MmapDirectory passes, so Tantivy will persist the same way. The runtime never depended on it: the cursor is backend-owned. The derived-index column-family open/close/drop hooks in databases.ts go with it.
  • A log that has never written a file retains its beginning. rocksdb-js reports oldestSequenceNumber: 0 with fileCount: 0 for it; the === 1 test read every brand-new database as a retention gap and failed every rebuild attempt. Found by restacking Native HNSW index: file-primary mmap graph as a backend on the shared derived-index runtime #2430 and defining a fresh table — the first real consumer exercising the runtime.

Decisions and alternatives

  • Transaction log, not an aftercommit stream or a permanent worker-0 drainer. Same-thread aftercommit dispatch has no durable cursor for logs shared by all workers and retains every audit object in a transaction; a fixed drainer pins all indexes to one worker. A lock-elected runner per backend gives each index one serial stream, its own cursor, and parallelism across workers. transactionBroadcast's subscription registry was considered as the base and rejected: its cursors are live-subscription state, its lifecycle is customer-facing, and its aggregate results omit the log identity a cursor vector needs.
  • Not a transactional dirty-key outbox (raised twice by planning review, overruled by the repo owner). It survives retention and works with auditing off, but adds a second durable write, a compaction stream and a cleanup protocol to every indexed mutation, a new column family and therefore a storage-format migration for every audited table, and still cannot commit an engine-specific native file atomically with RocksDB — the cursor protocol would be needed regardless.
  • Not in-transaction native mutation. Neither Tantivy nor the mmap plane shares RocksDB rollback: pre-commit application leaves phantoms after abort and puts native construction on request latency.
  • Rebuild in the runtime, not per backend. Reset → scan → project → deliver → replay is identical for vector and full-text and its duplication is how two runtimes came to exist.
  • Resolve after the last collected occurrence, not on first encounter (adopted from planning review): a writer committing between two occurrences of a key inside one chunk would otherwise have its later state certified by the cursor while the earlier state stayed indexed.
  • Eviction marker rather than keeping evicted documents and filtering candidates against the primary store, which would make this index the one exception to Harper's index lifecycle and add a primary read to every result.
  • Timer-coalesced idle completion rather than a barrier at every idle pass, which would cost one barrier per write for arrivals spaced just beyond drain completion.
  • Out of scope: a shared head reader for indexes whose cursors stay within a cohort (no measured N-scan bottleneck yet; the contracts allow it later), and retention pinning (rocksdb-js has no protected-position registration).

Measurements

Synthetic native-cost bench (derivedIndexRuntime.bench.js, 350 µs/apply, 5 ms barrier): 1,000 occurrences over 50 keys in one window — 1,000 applies / 359 ms through transactions, 50 applies / 24 ms through records; queued application held event-loop delay to 6.8 ms max versus 963 ms inline at the same ~2,800 mutations/s. At 1,500 arrivals/s, a 100 ms / 512-mutation cadence delivered 1,492/s with p99 write→durable 116 ms and 30 barriers in 3 s; the defaults (1 s / 4096) give p50 1.27 s and 4 barriers. On the native HNSW backend (#2430's ingest bench, 384-d): 265 native applies for 1,000 commits over 50 keys, foreground put 0.09 ms, apply 0.365 ms, barrier ~9 ms.

Verification

  • Derived suites (derivedIndexRuntime, derivedIndexRuntimeNativeBackend, derivedIndexRuntimeRocks, derivedIndexRegistry): 81 passing. The native-backend suite covers coalescing, oversized transactions, deferral, cadence, rebuild/retry/exhaustion, handoff with an apply scheduled and with a flush pending, shutdown failure holding the lock, condemnation across a simulated restart, lag trip/clear/hysteresis, the three terminal-park cases (each proven red without the fix), tail-anchored rebuilds across several logs, and a real worker thread reading the owner's publication through the binding.
  • End to end on real audited RocksDB tables: commit → runtime → queued backend → barrier → readiness; primary scan and replay; write shedding at the staging layer with canonical-source bypass.
  • Native HNSW index: file-primary mmap graph as a backend on the shared derived-index runtime #2430 restacked on this branch: vectorIndexPlane.test.js 22 passing against @harperfast/hnsw 0.2.1 — two workers into one native file, a committed write replayed after its worker dies, reload markers, a cursor outside retention, an empty index answering no results, a populated index that lost its file answering 503.
  • npm run build, tsc --noEmit, lint:required, prettier: clean.

Refs #2489. Supersedes #2533 and #2535 (whose storage adapter is dropped here, see above).

🤖 Generated with Claude Code

@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 a lock-elected, cursor-based delivery system of committed RocksDB log mutations to derived-index backends. It introduces the DerivedIndexRuntime and DerivedIndexRunner to coordinate aggregate log iteration, transaction assembly, authoritative record resolution, and a robust rebuild phase. Additionally, it adds host-storage primitives via RocksDerivedIndexStorage, local-only cache eviction markers in Table.ts, and an opt-in writer backpressure lag policy. Comprehensive unit tests, benchmarks, and integration tests are also included to verify correctness and performance. No review comments were provided for this pull request, so there is no additional feedback to address.

@claude

claude Bot commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

Reviewed; no blockers found.

kylebernhardy and others added 19 commits September 11, 2026 09:41
Adds to the shared derived-index runtime (#2489) what a backend with
milliseconds-per-mutation apply cost and an msync barrier needs:

- a coalesced last-write-wins `records` view beside `transactions`
- identity-first bounded collection with partial chunks for oversized
  transactions and no cursor publication mid-transaction; per-registration
  turn, chunk, cadence and rebuild options
- a runtime-scheduled durability cadence (age, thresholds, shutdown) through
  an optional backend `flush(reason)`
- rebuild as a runtime phase on the conservative log boundary with capped
  backoff, a shared attempt budget and an observable `unavailable` end state
- shutdown-before-unlock handoff, an owner-epoch fence for backends, and a
  new epoch per rebuild attempt
- sequence-locked shared readiness readable on every worker

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P9BNWi77MsouBBexxHpT65
- a fault found mid-drain can no longer publish `ready` over `rebuilding`
- resolution checks the chunk byte and time bounds after every key and
  carries the remainder, so the turn budget covers both phases
- reload markers committed before a rebuild's capture are covered by its scan
- an owner acquiring on shared `needs-rebuild`/`rebuilding` rebuilds
- `stop()`/unregister reject after a failed backend shutdown; the held lock
  is revivable through `requestRebuild`; one `shutdown` per epoch
- `requestRebuild` from a non-owner travels through a shared request word;
  a request during a rebuild no longer dangles
- promise-returning `reset`/`flush` are awaited or fail closed

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P9BNWi77MsouBBexxHpT65
- a held lock is revived under the same epoch and re-quiesced before any
  successor epoch or reset
- stop()/unregister return one cached promise, wait for every backend, and
  release table registrations only after the backend settled
- a rebuild consumes the shared rebuild request at start and on success; a
  non-owner never publishes readiness
- flush rejections are generation-fenced; the age timer re-arms while
  accepted work is not durable
- discarded log iterators are closed; scan tombstones are skipped
- rebuild proven against a real audited RocksDB table

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P9BNWi77MsouBBexxHpT65
- a failed unregister shutdown stays in the runtime-wide stop() wait
- a shared rebuild request reaches an owner parked on backpressure or backoff
- an inherited exhausted budget spends no further attempt; a backend that
  cannot rebuild parks on a condemned generation
- reload suppression uses the wall clock transaction timestamps use
- an unindexable reason carries the error class and status, not its message

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P9BNWi77MsouBBexxHpT65
- the readiness buffer carries a notify callback so a non-owning worker's
  requestRebuild wakes the owner directly
- readiness views are cached per buffer; epoch minting sits inside the
  acquisition error boundary
- a two-log rebuild with an empty log proves the boundary omits it safely

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P9BNWi77MsouBBexxHpT65
- a release waits for an in-flight reset before quiescing the epoch
- a stopped runner holding the lock after a failed shutdown is revivable
  through the runtime's requestRebuild
- a latched unavailable status clears once a peer revived the index
- the reload-suppression bound is shared with the next owner
- shared readiness reasons never carry backend error messages; live
  tombstones resolve to absent; unflushed counters reset with the cursor

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P9BNWi77MsouBBexxHpT65
…capabilities

Adopted from the planning recheck: a backend that queues declares
`queued: true` and registration rejects it unless it implements attach,
flush and shutdown. Also guards the idle-release cursor read and the
cleanup hooks, and keeps a failed shutdown's message out of the shared
readiness reason.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P9BNWi77MsouBBexxHpT65
…al store

- latestSeen is recorded at collection and cleared with the cursor;
  stalledMilliseconds reports time parked on backpressure or the ceiling
- the per-key wall-time check samples every 16 records
- the real-RocksDB test drives a second runtime's rebuild request through
  the native shared buffer
- documents the wall-clock-step residual of reload suppression

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P9BNWi77MsouBBexxHpT65
…by iteration

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P9BNWi77MsouBBexxHpT65
…le advance

Item 7 as ruled (option b): a registration's maxLagMilliseconds makes the
owner publish a shared lag word; every worker's runner registers an
admission check for the index's tables and Table.update()/delete() throw a
retryable 503 DerivedIndexLagError while it is set. Replication apply and
cache fills are never gated.

Also from the adjudicated doc round: `ready` and the retry budget settle on
the first durable advance (a queued backend under sustained ingest never
idles); a chunk the projection rejects entirely fails closed; the latched
unavailable check reads one word.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P9BNWi77MsouBBexxHpT65
kriszyp and others added 21 commits September 11, 2026 09:41
…ch-up

Adopted from the lag policy's planning gate: the admission check moves to
_writeUpdate/_writeDelete, where create(), loadAsInstance:false writes and
held-lock saves converge, bypassing replication apply and replay. Lag is the
longest of cursor distance, time parked, and time since catch-up was last
proven, sampled on a lag timer while parked; a trip survives handoff until
the successor proves catch-up. The rebuild scan skips Harper-internal
symbol-keyed store entries.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P9BNWi77MsouBBexxHpT65
…ld circuit

- the all-unindexable circuit covers every rebuild scan chunk and the whole
  scan, with a floor no larger than the chunk bound
- an index that becomes unavailable admits writes again
- the unproven-catch-up clock restarts on discard, so a rebuild does not
  fabricate lag from ownership age
- a throw from tryLock is retried instead of parking the runner
- shared-memory views re-fetch until the binding hands back shared memory
- admission checks are stored in arrays and walked without allocation;
  rebuiltRecords counts only indexed records

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P9BNWi77MsouBBexxHpT65
Adopted from the second planning recheck: the capability flag is
`asynchronous: true` — any effect that survives a method return — rather
than a queued apply; a synchronous backend returning a promise from flush
fails closed; release awaits the shutdown flush and any in-flight reset
before quiescing and unlocking; reset carries a stated crash-safety
obligation. Shared-memory views are cached and refreshed only off the
write path until the memory is shared; a registration that throws part-way
cancels its readiness subscription.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P9BNWi77MsouBBexxHpT65
- the fence, epoch mint and owner publishes re-fetch their views on every
  use until the binding hands back shared memory; the hot admission read at
  most every 100 ms
- _writeInvalidate/_writeRelocate are gated like the other staging methods
- the lag policy is suspended during a rebuild; a rebuild re-offers a
  deferred chunk after one flush age so a dropped wake cannot park it
- a release before any epoch was minted skips quiescence

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P9BNWi77MsouBBexxHpT65
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P9BNWi77MsouBBexxHpT65
- an exhausted-budget validate no longer opens an iterator after release
- fence, admission and readiness reads re-fetch at most every 100 ms while
  the buffer is private; the mint and owner publishes still re-fetch each use
- collection samples the clock every 16 entries
- a present entry that decodes to null reaches the projection again
- invalidate/relocate reuse the transaction they already resolve
- an undeclared asynchronous shutdown flush is not awaited; tables stay
  registered while a failed shutdown holds the lock

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P9BNWi77MsouBBexxHpT65
Fetch shared-memory views once: rocksdb-js wraps one process-wide native
allocation per key in a new external ArrayBuffer on every call and never
returns a SharedArrayBuffer, so the re-fetch-until-shared machinery was
re-fetching forever. Charge the rebuild scan's turn budget for filtered
entries and yield on an empty chunk. Remove the all-unindexable circuit,
which turned a bulk write of attribute-less records into an unavailable
index; skip, count and warn once per streak instead. Hold the epoch's
quiescence under an undeclared asynchronous flush. Arm one lag timer.
Add the admission-cost bench case.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P9BNWi77MsouBBexxHpT65
A caught-up owner idling through its grace period kept adding its idle
time to the lag measure, tripped the lag word after one budget, and then
released with the word set, so a node whose only writes hit those tables
stayed at 503 with nothing left to prove catch-up. The clock now starts
when work is offered and stops when durable equals offered.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P9BNWi77MsouBBexxHpT65
Arm one guarded retry timer while tryLock keeps throwing, document that
a condemnation lives in process memory beside the reset crash-safety
caveat, prove the shared readiness record from a real worker thread
through the binding alone, and prune narrating comments.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P9BNWi77MsouBBexxHpT65
A condemnation now also lands in the root store under the index's
marker key, so a restart before the rebuild's reset has invalidated the
cursor rebuilds instead of trusting it; the marker clears at the first
durable ready. The admission bypass keys on transaction.sourceApply and
isReplay as well as isNotification, so a canonical-source apply is never
shed. Acquisition stays suppressed while the lock-retry timer is armed.
Expose quiescence age, document partial-chunk visibility and the
awaited stop(), and add a real-table put benchmark for the guard.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P9BNWi77MsouBBexxHpT65
A marker that cannot be written makes the index unavailable with no
reset issued, one that cannot be read counts as present, and every
acquisition reads it so the rebuild that follows clears it. Quiescence
age counts from the start of release, the bench proves its stub reaches
the staging layer, and the marker keyspace is exercised on the real
store.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P9BNWi77MsouBBexxHpT65
Proving catch-up only at an idle pass tripped the lag word for a backend
keeping up under sustained ingest, whose log is never exhausted. The
third term is now the age of the oldest accepted work not yet durable,
which every durable advance shrinks. An unreadable condemnation marker
no longer pre-sets the local flag, and a refused write is retried on the
next acquisition before anything else is trusted.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P9BNWi77MsouBBexxHpT65
Lag now also measures time since the oldest commit the runner may not
have read yet, cleared whenever a drain reaches the end of the log, so a
reader too slow to keep up cannot hide behind a caught-up backend. A
condemnation the root store refuses stays a shared needs-rebuild with
the lock released, and whichever runner acquires next retries the write
before any reset without spending a rebuild attempt.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P9BNWi77MsouBBexxHpT65
…rained

The hysteresis clear now needs both a durable advance and the end of
the log reached since acquisition, so a successor's first barrier cannot
clear a trip it inherited with the backlog. A backend that cannot
rebuild no longer enters the rebuild path on a refused marker, and the
unread clock is not read when the policy is off.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P9BNWi77MsouBBexxHpT65
A reader that never quite empties a steadily fed log was charged the
age of its first unread commit; the term is now the smaller of that and
the distance between the clock and the newest entry read.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P9BNWi77MsouBBexxHpT65
The lag latch is process-shared memory that only an owning runner clears. Three
paths gave up ownership while it was still set and scheduled nothing that could
clear it, so every worker kept rejecting writes to the index's tables with a
retryable 503 indefinitely:

- `#needsRebuild()` when the backend has no `reset` or the runtime has no
  `scanRecords`. Nothing will ever rebuild, so nothing will ever catch up.
- `#acquired()` inheriting that same shared `needs-rebuild` state.
- `#deferForCondemnation()`, whose retry needs a wake, and wakes come from
  commits -- the very writes being shed.

This is the rule `#becomeUnavailable()` and the failed-shutdown hold already
followed; these three were the paths that missed it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
One backend contract. No shipped or planned backend is synchronous (the HNSW
plane and Tantivy both queue and barrier), so the `asynchronous` discriminant,
the registration branch that validated hooks only for declared-asynchronous
backends, the check that a "synchronous" flush did not return a promise, and
the quiescence path for that undeclared promise are gone. `attach`, `flush`
and `shutdown` are required of every backend; a fake that completes inside
`deliver()` implements them trivially.

Shared readiness is plain words, not a sequence lock. Nothing in production
read the free-form reason string the seqlock existed to publish; the shared
record now carries a `DerivedIndexReadinessReason` code in one Int32 word,
the owner-epoch counter lives in the same buffer instead of a second
`getUserSharedBuffer` key, and a read is four `Atomics.load`s with no spin.
The full message stays in the owner's local status and log. This removes the
one construct in the runtime that was novel with respect to how Harper already
uses `Atomics` over rocksdb-js shared buffers (primary-key allocation, blob
holds, HNSW node ids).

The rebuild anchors at the committed tail, not the oldest retained entry.
rocksdb-js advances `lastCommittedPosition` only to the earliest
still-uncommitted write (`TransactionLogStore::commitFinished`,
`uncommittedTransactionPositions.front()`), so a committed read is a
contiguous physical prefix and nothing committed after the capture can sit
behind the tail. The whole-retained-log replay after every rebuild, the
capture-time reload-marker suppression, its `Date.now()` comparison against
transaction timestamps, the shared reload word and the backward-clock
residual all go with it: a reload marker is met exactly once.

The lag latch clears on entering a rebuild (no durable cursor to guard) and on
every park the runtime cannot leave on its own, not only on `unavailable`.

`partial` is dropped from `DerivedIndexTransaction`: nothing consumed it and a
backend cannot act on it; the withheld cursor already says what is certified.
The per-log cursor checks in `#reconcileDurableCursor` that could not fail
after the whole-vector match are gone; the repeat-detection set stays, since
transaction timestamps are unique per log but not physically monotone.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…nning

rocksdb-js reports `oldestSequenceNumber: 0` with `fileCount: 0` for a log
that has no files yet, so the `=== 1` retention test rejected every brand-new
database: the first runner found no durable cursor, started a rebuild, and
`#captureBoundary` failed the attempt with "retains no committed transaction
and has lost its beginning" — on every attempt, until the budget parked the
index unavailable. Found by restacking the native HNSW index on the runtime
and defining a fresh table.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
`docs/derived-index-runtime-stage-1.md` described a PR and its stages. The
durable design — invariants, ownership, cursor semantics, offered versus
durable progress, the backend contract, bounded delivery, cadence, the
committed-tail rebuild, handoff fencing, shared readiness and the lag policy —
is now a section of the root DESIGN.md, where agents read design. Decision
records, alternatives and measurements move to the pull request.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@kriszyp
kriszyp force-pushed the feat/derived-index-native-backend-runtime branch from 4cad607 to b2f0e49 Compare September 11, 2026 15:42
Prettier expects a blank line between top-level sections; the manual
merge of the chooseOperation and derived-index-runtime sections
dropped it in both spots.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The storage surface from #2535 existed to back Tantivy's KvDirectory with a
Harper-owned column family. The HNSW plane keeps its graph in its own mmap
file, and #2568's benchmark shows the hosted path failing its own gates
(26 GB through ~108k JS read callbacks for 500 searches; a root-wide flush
per publication) while Tantivy's native MmapDirectory passes them, so
Tantivy will persist the same way. The runtime contract never depended on
it: the cursor is backend-owned and read from whatever the backend made
durable.

Removes the adapter, its tests, and the derived-index column-family
open/close/drop hooks in databases.ts.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@kriszyp
kriszyp merged commit 927c474 into main Sep 11, 2026
47 checks passed
@kriszyp
kriszyp deleted the feat/derived-index-native-backend-runtime branch September 11, 2026 19:15
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.

2 participants