Release the RocksDB transaction handle on abort and on a failed direct commit - #2128
Open
kriszyp wants to merge 5 commits into
Open
Release the RocksDB transaction handle on abort and on a failed direct commit#2128kriszyp wants to merge 5 commits into
kriszyp wants to merge 5 commits into
Conversation
…ed direct commit abort() released the RocksDB transaction only through the read-refcount loop (`while (this.readTxnsUsed > 0) this.doneReadTxn()`), and only getReadTxn() ever sets readTxnsUsed. A write-first link — save() built the native transaction with no prior read, the shape of a blind write to a second database in a multi-store chain (see chainStillActive) — leaves it undefined, so `undefined > 0` is false, the loop never runs, and the handle is stranded. abortDueToTimeout() walks the chain calling abort() on exactly those links. directCommitSync() had the same gap from the other side: it removes the transaction from tracking and never nulls `this.transaction`, so a throwing commitSync leaves an open handle that nothing can reach — and on success left the object serving an already-closed transaction from getReadTxn()'s early return. Neither leak is recoverable: rocksdb-js's descriptor registry holds a strong reference to the handle, so an unreleased one keeps its read snapshot and RocksDB cannot discard obsolete versions for that whole database until the process exits. Reported in #2107, where a high-churn secondary index reached ~4 keys per live row in 5 days and bounded range scans degraded ~21x. rocksdb-js is being fixed to self-heal at GC as well; these are the release paths that should never have depended on it. Also surface the condition: `system_information.metrics` gains `numSnapshots` per database (rocksdb.num-snapshots). oldestSnapshotTime was the only snapshot signal and it stops moving once the oldest snapshot is pinned, so accrual was invisible; a nonzero count means that database can no longer reclaim. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…rns to baseline Review follow-ups. doneReadTxn()'s native abort was unguarded, so an already-aborted handle threw out of abort()'s very first statement and skipped everything after it — CLOSED state, blob cleanup, context release — for callers (abortDueToTimeout, abortChainAfterRetries) that have no handler. Now guarded the same way releaseReadTxn() already was, which is also what makes the new unconditional release reachable on the read path. The object-double tests proved the JS release logic but not the thing #2107 is about, so add one that builds the write-first shape on a real RocksDB store and asserts both the registry transaction count and rocksdb.num-snapshots return to baseline after abort. It fails on main, as do the other two. Also trim the added comments to what the code cannot say, and correct the numSnapshots note: an in-flight read legitimately holds a snapshot, so the signal is a count that stays nonzero while nothing is reading, not any nonzero count. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Review follow-up. Four release paths had each grown their own detach → abort → swallow-and-log block; abortNativeTransaction() is the one copy, so the "RocksTransaction.abort() throws on an already-released handle and every caller here is cleanup with no handler" reasoning lives in one place instead of drifting across four. Also cover it: a stub whose abort() throws now proves abort() absorbs it and still reaches the rest of its cleanup, which nothing exercised before. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The extracted helper had been inserted between recordCommitLatency's doc comment and the function itself. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Contributor
There was a problem hiding this comment.
Code Review
This pull request addresses transaction handling and native handle cleanup in DatabaseTransaction.ts to prevent resource leaks and avoid pinning obsolete database versions. It introduces a safe abortNativeTransaction helper, ensures native handles are detached before committing or aborting, adds tracking for numSnapshots in system stats, and includes comprehensive unit tests. The review feedback suggests adding null/undefined guards in abortNativeTransaction and directCommitSync for defensive programming, and using assert.strictEqual instead of assert.equal in the test suite to comply with the repository's style guide.
Contributor
|
Reviewed; no blockers found. |
kriszyp
marked this pull request as ready for review
August 10, 2026 01:36
abortNativeTransaction() now accepts and early-returns on a nullish handle, with an explicit `== null` check per the repo convention, so the helper is safe for direct callers rather than relying on each call site having already checked. The four new tests use assert.strictEqual. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Two paths could strand a RocksDB transaction handle, permanently pinning a read snapshot so the database could never discard obsolete row versions. Both now release it unconditionally, and
system_information.metricsgainsnumSnapshotsso the condition is visible.Reported as #2107 — RocksDB read snapshot leaks permanently: on a production cluster a high-churn secondary index reached ~4 keys per live row in 5 days,
numberReseeksIterationwent 51 → 43,647, and bounded range scans degraded ~21x, with a process restart as the only recovery.What was wrong
abort()released the native handle only throughwhile (this.readTxnsUsed > 0) this.doneReadTxn(), andgetReadTxn()is the only thing that setsreadTxnsUsed. A write-first link —save()built the native transaction with no prior read — leaves itundefined, soundefined > 0is false and the loop never runs. That shape is reachable: a write-only link in a multi-store chain never callsgetReadTxn()(see the comment atchainStillActive), andabortDueToTimeout()walks the chain callingabort()on exactly those links.directCommitSync()removed the transaction from tracking but never nulledthis.transaction. On a throwingcommitSyncthat left an open, untracked handle nothing could reach; on success it left the object serving an already-closed transaction fromgetReadTxn()'s early return.doneReadTxn()'s native abort was unguarded.RocksTransaction.abort()throws on an already-released handle, anddoneReadTxn()is the first statement ofabort()— so a throw there skipped everything after it:CLOSEDstate, blob cleanup, context release.abortDueToTimeout()andabortChainAfterRetries()have no handler of their own.releaseReadTxn()was already guarded; this brings the other paths in line, and it is what makes the new unconditional release reachable on the read path at all.Neither leak self-heals: rocksdb-js's descriptor registry holds a strong reference to the handle, so an unreleased one survives GC and holds its snapshot until the process exits. HarperFast/rocksdb-js is being fixed to release at GC as well; these are the paths that should never have depended on that.
What changed
The four detach-abort-swallow blocks are now one
abortNativeTransaction()helper, so the reasoning behind swallowing the throw lives in one place instead of drifting across four call sites.system_information.metricsgainsnumSnapshotsper database.oldestSnapshotTimewas the only snapshot signal and it stops moving once the oldest snapshot is pinned, so accrual was invisible. Note this readsrocksdb.num-snapshots, which lands in rocksdb-js 2.8; against the currently pinned 2.7.1 the field is simply absent, so this is inert until that ships and the pin moves.Verification
npm run test:unit:resources— 1517 passing, 15 pending. Prettier andlint:requiredclean.Four new cases in
unitTests/resources/txn-tracking.test.js. Three are object-double checks of the release logic; the fourth builds the write-first shape on a real RocksDB store and asserts that both theregistryStatus()transaction count androcksdb.num-snapshotsreturn to baseline afterabort()— that one is the actual #2107 property. Fails-on-base: withorigin/main'sresources/DatabaseTransaction.tsrestored, the write-first, real-snapshot, and direct-commit cases all fail; the read-created control passes on both, so it is not vacuous.Not observable end-to-end beyond that: the leak's user-visible symptom is a multi-day scan degradation, so an integration test would have to age a store. The real-RocksDB unit assertion on
num-snapshotsis the closest honest proxy.For the human reviewer
Unconditional release as a fallback after the refcount loop, rather than one unified release path. Two mechanisms now exist that must stay mutually exclusive — the loop owns the read-created handle, the fallback owns the write-first one — and the seam between them is exactly where the unguarded-throw gap lived. Chosen because collapsing them into a single mechanism means reworking the
readTxnsUsed/readTxnRefCountpair and its deliberate off-by-one base, which is a larger change than this fix warrants. Reversible with a local refactor. Say no if you'd rather do the counter rework now.directCommitSync()aborts and rethrows on a failedcommitSync. That makes a failed sync commit terminal-rollback rather than leaving the handle for the caller to retry. Its two callers (resources/replayLogs.ts) already catch and log, and each version boundary builds a fresh transaction, so no caller loses a retry it was using. Changing this later alters observable write-atomicity behavior, so it is worth a second opinion.numSnapshotsships ahead of the stat it reads. It is inert until rocksdb-js 2.8 lands and the pin moves. The alternative was holding this until the dependency ships; kept because it costs nothing and the metric is what makes the next occurrence a five-minute diagnosis rather than a multi-hour one.Where to look hardest:
abort()in resources/DatabaseTransaction.ts. The question worth the most scrutiny is whether zeroingreadTxnsUsedin the fallback can strand an outstanding iterator that the refcount was tracking — I believe not, because the loop above has already drained any nonzero count anddoneReadTxn()no-ops on a nulled handle, but that is the invariant the whole change rests on.What the tests do not prove: that the write-only-
next-link path actually reachesabort()with a live handle in a real multi-store request. The chain shape is asserted by main's own comments and byabortDueToTimeout()'s walk, not by a test that builds it end to end — a seconddatabase:in the unit suite resolves to the same store, so nonextlink forms there.Coverage: Codex and Gemini ran on the implementation; Codex and Gemini again on the final artifact, with the Harper-domain leg on the middle revision. Findings acted on: the unguarded
doneReadTxn()abort, the object-double-only test coverage, the duplicated release blocks, the missing throwing-abort case, an overstatednumSnapshotscomment (an in-flight read legitimately holds a snapshot — the signal is a count that stays nonzero while nothing is reading), and a helper that had been inserted betweenrecordCommitLatency's doc comment and its function. Dropped after checking the code: Gemini's claim thattrackedTxns.delete(this)is skipped for read-created handles (doneReadTxn()already deletes them) and its strict-null-check concern (tsconfig.jsonsetsstrict: false).Human-Review-Need: 4 @ 3c5ead7