Keep reads working when storage.debugLongTransactions is enabled - #2225
Open
kriszyp wants to merge 2 commits into
Open
Keep reads working when storage.debugLongTransactions is enabled#2225kriszyp wants to merge 2 commits into
kriszyp wants to merge 2 commits into
Conversation
useReadTxn() pushed a stack trace onto this.stackTraces whenever the flag was on, but only getReadTxn()'s fresh-handle branch ever created that array. Every other route into useReadTxn() — a handle adopted by save(), a transaction past OPEN, or ImmediateTransaction, whose getReadTxn() never returns one — left it undefined, so the push threw "Cannot read properties of undefined (reading 'push')" and the search 500'd. Enabling the diagnostic broke the reads it was meant to diagnose. Trace only when there is a real handle to trace: without one the transaction is never tracked, so the long-transaction monitor has nothing to dump the traces for, and an ImmediateTransaction would accumulate them for its lifetime. Fixes #2222 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Contributor
There was a problem hiding this comment.
Code Review
This pull request resolves an issue where useReadTxn() could throw when storage.debugLongTransactions is enabled, specifically for transactions whose read handles did not originate from getReadTxn()'s fresh-handle branch (such as adopted handles or closed transactions). The review feedback identifies a performance optimization on the read hot-path: since untracked transactions are never inspected by the long-transaction monitor, we should avoid capturing expensive stack traces for them entirely by guarding with this.stackTraces instead of initializing it with ??=. Corresponding test assertions should also be updated to reflect this optimization.
Contributor
|
Reviewed; no blockers found. |
Guard on stackTraces rather than on the returned handle. The array is seeded by the same getReadTxn branch that registers the transaction with trackedTxns, so its presence is what says the monitor can dump the traces; anything else is untracked and was paying an Error capture per read that nothing would ever read. When save()'s adopt branch is taught to seed the bookkeeping it skips today (#2224), tracing resumes for those transactions on its own. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
kriszyp
marked this pull request as ready for review
August 19, 2026 18:54
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.
Enabling
storage.debugLongTransactionsno longer breaks reads: with the flag on, anysearch()whose transaction did not open its own read handle threwTypeError: Cannot read properties of undefined (reading 'push')and returned a 500 (#2222).useReadTxn()recorded a stack trace unconditionally, but onlygetReadTxn()'s fresh-handle branch ever created the array it pushed onto — so a handle adopted bysave(), a transaction pastOPEN, or anImmediateTransaction(whosegetReadTxn()never returns a handle) crashed the read. The diagnostic broke exactly the reads an operator turns it on to diagnose.Unresolved from review: the cross-model legs and I could not reproduce the reported 500 through an HTTP request — see Verification — so this is pinned at the unit level, not end-to-end. The adjudicator also surfaced a pre-existing, flag-independent gap in the same area (
save()'s adopt branch seeds none of the per-handle bookkeepinggetReadTxn()establishes, soreadTxnsUsedgoesundefined++→NaN); filed separately as #2224 rather than folded in here, because seeding it changes refcount and long-transaction-monitor semantics.For the human reviewer
fix-layer-read-site-vs-adopt-site). The broken invariant is thatgetReadTxn()'s fresh branch establishes three things together —readTxnsUsed = 1,trackedTxns.add(this),stackTraces = [...]— andsave()'s adopt branch establishes none. Seeding all three insave()would fix this crash and theNaNrefcount at once, but it also puts write-first transactions under the long-transaction monitor for the first time (they'd become abortable per Force-committing an over-time transaction leaves orphaned secondary-index entries (atomicity violation) #1407) and changes whatdoneReadTxn()'s refcount means. That is a semantic change I'm not willing to make as a drive-by on a crash fix, so this PR guards the read site and save() adopts a read handle without the per-handle bookkeeping getReadTxn() establishes (readTxnsUsed becomes NaN) #2224 carries the root fix. Say the word if you'd rather have them together.traces-only-with-a-handle). The guard is onthis.stackTraces, not on the returned handle: the array is seeded by the samegetReadTxn()branch that doestrackedTxns.add(this), so its presence marks a transaction whose traces the monitor can actually dump. Untracked reads (adopted handle,ImmediateTransaction, closed transaction) capture nothing rather than paying anErrorper read that nothing reads. I originally guarded on the handle so adopted handles would keep collecting traces in case they later became tracked; #2224 would seed all three pieces of bookkeeping together, so tracing resumes on its own and the extra collection buys nothing. Changed in response to thegemini-code-assistreview — thread resolved.unit-only-scope,require-cache-eviction-as-the-test-seam). Four cases: three that crash onorigin/mainwith the reportedTypeError, plus a positive control proving a monitor-registered transaction is still traced. The test flips the load-timeDEBUG_LONG_TXNSconst by re-requiring the module with its own cache record and restoring the shared one, rather than exporting a new test seam next tosetTxnExpiration. A seam is API once exported; the trade is one idleunref()ed monitor timer per run over an empty set. If you'd rather have the seam, it's a small change.Verification
unitTests/resources/debugLongTransactionsReads.test.jspins the three crash shapes plus a positive control, and the three crash cases fail onorigin/mainsources (rebuiltdist, not incremental) with the exact reported error and frame:TypeError: Cannot read properties of undefined (reading 'push')atDatabaseTransaction.useReadTxn (resources/DatabaseTransaction.ts:421:41)— includingImmediateTransaction.useReadTxn, the frame in the issue report. 3 failing + 1 passing control before, 4 passing after.storage.debugLongTransactions: trueand drove 12 request shapes against it (REST collection query, REST GET withselect, relationship serialization in both directions, staticsearch()with and without a context, write-then-search in one request transaction, cross-table write-then-search, SQLSELECT ... WHERE,search_by_value) — all returned 200. Every one of those opens its read handle throughgetReadTxn()'s fresh branch, which seedsstackTraces. ReachinguseReadTxn()with an unseeded array needs the context's transaction reference to be released betweentransactional()'sopen === OPENcheck andtxnForContext(), which I could not force deterministically from a fixture. The reporter's stack frame (ImmediateTransaction.useReadTxn) is reproduced directly in the unit test instead.npm run test:unit:resources— 1587 passing, 0 failing.npm run test:integrationon the three suites that run withdebugLongTransactions: true(longtxn-secondary-index,eviction-secondary-index,txn-overtime-atomicity) — 7 pass, 0 fail.npm run test:unit:maincould not run on this machine: a stale mocha process (17 days old) holds~/harper/database/system/LOCK, so the suite dies at module load before any test. Unrelated to this change; CI runs the gate.npm run typecheck,npm run lint:required,prettier --checkall clean.Complexity: easy
Review-Coverage: authored=claude; ran=codex,gemini; declined=cursor-grok,cursor-composer,domain; rounds=4 @ 2bf62cd
Human-Review-Need: 3 @ 2bf62cd