Release a transaction dropped without commit or abort, instead of leaking its read snapshot for the life of the process - #768
Release a transaction dropped without commit or abort, instead of leaking its read snapshot for the life of the process#768kriszyp wants to merge 7 commits into
Conversation
There was a problem hiding this comment.
Code Review
This pull request addresses a critical issue where orphaned transactions (dropped without being committed or aborted) pinned RocksDB snapshots and prevented the reclamation of obsolete versions. It introduces a mechanism to release these transactions when their JS wrappers are garbage collected, exposes a new rocksdb.num-snapshots statistic, and adds transaction details to the registry status. The review feedback highlights a thread-safety concern regarding concurrent access to TransactionHandle::state across threads, suggesting making it atomic to prevent data races. Additionally, it recommends using process.versions.bun for Bun detection in tests to maintain consistency with repository conventions.
| const forceGC: () => void = | ||
| typeof globalThis.gc === 'function' | ||
| ? globalThis.gc | ||
| : typeof (globalThis as { Bun?: { gc?: (sync: boolean) => void } }).Bun?.gc === 'function' | ||
| ? () => (globalThis as unknown as { Bun: { gc: (sync: boolean) => void } }).Bun.gc(true) | ||
| : () => { | ||
| throw new Error('these tests need an exposed GC (node/deno --expose-gc, or Bun.gc)'); | ||
| }; |
There was a problem hiding this comment.
Style Guide Adherence: Bun Detection Convention
According to the repository's general rules, we should prefer accessing process.versions.bun directly to check for Bun's presence in tests to maintain consistency with the repository's established conventions.
| const forceGC: () => void = | |
| typeof globalThis.gc === 'function' | |
| ? globalThis.gc | |
| : typeof (globalThis as { Bun?: { gc?: (sync: boolean) => void } }).Bun?.gc === 'function' | |
| ? () => (globalThis as unknown as { Bun: { gc: (sync: boolean) => void } }).Bun.gc(true) | |
| : () => { | |
| throw new Error('these tests need an exposed GC (node/deno --expose-gc, or Bun.gc)'); | |
| }; | |
| const forceGC: () => void = | |
| typeof globalThis.gc === 'function' | |
| ? globalThis.gc | |
| : process.versions.bun | |
| ? () => (globalThis as unknown as { Bun: { gc: (sync: boolean) => void } }).Bun.gc(true) | |
| : () => { | |
| throw new Error('these tests need an exposed GC (node/deno --expose-gc, or Bun.gc)'); | |
| }; |
References
- Prefer accessing
process.versions.bundirectly to check for Bun's presence in tests to maintain consistency with the repository's established conventions, as long as the repository's tsconfig configuration allows it without compilation errors.
📊 Benchmark Resultsget-sync.bench.tsgetSync() > random keys - small key size (100 records)
getSync() > sequential keys - small key size (100 records)
ranges.bench.tsgetRange() > small range (100 records, 50 range)
realistic-load.bench.tsRealistic write load with workers > write variable records with transaction log
transaction-log.bench.tsTransaction log > read 100 iterators while write log with 100 byte records
Transaction log > read one entry from random position from log with 1000 100 byte records
worker-put-sync.bench.tsputSync() > random keys - small key size (100 records, 10 workers)
worker-transaction-log.bench.tsTransaction log with workers > write log with 100 byte records
Results from commit 85444c0 |
The descriptor's transaction registry holds a strong shared_ptr, so the JS wrapper's finalizer resetting its own reference could never destroy the handle — and `TransactionHandle::close()`, the only `ClearSnapshot()` path, was therefore unreachable for any transaction dropped without `commit()`/`abort()`. The orphaned handle kept its read snapshot for the life of the process, so RocksDB could not discard obsolete versions for that database: on a high-churn secondary index that reached ~4 keys per live row in 5 days and degraded bounded range scans ~21x, with restart the only recovery (HarperFast/harper#2107). The finalizer now calls `onWrapperCollected()` first: once V8 has collected the wrapper no JS code can commit, abort, retry, or read through the handle again, so it is closed. A commit in flight (`state == Committing`) is the exception — the commit state owns the handle and closing there would cancel it mid-flight — so the commit-completion paths close it instead: success already closed unconditionally, and the failure paths, which deliberately leave the handle open for a caller that may retry, now check `wrapperCollected` because there is no caller left. Making the registry reference weak was the other candidate and is not safe here: an async `get` holds a raw `TransactionHandle*` and relies on `close()` cancelling and waiting for in-flight work, which a destructor triggered by the last shared_ptr drop would race. Also surface the leak: `registryStatus()` gains `transactionDetails` (id/snapshotSet/state/ageMs per live handle), since a bare count cannot distinguish a request in flight from a database that can never reclaim again. Its read of the transactions map now takes `txnsMutex`, which it was missing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The only per-database snapshot signal was `rocksdb.oldest-snapshot-time`, which reports a timestamp but not how many snapshots are held — and once the oldest is pinned it never moves, so further accrual is invisible. A nonzero count means that database cannot discard obsolete versions behind its oldest snapshot for as long as it stays nonzero, which is the condition worth alerting on (HarperFast/harper#2107). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Review follow-up. transactionDetails reads snapshotSet and state from whichever environment calls registryStatus(), but txnsMutex only covers the registry map's membership — the read paths that set snapshotSet and the commit-completion callback that sets state hold no lock at all, so those were plain cross-thread reads of non-atomic fields. Both are now std::atomic and the diagnostic reads them relaxed. Also from review: - The GC helper used only globalThis.gc, which Bun leaves undefined (it exposes Bun.gc), so `pnpm test:bun` would have collected nothing and timed out on every orphan case. - The mid-commit test could not do what its name claimed: a pending commit promise's executor still holds the wrapper, so V8 cannot collect it while the commit is in flight. Renamed to the property it actually proves — dropping the reference before the commit settles neither loses the write nor leaks the handle — and recorded why the Committing deferral is unreachable from JS. - Added the orphan-that-never-read case, which closes with snapshotSet false and so takes a different teardown path than every other case. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reverts the atomic conversion from the previous commit and drops snapshotSet and state from the diagnostic instead. Making the fields atomic was the wrong trade for three reasons review surfaced: the debug build stopped compiling (DEBUG_LOG is variadic, and std::atomic has no copy constructor), the implicit operators default to seq_cst so every state and snapshot check on the read and write paths grew a full barrier for the sake of an occasional diagnostic read, and it still would not have made the cross-thread `if (state == Committing) state = Pending` transitions atomic — that hazard is pre-existing and belongs in its own change, not smuggled into a leak fix. transactionDetails now reports id and ageMs, both fixed before the handle is published to the registry and therefore safe to read from any environment under txnsMutex. Together with rocksdb.num-snapshots — a nonzero count says the database cannot reclaim — an age beyond any plausible request lifetime is what actually identifies the orphan, which is what the field was for. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`--v8-flags=--expose-gc` applies only to the `deno run` process it is passed to. Vitest uses the `forks` pool on Deno, so the child processes that actually run the tests had no `globalThis.gc`: every `skipIf(!globalThis.gc)` test silently skipped there, and the new transaction-orphan-gc tests — which throw rather than skip — failed the Deno job on all three platforms. `DENO_V8_FLAGS` is read from the environment, so the forked workers inherit it. Full Deno suite locally: 748 passed, 5 skipped (was 739 passed, 9 skipped, 5 failed), so this also restores four GC tests that had been dead on Deno. Co-Authored-By: Claude Opus <noreply@anthropic.com>
Reverts the DENO_V8_FLAGS change from ea8944e. Exposing GC to Deno's Vitest workers is correct (#770) but not landable here: with GC actually available, test/lock.test.ts fails on Deno (#771 — a pending withLock dispatch does not keep the event loop alive, so the callback is delivered late or not at all) and one macOS verification-table case fails. Both are pre-existing on main and neither belongs in a transaction-leak fix. So these tests now guard with skipIf, like every other GC-dependent test in this suite, instead of throwing. They run on Node and Bun and skip on Deno. Node: 5/5 pass. Deno 2.8.3 with the CI command: 5 skipped, lock.test.ts green. Refs #770, #771 Co-Authored-By: Claude Opus <noreply@anthropic.com>
…umber Appending a second numbered invariant after the multi-paragraph #11 makes the list loose, so oxfmt requires a blank line between the items. Co-Authored-By: Claude Opus <noreply@anthropic.com>
d52d451 to
095e6db
Compare
A transaction dropped without
commit()orabort()used to live for the life of the process, holding a RocksDB read snapshot that stopped the database from ever discarding obsolete row versions. It now releases itself when V8 collects its JS wrapper.Reported as HarperFast/harper#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.The defect
DBDescriptor::transactionAddstores a strongshared_ptrin thetransactionsmap, while theclosablesentry written on the very next line is aweak_ptr.TransactionHandle::close()is the only path that callsClearSnapshot(), and it is reached from~TransactionHandle()— but the destructor can never run while the registry holds its reference. The napi finalizer only did(*txnHandle).reset(), which drops the JS-side reference and nothing else. So the destructor was correct and unreachable, and any caller that dropped a transaction leaked it permanently and silently.The finalizer now calls
onWrapperCollected()first. Once V8 has collected the wrapper, no JS code can commit, abort, retry, or read through that handle again, so it is closed. A commit in flight is the one exception —TransactionCommitStatestill owns the handle, and closing there would cancel the commit mid-flight — so the commit-completion paths close it instead: success already closed unconditionally, and the failure paths, which deliberately reset toPendingso a caller can retry, now checkwrapperCollectedbecause there is no caller left.Why not make the registry reference
weak_ptr. That was the first suggestion in the issue and it is not safe here: an asyncgetholds a rawTransactionHandle*(AsyncGetState<TransactionHandle*>) and depends onclose()runningcancelAllAsyncWork()/waitForAsyncWorkCompletion()before the transaction is destroyed. Letting the lastshared_ptrdrop destroy the handle would race that. Going throughclose()from the finalizer gets the same self-healing with that machinery intact.Observability
registryStatus()now reportstransactionDetails—idandageMsper live handle — androcksdb.num-snapshotsis exposed as a curated stat. A bare transaction count cannot distinguish a request in flight from a database that can never reclaim again; a nonzero snapshot count plus a handle age beyond any plausible request lifetime can. TheregistryStatus()read of the transactions map also now takestxnsMutex, which it was missing.transactionDetailsdeliberately does not reportsnapshotSetorstate. See the decision below.Verification
pnpm test— 752 passing, 1 skipped, on Node.pnpm checkclean.pnpm build:binding:debugcompiles.New
test/transaction-orphan-gc.test.tsdrives the real native stack (no mocks): a dropped transaction, a dropped transaction after a rejected commit, a still-referenced transaction (control — must not be disturbed), a caller that drops its reference before the commit settles, and an orphan that never read. The first two fail onmain; the controls pass on both, so they are not vacuous.The reproducer behind the issue, against
mainbefore this change:Not fixed here
if (state == Committing) state = Pendingin the commit-completion paths is a cross-thread compare-then-store that can overwrite anAbortedset concurrently byclose(). Review surfaced it; it is pre-existing, independent of this change, and wants its own fix rather than being folded into a leak fix. Filed as #769.For the human reviewer
Finalizer-driven
close()rather than a weak registry reference. Chosen because an asyncgetholds a rawTransactionHandle*and relies onclose()'s cancel-and-wait; a destructor triggered by the lastshared_ptrdrop would skip it. The alternative — weak registry ref plus changingtransactionRemoveto take a raw pointer (close()currently callsshared_from_this(), which would throwbad_weak_ptrfrom a destructor) — is a larger lifetime change that also brushes against the open Worker-env teardown destroys transactions on the shared DBDescriptor, corrupting the heap under concurrent commits #741 and Pessimistic transactions still poison the environment on a drop-race commit #726. Reversible, but it would be a rewrite of this approach rather than a tweak. Say no if you'd rather take the lifetime change properly.transactionDetailsreports onlyidandageMs. An earlier revision reportedsnapshotSetandstateand made bothstd::atomicto do it safely. Review showed that was the wrong trade: it broke the debug build (DEBUG_LOGis variadic,std::atomichas no copy constructor), the implicit operators default toseq_cstso every state and snapshot check on the read/write paths grew a full barrier for an occasional diagnostic read, and it still didn't make the compare-then-store transitions atomic.idandcreatedAtare fixed before the handle is published to the registry, so they are race-free by construction. Cost: you cannot see per-handle "is this one holding a snapshot" —rocksdb.num-snapshotsanswers that per database instead. Cheap to revisit if the per-handle flag turns out to matter operationally.The
Committingdeferral branch is unreachable from JS. A pending commit promise's executor still holds the wrapper, so V8 cannot collect it while the commit is in flight. The branch is kept as a correctness guard for any future path that can drop the wrapper mid-commit (a native-side caller, or a JS commit shape that doesn't retain), and the test that used to claim to exercise it has been renamed to what it actually proves. Alternative is deleting the branch and asserting the invariant instead; kept because the cost is three lines and the failure mode it guards is a cancelled in-flight commit.Where to look hardest:
onWrapperCollected()insrc/binding/transaction/transaction_handle.cppand the twowrapperCollectedchecks insrc/binding/transaction/transaction.cpp. The question worth the most scrutiny is whetherstate == Committingis a sufficient test for "a commit still owns this handle" on every path that can reach the finalizer.What the tests do not prove: cross-worker registry safety under concurrent envs, and finalization during an active commit (unreachable, above). The
pnpm test:bunandpnpm test:denoroutes are handled by the GC adapter in the new test file but were not run here.Coverage: Codex and Gemini both ran on the final artifact; the Harper-domain leg ran on the previous revision. Findings acted on: the cross-environment field race, the debug-build break, the seq_cst hot-path regression, the Bun GC adapter, the overclaiming mid-commit test, and the missing never-read orphan case. Findings dropped after checking the code: Gemini's null-dereference on
state->handle(the enclosing condition already requires a non-null handle) and the extrasteady_clockread per transaction (a vDSO read next toBeginTransaction(), andsteady_clockis the correct source for an age that must survive wall-clock adjustments).Human-Review-Need: 4 @ 095e6db