Skip to content

Range reads honor the transaction option: getRange(), getKeys(), and getKeysCount() see a transaction's staged writes and snapshot - #837

Open
kriszyp wants to merge 7 commits into
mainfrom
fix/getrange-transaction
Open

Range reads honor the transaction option: getRange(), getKeys(), and getKeysCount() see a transaction's staged writes and snapshot#837
kriszyp wants to merge 7 commits into
mainfrom
fix/getrange-transaction

Conversation

@kriszyp

@kriszyp kriszyp commented Sep 5, 2026

Copy link
Copy Markdown
Member

db.getRange({ transaction }), getKeys(), and getKeysCount() now read through the given transaction exactly as db.get(key, { transaction }) does: the caller's column family, the transaction's staged writes, and its snapshot. Before, the typed option was accepted and silently dropped, so a range read and a point read on the same handle in the same transaction disagreed about the database (the root cause of HarperFast/harper#2506). Refs #830.

The transaction is routed by id into the native iterator (a seventh constructor argument), where the caller database's descriptor resolves it, so an index column family's range read stays on the index column family; substituting the transaction's own context would have scanned the column family the transaction was created on. Transaction iterators register with the transaction, and commit, commitSync, abort, the coordinated-retry reset, and teardown close them before touching the RocksDB transaction, so a later next() throws "Iterator not initialized" while return()/throw() stay idempotent for loop cleanup; a range or count on a transaction that already started committing, or that has finished, throws "Transaction is not in pending state" (the latter crashed on main). A transaction handed to a database at another path is rejected, since ids are allocated per database and would otherwise resolve to an unrelated transaction, and so is a bare { id } object, which used to resolve by id alone; a range on a transaction whose own handle was closed while the descriptor lived on also crashed and now throws "Database not open". The native implementation was authored by a Codex session on this task; this Claude session rebased it onto #829, added the lifetime hardening and provenance check, the docs, and most of the tests.

Left as they are, all pre-existing and recorded as findings for separate issues: a forced cross-environment teardown (shutdown()/destroy()) is not serialized against a next() in flight on another environment's thread (the descriptor's closables sweep never was); a RocksDB iterator that hits a read error still reports exhaustion rather than throwing (plain ranges and counts too); and txn.getSync() after abort() has the same cleared-handle crash that this PR closes for ranges, in code this PR does not touch.

