Don't poison a transaction parked in its commit phase (it destroyed the deploy payload blob) - #2086
Don't poison a transaction parked in its commit phase (it destroyed the deploy payload blob)#2086kriszyp wants to merge 6 commits into
Conversation
The long-transaction limit exists to stop an APPLICATION from holding a transaction open with an unfinished write set (#1407). Once commit() has been entered the write set is sealed and the caller is awaiting the commit, so the time spent in the pre-commit phase — `before`/`beforeIntermediate` hooks, in practice a blob's durable file write — is core's own I/O, and a multi-tens-of-MB deploy payload legitimately outruns the limit there. Poisoning it there did real damage: abort() cleared the write set and unlinked the write's pre-saved blobs, and the in-flight commit then resumed, found no writes, and resolved as SUCCESS. The caller was told its write landed when it had been dropped, and was left holding a blob whose file was gone but whose fileId was still set — so its next put (the deploy recorder re-puts the same record object) silently minted a reference to a destroyed file. In the field that was a deploy payload blob, and the peer's install failed permanently with `Blob file not found` (#2062). - the monitor leaves a committing transaction alone, logging instead of aborting - commit() re-checks the poison flag after the pre-commit await, so a transaction poisoned through the multi-store chain throws rather than resolving as a phantom success - a blob whose file has been unlinked is marked discarded, and saveBlob refuses to re-store it instead of minting another reference to a file that is gone Fixes #2062 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
From cross-model review of the previous commit: - the exemption was unbounded, so a pre-commit source that stalls rather than finishing could pin its read snapshot forever; the monitor now spares a committing transaction for COMMIT_PHASE_GRACE over-limit ticks (~5 min at the 30s default) and then aborts it like any other over-time transaction - the resume guard only caught the monitor's poison; a plain abort() in the same window still resolved as a phantom success. Detect a write set cleared and a handle released underneath us and throw - don't flip the committing marker on the (synchronous) no-pre-commit-work path, and initialize it rather than adding it dynamically - LMDB's monitor warn now carries startedFrom like the RocksDB one; trimmed the comments to the invariants Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…n LMDB From round 2 of cross-model review: - a canonical-source apply or replay that exhausted the grace fell through to the monitor's force-commit branch, which durably commits the record while its blob file is still being written — a replica row referencing an incomplete blob, the exact outcome this issue is about. It predates this change (such a transaction hit that branch on every tick before the grace existed). Those transactions may be neither aborted (harper-pro#348) nor force-committed mid-write, so they are spared for as long as the write takes; their sources are bounded by the receive-side idle watchdog instead. - LMDB's before-phase resume now carries the same abort/poison guard as the RocksDB path, and resets its grace counter per commit phase - the grace comment claimed ~5 minutes; sparing re-arms the timeout, so each spared tick costs two monitor intervals — ~10 minutes at the 30s default Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
From round 3 of cross-model review: - cleanupUnusedBlobs waits for an in-flight save to settle before unlinking, so the instance was only tombstoned once that landed. A re-store in that window minted a reference to a file already condemned. Mark it when the deletion is decided instead. - the new tests waited on the head of the context's transaction chain, which on LMDB never owns the table's writes (txnForContext claims an unclaimed head only for RocksDB) — three of them failed under test:unit:lmdb. They now walk the chain for the link that is actually committing, and pass on both engines. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reading order1. 2. 3. 4. Decisions a reviewer should questionExempting the commit phase at all. #1411 made over-time write transactions abort rather than force-commit, and this walks part of that back. My argument: the limit's premise is "the application is holding a transaction open with an unfinished write set" — once Why the grace is bounded, and why Why the blob tombstone exists at all, given 1 and 2. With the monitor no longer poisoning a committing transaction and the resume guard catching the rest, the destroyed-blob path should be unreachable. The tombstone is there because "should be unreachable" is exactly what the pre-#2062 code believed. It converts any residual path from a silently-committed reference to a destroyed file into a loud error naming the cause. It is deliberately set when the deletion is decided, not when the unlink is issued — What the tests do and do not proveThey prove: the write commits and its blob is readable when the save outruns the limit (both engines, plus end to end over REST); a poisoned or plain-aborted parked transaction throws instead of phantom-committing; a source apply is neither poisoned nor force-committed; the grace is bounded; a discarded blob cannot be re-stored. They do not prove: anything through the real CoverageFour rounds of cross-model review (codex graded leg + gemini + Harper-domain adjudication). Rounds 1–3 each found something real and each is a commit in this branch: the unbounded exemption, the plain-abort phantom commit, the — KrAIs (Claude Opus 5) |
There was a problem hiding this comment.
Code Review
This pull request addresses issue #2062, where transactions parked in their commit phase (such as during a large blob write) were incorrectly poisoned by the long-transaction monitor for exceeding the open-transaction limit. The changes introduce a bounded grace period (COMMIT_PHASE_GRACE) for transactions in the commit phase, add checks to prevent phantom commits if a transaction is aborted while waiting on pre-commit work, and tombstone discarded blobs to prevent silent references to deleted files. The review feedback recommends replacing fixed delays in the new unit tests with condition-based waitFor polling to prevent test flakiness in resource-constrained CI environments.
| await BlobResource.put({ id: 2062, blob }, context); | ||
| }); | ||
| slow.write(Buffer.alloc(16384, 'a')); | ||
| await delay(150); // the monitor fires repeatedly while the commit waits on the blob write |
There was a problem hiding this comment.
Avoid using fixed sleeps or real-time delays (like delay(150)) in tests to prevent flakiness in slow or resource-constrained CI environments. Instead, use a condition-wait helper like waitFor to poll for the expected state (e.g., waiting for the transaction's commitPhaseTicks to increment).
| await delay(150); // the monitor fires repeatedly while the commit waits on the blob write | |
| await waitFor(() => committingTxn(context)?.commitPhaseTicks > 2, { | |
| message: 'should wait for monitor to tick several times', | |
| }); |
References
- Prefer using condition-waits (e.g., a helper like
waitForthat polls for a condition) instead of fixed sleeps or real-time delays (likesetTimeout) in tests to avoid flakiness caused by coarse clock resolutions or slow environments.
| await BlobResource.put({ id: 2066, blob }, context); | ||
| }); | ||
| slow.write(Buffer.alloc(16384, 'f')); | ||
| await delay(600); // many ticks, well past COMMIT_PHASE_GRACE |
There was a problem hiding this comment.
Avoid using fixed sleeps or real-time delays (like delay(600)) in tests to prevent flakiness in slow or resource-constrained CI environments. Instead, use a condition-wait helper like waitFor to poll for the expected state (e.g., waiting for the transaction's commitPhaseTicks to exceed COMMIT_PHASE_GRACE).
| await delay(600); // many ticks, well past COMMIT_PHASE_GRACE | |
| await waitFor(() => committingTxn(context)?.commitPhaseTicks > COMMIT_PHASE_GRACE, { | |
| message: 'should wait until commitPhaseTicks exceeds COMMIT_PHASE_GRACE', | |
| }); |
References
- Prefer using condition-waits (e.g., a helper like
waitForthat polls for a condition) instead of fixed sleeps or real-time delays (likesetTimeout) in tests to avoid flakiness caused by coarse clock resolutions or slow environments.
|
Reviewed; no blockers found. |
|
CI status: 37 pass, 4 fail — all four failures are pre-existing on
This PR's own coverage is green: all three Staying in draft until the pre-existing — KrAIs (Claude Opus 5) |
The QA-782 contrast test addressed a table-scoped transaction-log purge on both storage engines. RocksDB deliberately rejects that form because its transaction logs are database-wide, while LMDB needs the table to purge history.\n\nUse a database-scoped request on RocksDB and preserve the table-scoped LMDB request so the contrast arm reaches the intended purge on both engines.\n\nCo-Authored-By: GPT-5 Codex <noreply@openai.com>
Fixes #2062.
What was wrong
A blob is saved in the commit's pre-commit phase, so a write carrying one stays inside
commit()until the file has landed. For a multi-tens-of-MB deploy payload that is well paststorage.maxTransactionOpenTime, and the long-transaction monitor poisoned the transaction right there.That was worse than the issue's original diagnosis.
abort()cleared the write set and unlinked the write's pre-saved blobs — and then the in-flight commit resumed, found nothing left to write, and resolved as success. So:ingestPayload's cleanup path to catch), andfileIdwas still set.saveBlobshort-circuits on a setfileId, so the deploy recorder's nextputof the same record object silently minted a reference to a destroyed file — surfacing at the peer asBlob file not found, unrecoverably.Reproduced end to end in
integrationTests/database/blob-commit-over-time.test.ts: onmainthe HTTP write returns 200 and the record is a 404.The fix
The open-transaction limit polices an application holding a transaction open with an unfinished write set (#1407). Once
commit()is entered the write set is sealed and the caller is awaiting the commit, so the time spent in the pre-commit phase is core's own I/O — not the thing the limit exists to stop.COMMIT_PHASE_GRACEover-limit ticks (~10 min at the 30s default, since sparing re-armstimeout), because the transaction still pins a read snapshot — a source that stalls rather than finishing falls through to the normal abort.sourceApply/isReplayare spared without a bound: they may be neither aborted (harper-pro#348) nor force-committed mid-write, and their blob sources are bounded by the receive-side idle watchdog.timedOut(monitor poison, including via the multi-store chain) or a write set cleared with the handle released (a plainabort()). Either throws instead of resolving as a phantom commit.storageInfo.discarded), set when the deletion is decided rather than when the unlink is issued, andsaveBlobrefuses to re-store it. Any residual path that would have minted a second reference to a destroyed file now fails loudly, with the cause, instead of at some later read.LMDB carries the same three changes.
Also fixed along the way
sourceApply/isReplaytransactions parked in the pre-commit phase were previously force-committed on every monitor tick — durably committing a replica record while its blob file was still being written. That predates this change (it's the monitor'selsebranch); the new unit test fails onmainfor exactly that reason.Verification
unitTests/resources/txn-tracking.test.js— five cases: the commit completes and the blob survives; poisoned-while-parked throws; plain-abort-while-parked throws; a source apply is neither poisoned nor force-committed; the grace is bounded. Pass on both engines (test:unit:resourcesandtest:unit:lmdb).unitTests/resources/blob.test.js— a discarded blob cannot be silently re-stored.integrationTests/database/blob-commit-over-time.test.ts— end to end over REST with the limit lowered and the blob source trickled; asserts the record commits, the blob reads back complete, no abort line, and the "in its commit phase past the open-transaction limit" line (so the test cannot pass vacuously). Fails onmain.npm run test:unit:resources(1494 passing), the deploy integration suite (59/59), and the two existing Force-committing an over-time transaction leaves orphaned secondary-index entries (atomicity violation) #1407/Abort over-time write transactions instead of force-committing (#1407) #1411 anchors —longtxn-secondary-index(QA-176) andaborted-update-index-migration(QA-616) — all pass, i.e. the abort guarantee for application-held transactions is unchanged.test:unit:maincan't run on this machine (the local Harper install holds~/harper/database/system/LOCK); it's untouched by this change and covered by CI.Where to look hardest
COMMIT_PHASE_GRACE = 10is a judgment call. Too low and a genuinely huge payload on a slow link gets aborted (cleanly now, but aborted); too high and a stalled pre-commit source pins a read snapshot for longer. The unbounded sparing ofsourceApplyrests on the replication receive path armingblobStreamIdleTimeoutMs— worth a second opinion.DatabaseTransaction.tsresume guard (stagedWrites > 0 && this.writes.length === 0 && !this.transaction). It must not false-positive on the retry / immediate-commit / replay paths. I could not find a caller that plain-abort()s a transaction already parked in commit, so that half is a latent trap being closed rather than a live bug — the test drives it directly.Open / not addressed
deploy_componentpath: the deploymentputonly registers with the monitor when a read precedes the write in the same transaction (getReadTxnearly-returns once a write created the handle), which the clustered field case did and a single-node harness does not. The integration test drives the same mechanism through a component resource instead.committing,commitPhaseTicks) — deliberate; adding them dynamically would cost a hidden-class transition on the commit path.Generated with Claude Opus 5. Cross-model review: codex + gemini + Harper-domain adjudication, four rounds — round 4 verdict
COMMENTS, no majors outstanding.Human-Review-Need: 4 @ 22e34ad
🤖 Generated with Claude Code