Release the retained read handle's write intents when an outstanding-iterator commit replays - #2050
Release the retained read handle's write intents when an outstanding-iterator commit replays#2050kriszyp wants to merge 4 commits into
Conversation
…ith outstanding iterators replays The outstanding-iterators commit branch (8d69f1b) replays staged writes onto a fresh transaction and retains the original native handle solely for the iterators -- but the retained handle keeps its verification-table write intents locked until the last iterator drains or the long-transaction monitor releases the snapshot. No commit will ever release those intents, and other writers' coordinated-retry commits park on them: with an unbounded park this is harper#2001's leaked-holder write wedge. Release them at replay time via rocksdb-js Transaction.abandonWrites() (feature-detected; older rocksdb-js keeps today's behavior). The replay staged its own intents on the same slots first, so verification-table publish-blocking never gaps; reads through the retained handle, including read-your-own-writes, keep working. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…on test Addresses the cross-model review: the call runs after the replay commit is submitted, so an exception there would skip onCommit and the chain-store commit for an already-durable write -- fence it like the other post-submit steps. The test now also asserts the native method exists once the rocksdb-js version carrying it is installed, so a silent `?.` no-op cannot leave the wedge live while the test stays green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review
This pull request introduces changes to release write intents on retained read transactions by calling abandonWrites() if available on the transaction handle. It also adds a corresponding unit test that dynamically resolves the installed version of @harperfast/rocksdb-js to verify this behavior. Feedback on the changes includes: 1) Preventing a potential TypeError and misleading warning log in DatabaseTransaction.ts by using optional chaining when accessing this.transaction in case it is null. 2) Applying optional chaining when accessing properties of the parsed package.json in the test helper to ensure robust parsing.
…e replay Kris's request: the replay doubles the write work for that commit, so the application author should hear about the pattern. Once per process, naming the resource/method that started the transaction. Also from review: guard the release so a coordinated-retry round cannot re-fire it (gemini/claude bots), and null-check the retained handle so a closed transaction cannot turn into a misleading warning. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Guided tourTwo behavior changes, both inside 1. Warn once about the pattern (before the replay is built) if (!replayedWritesWarned) { replayedWritesWarned = true; harperLogger.warn?.(...) }Committing while iterators are open means every staged write is re-staged and committed a second time. That cost is invisible to the application author today. The warning names the count and the 2. Release the dead intents (after the replay commit is submitted) if (!this.writesAbandoned) {
this.writesAbandoned = true;
try { (this.transaction as {...} | null)?.abandonWrites?.(); }
catch (error) { harperLogger.warn?.(...); }
}Four deliberate properties, each from a review finding:
Read this before reviewing the diff
What the test proves, and what it doesn'tIt pins Harper's side: exactly one Where to look hardest
Review coveragecodex + gemini + Harper-domain adjudication pre-push, plus the CI bots on the pushed branch. Their findings produced the once-guard, the null-check, and the fence. grok was version-gated and did not run. Posted by Claude Fable 5 on behalf of @kriszyp. |
The unit suite shares one process, so another test file drives a commit under open iterators before this one runs and consumes the warning — green locally on the single file, red in CI. Adds a small exported reset seam (the same shape as setTxnExpiration) and calls it from the test. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
| // post-submit steps here: the replay commit is already in flight, so a throw must | ||
| // not skip onCommit/the chain-store commit below. Optional: rocksdb-js < 2.7 | ||
| // lacks the method. | ||
| if (!this.writesAbandoned) { |
There was a problem hiding this comment.
High: writesAbandoned is never reset, so the release is defeated on a reused transaction object
writesAbandoned is a per-native-handle once-guard (like baseReadRefConsumed), but unlike baseReadRefConsumed it is never cleared. getReadTxn() resets baseReadRefConsumed = false when it creates a fresh this.transaction, precisely because a DatabaseTransaction is reused across handle lifecycles (see the // this transaction be reused and committed again note in the success handler; doneReadTxn() nulls this.transaction, then a later getReadTxn() builds a new one). writesAbandoned stays true across that boundary.
Failure sequence on a reused object (e.g. a long-lived context that read-iterates-writes-commits more than once):
- Batch 1 commits under open iterators → replay +
abandonWrites(),writesAbandoned = true; iterators drain →doneReadTxn()aborts and nulls the handle. - Same object, batch 2:
getReadTxn()mints handle H2 (resetsbaseReadRefConsumed, notwritesAbandoned). Commit under open iterators re-enters this branch, butif (!this.writesAbandoned)is nowfalse→abandonWrites()is skipped for H2. H2's VT write intents leak exactly as before this PR — the harper#2001 per-thread write wedge recurs on the second and later occurrences.
The fix mirrors the sibling guard — reset it where the fresh handle is created, in getReadTxn() next to this.baseReadRefConsumed = false;:
this.readTxnsUsed = 1;
this.baseReadRefConsumed = false; // fresh handle, fresh base reference for commit() to consume
this.writesAbandoned = false; // fresh handle: its write intents have not been abandoned yet
This keeps the intended once-per-commit-cycle guard (retry rounds re-enter with this.transaction unchanged, so it still won't double-fire) while re-arming for the next reuse. Worth adding a test for the two-cycle reuse path — the current test only exercises a single abandon within one cycle, which is exactly the case that passes regardless.
—
Generated by Barber AI
Why
8d69f1bd1replaced the LINGERING deferral: a commit with outstanding read iterators now replays its staged writes onto a fresh transaction and commits them immediately, keeping the original native handle open solely so the iterators can finish. That closed a real class of acknowledged-write loss.What it left behind is the retained handle's verification-table write intents. The replay owns those writes now, so no commit will ever run on the original handle — nothing releases its intents until the last iterator drains (or the long-transaction monitor releases the snapshot). Meanwhile every other writer's
coordinatedRetrycommit that conflicts on those slots parks on them. With today's unbounded park that is #2001's per-thread write wedge: a live capture showed one slot lock-tagged withholders=3 woken=0more than five hours after onset, with the wedged thread 503-ing every write.The retention window predates
8d69f1bd1(LINGERING held the intents too), but there the pending commit still intended to write them. After the replay they are dead locks, which also makes them safe to release early.What
Call
abandonWrites()on the retained handle right after the replay commit is submitted. It drops the dead VT intents while leaving reads — including read-your-own-writes — working for the outstanding iterators.Ordering is safe by construction: the replay's save loop has already staged its own intents on the same slots, so VT publish-blocking never gaps; a writer parked on the original's tracker wakes once both are released.
The call is fenced in try/catch, matching this function's other post-submit steps (
onCommit,leaveWriteQueue): the replay commit is already in flight, so a throw here must not skip theaftercommitnotify or the chain-store commit for an already-durable write.Depends on HarperFast/rocksdb-js#747
abandonWrites()does not exist in the pinned@harperfast/rocksdb-js2.6.1 — this change is inert until #747 lands, is released, and the dependency is bumped. The call is feature-detected (?.) so it is safe to merge in either order, and the test asserts the native method must exist once a version ≥ 2.7 is installed, so the fix cannot stay silently no-op after the bump.Testing
unitTests/resources/transaction.test.js— new RocksDB-only case: an outstanding iterator plus a staged write drives the replay branch, and asserts the retained handle is abandoned exactly once, the replayed write is durable, and the iterator keeps producing entries afterward. Version-gated assertion that the native method is present once the dependency carries it.End-to-end verification route: the release semantics are proven in rocksdb-js#747's park/wake test (a parked coordinated-retry commit resolves
RETRY_NOWthe moment the holder abandons). On the Harper side the wedge mechanism itself was reproduced live on a test cluster — see #2001 for the A/B — but it needs a leaked holder plus a conflicting commit, which is not something the unit suite can stage; this test pins the call contract and the surrounding invariants instead.unitTests/resources/transaction.test.js: 30 passing. Cross-model review: codex + gemini + Harper domain pass.Generated with Claude Fable 5.