For the human reviewer

  1. Semantic flip for Harper, shipped as a minor release with no opt-in. Harper's query engine already passes { transaction } on every primary-store and index scan (resources/search.ts), so on its next rocksdb-js bump every search()/query() becomes transactional and each scan pins its read transaction until the iterator is drained or closed. Harper's read-transaction lifetime accounting (DatabaseTransaction.readTxnsUsed / doneReadTxn) is the queued companion task. Chosen: no gate, because Harper pins @harperfast/rocksdb-js to an exact version (2.8.0), so the flip lands only in Harper's own dependency-bump PR, where the companion change ships with it; an opt-in flag would keep today's default of a typed option that is accepted and ignored, and a major bump would label a bug fix as a contract change. Reversible: an opt-in open option can still be added before release. Cost of a "no": another release cycle and a flag to remove later. The planning review's framing verdict was chosen-approach-sound; ship the JS and native binaries together, since the seventh constructor argument cannot be mixed with an older binding.
  2. txn.getRange() now establishes and reads on the transaction snapshot, as txn.get() and txn.getKeysCount() already did; before, a direct transactional range saw the latest committed state plus the write batch and pinned no snapshot. This is what makes range and point reads agree, but a transaction that only ever scanned now holds a snapshot for its lifetime (a lifetime and optimistic-conflict-rate change). disableSnapshot transactions and tailing: true still read the latest committed state (tested and documented in the README; the invariant is AGENTS.md 19).
  3. Iterators are invalidated when commit starts, including attempts that later fail. RocksDB consumes the write batch at commit and the coordinated-retry reset deletes the transaction, so an open iterator cannot survive either; preserving a detached view would need a different ownership design, and Harper's model does not need it (it commits the writes on a fresh transaction and keeps the read handle until its iterators drain). A next() after commit now throws deterministically where it previously read freed memory.
  4. Transaction provenance is checked in JavaScript, by store path, for every API that takes the option. getTxnId now requires a real Transaction (one that carries its store) and rejects one whose store path differs from the caller's; column families of one database share the path, so cross-column-family reads still work. This is stricter than before for callers that passed a bare { id } (none in Harper, which passes RocksTransaction instances). The alternative, native descriptor identity, would need new surface across the binding.
  5. The write-batch bound check is version-gated rather than removed. RocksDB applies read-option bounds on the write-batch side since 8.10.0 (this package pins 11.8.1), so on the pinned build transaction iterators pay the far-bound compare only for the reverse exclusiveStart case, like plain iterators (RocksDB's lower bound is inclusive). A ROCKSDB_VERSION/ROCKSDB_PATH build against an older release checks both bounds on transaction iterators; that path cannot be exercised by the pinned build.
  6. Overruled review findings, for the record. A transactional count now registers its iterator like a range (one map node and two uncontended mutex round trips per count, against a count that iterates at a microsecond per key), and a write-only transaction takes the empty registry mutex three times (tens of nanoseconds against a commit costing tens of microseconds); neither is measurable, and both keep one admission path.

Verification

Route (a), extended vitest suites: test/ranges.test.ts (with the foreign-database case), test/transaction-cross-column-family.test.ts, test/transactions.test.ts, and the orphan-GC fixture (routed iterator mode). Fails-on-base: with the new tests applied to a build of the merge base (7ab102c), 15 tests fail with expected [ 'b', 'd' ] to deeply equal [ 'a', 'b', 'c', 'd' ], expected [ { key: 'committed', … } ] to deeply equal [ …, { key: 'later', … } ], expected [Function] to throw an error, expected [] to deeply equal [ 'b' ], and (cross column family) expected [ …(25) ] to have a length of 26 but got 25; all pass on the branch. Disabling the explicit bound check on the branch makes the reverse bounds tests yield the lower-bound key (expected [ 'e', 'd', 'c', 'b', 'a' ] to deeply equal [ 'e', 'd', 'c', 'b' ]), the measurement behind entry 5. pnpm build:binding preceded every run. pnpm test on the final head (d3dfdc9): 63 files passed, 1 skipped; 884 tests passed, 9 skipped (the skips are the pre-existing GC- and platform-gated cases). pnpm check clean. The native GoogleTest target is unaffected (N-API code only).

benchmark/ranges.bench.ts (rocksdb only, base and branch interleaved twice on an idle machine, ops/s): small range 19.7k/18.6k then 18.6k/18.6k, keys only 28.7k/27.3k then 26.5k/27.3k, forward 20.2k/19.5k then 19.7k/19.3k, reverse 223k/246k then 224k/240k, sparse prefix 80.7k/79.0k then 79.7k/79.2k. Plain forward scans are within run-to-run noise; reverse scans gained 7 to 10 percent from the explicit SeekForPrev and the removal of the peek-ahead in Next().

Complexity: complicated

Review-Coverage: authored=codex; ran=claude,gemini; declined=cursor-grok,cursor-composer,domain; rounds=5 @ 594c8a2

Human-Review-Need: 4 @ 594c8a2

kriszyp and others added 6 commits September 4, 2026 21:44
Route range iterators through the transaction supplied in options while preserving the caller's column family, snapshot, bounds, and iterator lifetime. Add coverage across range APIs and transaction modes.\n\nCo-Authored-By: GPT-5 Codex <noreply@openai.com>
Follow-up to the range routing change: register a transaction iterator only
after its registry insert succeeds, drain the registry without allocating,
admit counts through the same pending-state check as ranges and surface the
error instead of reading a write batch a commit is consuming, close iterators
before the coordinated-retry reset deletes the transaction, always step off a
key equal to the exclusive end bound in reverse, and skip the bound compare
on plain iterators except the reverse exclusive-start case RocksDB's inclusive
lower bound cannot express. Document the option and tailing semantics; cover
staged overwrites/deletes, empty bases, range-first and disabled snapshots,
pessimistic mode, in-flight commits, foreign registries, commitSync, and a
routed orphan iterator.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018QjjjDsTgnSnsHc3sBUk3t
Round-1 review fixes: `getTxnId` rejects a transaction whose store path is
not this database's, since ids are allocated per database and another
database's id resolves to an unrelated transaction; native `Return`/`Throw`
tolerate an iterator a commit or abort already closed so loop cleanup
cannot throw; `closeIterators` waits for a handle mid-destruction on another
thread instead of freeing the transaction under it; the far-bound compare on
transaction iterators is gated at compile time on the linked RocksDB (bounds
on the write batch since 8.10.0), so the pinned build only pays it for the
reverse exclusive-start case. Document count admission and cleanup semantics.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018QjjjDsTgnSnsHc3sBUk3t
Header docs now describe createIterator/closeIterators instead of the
removed register/unregister pair and no longer claim init() registers with
the descriptor; README states that a transaction context takes precedence
over a transaction option; AGENTS.md says the iterator registry does not
serialize a cross-environment close against an in-flight next().

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018QjjjDsTgnSnsHc3sBUk3t
`txn.getRange()` after abort or commit dereferenced the transaction's
cleared DBHandle while resolving the key buffer, ahead of the pending-state
check; it now throws like a range opened during commit does.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018QjjjDsTgnSnsHc3sBUk3t
A transaction whose own database handle closed while the descriptor lived
on crashed in the iterator constructor; both admission paths now check the
target handle is open. `getTxnId` requires a real Transaction (one that
carries its store), so a bare `{ id }` can no longer resolve another
caller's transaction in the same database.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018QjjjDsTgnSnsHc3sBUk3t
@kriszyp kriszyp added this to the v5.3 milestone Sep 5, 2026

@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 support for transactional ranges and iterators, allowing operations like getRange(), getKeys(), and getKeysCount() to run within a transaction context. It updates documentation, handles cross-column-family scans, ensures proper iterator lifetime management (closing iterators before transaction commit/abort), and adds comprehensive tests. The review feedback suggests wrapping the non-transactional branch of Database::GetCount in a try/catch block to prevent potential crashes, avoiding unused structured bindings in TransactionHandle::closeIterators to prevent compiler warnings, and explicitly throwing an error if a transaction is passed to a read-only database instead of silently ignoring it.

Comment thread src/binding/database/database.cpp
Comment thread src/binding/transaction/transaction_handle.cpp
Comment thread src/store.ts
@github-actions

github-actions Bot commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

📊 Benchmark Results

get-sync.bench.ts

getSync() > random keys - small key size (100 records)

Implementation Rank Operations/sec Mean (ms) Min (ms) Max (ms) RME (%) Samples
🥇 lmdb 1 24.75K ops/sec 40.41 38.87 664.095 0.111 123,746
🥈 rocksdb 2 10.62K ops/sec 94.18 89.86 4,103.845 0.169 53,091

getSync() > sequential keys - small key size (100 records)

Implementation Rank Operations/sec Mean (ms) Min (ms) Max (ms) RME (%) Samples
🥇 lmdb 1 28.65K ops/sec 34.91 33.75 662.625 0.108 143,236
🥈 rocksdb 2 11.00K ops/sec 90.94 88.51 667.026 0.057 54,981

ranges.bench.ts

getRange() > small range (100 records, 50 range)

Implementation Rank Operations/sec Mean (ms) Min (ms) Max (ms) RME (%) Samples
🥇 lmdb 1 24.19K ops/sec 41.34 35.67 1,946.399 0.289 120,950
🥈 rocksdb 2 16.25K ops/sec 61.55 52.41 1,166.704 0.126 81,231

realistic-load.bench.ts

Realistic write load with workers > write variable records with transaction log

Implementation Rank Operations/sec Mean (ms) Min (ms) Max (ms) RME (%) Samples
🥇 rocksdb 1 355.46 ops/sec 2,813.275 59.86 43,925.563 12.66 711
🥈 lmdb 2 26.82 ops/sec 37,286.15 406.701 1,188,664.867 136.574 64.00

transaction-log.bench.ts

Transaction log > read 100 iterators while write log with 100 byte records

Implementation Rank Operations/sec Mean (ms) Min (ms) Max (ms) RME (%) Samples
🥇 rocksdb 1 39.24K ops/sec 25.49 11.53 20,709.666 0.842 196,181
🥈 lmdb 2 446.47 ops/sec 2,239.79 157.812 23,476.136 1.46 2,233

Transaction log > read one entry from random position from log with 1000 100 byte records

Implementation Rank Operations/sec Mean (ms) Min (ms) Max (ms) RME (%) Samples
🥇 rocksdb 1 735.66K ops/sec 1.36 1.19 510.83 0.066 3,678,319
🥈 lmdb 2 447.54K ops/sec 2.23 1.11 9,843.743 1.09 2,237,713

worker-put-sync.bench.ts

putSync() > random keys - small key size (100 records, 10 workers)

Implementation Rank Operations/sec Mean (ms) Min (ms) Max (ms) RME (%) Samples
🥇 rocksdb 1 814.87 ops/sec 1,227.187 1,052.183 2,867.745 0.426 1,630
🥈 lmdb 2 1.16 ops/sec 859,060.9 818,643.41 898,406.858 2.31 10.00

worker-transaction-log.bench.ts

Transaction log with workers > write log with 100 byte records

Implementation Rank Operations/sec Mean (ms) Min (ms) Max (ms) RME (%) Samples
🥇 rocksdb 1 21.54K ops/sec 46.42 29.43 20,393.025 2.07 43,085
🥈 lmdb 2 812.75 ops/sec 1,230.387 67.70 13,991.295 5.64 1,627

Results from commit e06a67d

`Database::GetCount` wrapped only its transactional branch; the plain
branch constructs a `DBIteratorHandle` the same way and now reports a
failure as a JS error instead of letting it escape the N-API callback.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018QjjjDsTgnSnsHc3sBUk3t
@kriszyp
kriszyp marked this pull request as ready for review September 5, 2026 13:48
@kriszyp kriszyp modified the milestones: v5.3, v5.2 Sep 8, 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

Development

Successfully merging this pull request may close these issues.

1 participant