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 8 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.
📊 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 8c7390c |
d52d451 to
095e6db
Compare
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>
095e6db to
4db2a0b
Compare
|
|
||
| DEBUG_LOG("%p TransactionHandle::onWrapperCollected Closing orphaned transaction (txnId=%u, state=%d)\n", | ||
| this, this->id, static_cast<int>(this->state)); | ||
| this->close(); |
There was a problem hiding this comment.
Medium: GC-time close() can free the handle out from under a still-running async get, because the wait it relies on is allowed to time out silently
This is a new interaction with the rebased base rather than a change in this PR's own code — the diff is byte-identical to the previously reviewed revision, but the base moved underneath it.
The safety argument for calling close() from a finalizer is that close() cancels and waits for in-flight async work before destroying the transaction. That wait does not actually guarantee it:
// src/binding/napi/async.h:162-179
void waitForAsyncWorkCompletion(std::chrono::milliseconds timeout = std::chrono::milliseconds(5000)) {
...
if (elapsed >= timeout) {
DEBUG_LOG(... "timeout waiting for async work completion, %u items remaining" ...);
return; // gives up, no signal to the caller
}close() calls it with the default and then proceeds unconditionally to this->txn->ClearSnapshot(); delete this->txn; (transaction_handle.cpp:342-344). It has already dropped the registry's strong reference via transactionRemove at the top, so when control returns here and the finalizer runs (*txnHandle).reset() (transaction.cpp:117), that is the last reference and the handle is freed.
What makes this newly reachable is the pairing of two independent changes:
- This PR makes an orphaned
TransactionHandledestroyable at all. Before it, the registry's strong reference kept the object alive forever, so a timed-outclose()left a live-but-cancelled object and the execute handler'sstate->handle->isCancelled()guard read valid memory. - The new base added worker-thread dereferences inside the same execute handler:
// transaction_handle.cpp:515-519 (base-added)
vtCheckAsyncGet(state, state->handle->dbHandle->descriptor->db.get(), ...);plus vtCheckLatest reading state->readOptions.snapshot on the worker (database.h:440 → database.h:118 readSnapshot->GetSequenceNumber()) — a raw snapshot pointer captured at queue time, which ClearSnapshot() has now released.
state->handle is a bare TransactionHandle* (AsyncGetState<TransactionHandle*>), so past the timeout even the isCancelled() guard on line 503 is itself a use-after-free.
Reachability is narrow but not exotic: the async-work registration is claimed at queue time, so the 5 s budget includes libuv queueing delay (default UV_THREADPOOL_SIZE=4). It also needs a directly-constructed Transaction rather than db.transaction(), whose async-function context pins the wrapper for the whole body — but Transaction is public API (src/index.ts:56) and this PR's own test constructs one that way.
The invariant worth enforcing rather than working around is in waitForAsyncWorkCompletion itself: a wait that is permitted to return without the condition holding is not a wait. Having it report the timeout, and having onWrapperCollected() re-arm/defer instead of destroying when it did not drain, fixes this at the root. Note the same pattern is relied on by DBHandle::close() (db_handle.cpp:132-135), so the fix likely belongs in async.h rather than in this PR alone — reasonable to split it out if you'd rather not grow a leak fix.
—
Generated by Barber AI
There was a problem hiding this comment.
Agreed — close() from the finalizer is not safe while an async get still holds the handle, because waitForAsyncWorkCompletion can time out and then delete this->txn under the worker.
Stacked fix in #789:
onWrapperCollectedstill setswrapperCollectedand still defers ifCommitting.- For a pending txn it cancels in-flight work, and does not
close()whileactiveAsyncWorkCount > 0. AsyncGetStatenow pins the handle with ashared_ptr(same shape as asyncDatabase::Get).- The execute handler sees cancelled/collected and rejects with
Transaction is closedinstead of callingtxn->Get. - The complete callback
close()s once execute has finished, so the snapshot is still released (harper#2107).
That way a get that races GC throws rather than UAFing. In-flight commit is unchanged (completeCommitWork still owns close).
Co-Authored-By: GPT-5 Codex <noreply@openai.com>
| * from multiple threads concurrently (e.g. DBDescriptor::close() on env M's | ||
| * JS thread racing the async commit's complete callback on env W's JS thread). | ||
| */ | ||
| /** |
There was a problem hiding this comment.
This method seems to have been inserted between the description of the TransactionHandle::close() method and the actual TransactionHandle::close() code. Please move TransactionHandle::close()'s docblock next to it's implementation.
|
|
||
| DEBUG_LOG("%p TransactionHandle::onWrapperCollected Closing orphaned transaction (txnId=%u, state=%d)\n", | ||
| this, this->id, static_cast<int>(this->state)); | ||
| this->close(); |
There was a problem hiding this comment.
Agreed — close() from the finalizer is not safe while an async get still holds the handle, because waitForAsyncWorkCompletion can time out and then delete this->txn under the worker.
Stacked fix in #789:
onWrapperCollectedstill setswrapperCollectedand still defers ifCommitting.- For a pending txn it cancels in-flight work, and does not
close()whileactiveAsyncWorkCount > 0. AsyncGetStatenow pins the handle with ashared_ptr(same shape as asyncDatabase::Get).- The execute handler sees cancelled/collected and rejects with
Transaction is closedinstead of callingtxn->Get. - The complete callback
close()s once execute has finished, so the snapshot is still released (harper#2107).
That way a get that races GC throws rather than UAFing. In-flight commit is unchanged (completeCommitWork still owns close).
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).Review-Coverage: authored=unknown; ran=none; rounds=1 @ 4db2a0b
Human-Review-Need: 4 @ 4db2a0b