Repository navigation
Make a transaction's commit wait for the long-transaction monitor's force-commit instead of acking in-flight writes - #2916
Conversation
…f it The long-transaction monitor force-commits an over-limit source-apply, replay, or read-only transaction without awaiting it. That commit claims every staged write, closes the transaction, and detaches its native handle, so the owner's later commit() found nothing to do and resolved at once: transaction() acked writes whose native commit was still in flight, and if that commit then failed the monitor only logged it at debug level. For a source-apply this is the silent write loss harper-pro#348 guards against. The monitor now records the promise its commit returned on the transaction. commit() chains on it, the monitor clears it on success, and it is kept on failure so the owner rejects instead. Surfaced by main's Unit Test (Node.js v22) at 4365c0c: "names a source-apply txn that the monitor is not reaping" read back the previous test's value after its transaction() resolved. Dispatch-Task: main-red-kriszyp_harper_4365c0c5b_4d7eefda Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… commit reports it The owner's doneWriting commit rejected straight from the retained monitor failure, skipping the context release and write-set cleanup that every other terminal failure runs, and the kept failure would reject every later transaction.commit(context) too. It now aborts and consumes the failure once reported. The pre-return failure test awaits the monitor's outcome instead of a fixed delay. Dispatch-Task: main-red-kriszyp_harper_4365c0c5b_4d7eefda Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ed monitor commit abort() runs blob cleanup, and a multi-store commit can fail after its head store landed, so it could unlink files the head's audit entries still reference. The monitor's failed commit already ran its terminal cleanup; as a non-final commit it only kept the context, which is all the owner releases now. Dispatch-Task: main-red-kriszyp_harper_4365c0c5b_4d7eefda Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ws synchronously In the async commit path a synchronous throw from this.next.commit() left the success callback before the landed store cleared its writes and released its record locks. It now becomes a rejected completion, as the synchronous path already handles it, so the failure surfaces only after that bookkeeping. Dispatch-Task: main-red-kriszyp_harper_4365c0c5b_4d7eefda Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…hrow cannot leave it unhandled Dispatch-Task: main-red-kriszyp_harper_4365c0c5b_4d7eefda Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review
This pull request implements a mechanism where a transaction owner's commit waits for the monitor's force-commit, ensuring proper synchronization and cleanup during timeouts or multi-store commit failures. It introduces the monitorCommit promise to track the monitor's commit progress, handles synchronous errors when committing chained transactions to ensure bookkeeping for landed stores is completed, and updates the design documentation and test suite to cover these scenarios. No review comments were provided, and the implementation looks solid, so I have no feedback to provide.
| - **Write-bearing request transactions are aborted and poisoned** (issue #1407). The monitor calls `abortDueToTimeout()`, which sets `timedOut`, forces `open = CLOSED` (so `doneReadTxn` takes the discard path instead of re-entering `commit()` via the `LINGERING` branch — which would now throw), then `abort()`s. `addWrite`/`commit` both guard on `timedOut` and throw `transactionOpenTooLongError` (503), so the in-flight request rolls back cleanly rather than the monitor silently force-committing a partial write set (atomicity violation + orphaned secondary-index entries that only a full rebuild repairs). The old behavior `commit()`d and reused the still-open transaction. | ||
| - **`hasPendingWrites()` walks the `next` chain.** Writes to a second database live on `transaction.next` (see `txnForContext`), so a transaction that reads database A (head, tracked via its read snapshot, empty `writes`) and writes database B (`next`) is still write-bearing. Without the walk the head looks read-only and the monitor's force-commit path would cascade-commit B. `abortDueToTimeout()` poisons + aborts the whole chain. | ||
| - **Read-only, `sourceApply`, and `isReplay` transactions keep the prior force-commit behavior.** Read-only long transactions (large scans/exports) have no atomicity/index risk and must not have their ongoing reads poisoned. Canonical-source applies (replication peer / external caching source) and crash-recovery replay have no resubscribe/resume path: aborting a write would drop it while the resume cursor advances past it — a permanent divergence (harper-pro#348). `sourceApply` is propagated down the `next` chain in `txnForContext`, so gating on the head suffices. (Replay is additionally synchronous, so the async monitor can't fire mid-replay anyway.) | ||
| - **The owner's commit waits for the monitor's force-commit.** That commit claims every staged write, marks the transaction `CLOSED`, and detaches its handle, so the owner's later `commit()` finds nothing to do. The monitor therefore stores the promise it got back as `monitorCommit`: `commit()` chains on it and the monitor clears it on success. A failure is kept until the owner's final (`doneWriting`) commit rejects with it and releases the context, since the monitor itself only logs it. That release is deliberately not `abort()`: a multi-store commit can fail after its head store landed, and `abort()`'s blob cleanup would unlink files the head's audit entries still reference. A landed store always finishes its own bookkeeping (writes cleared, record locks released) before a chained store's failure surfaces, including a synchronous throw from `next.commit()`. |
There was a problem hiding this comment.
Suggestion (non-blocking): This bullet reads as a universal invariant, but the mechanism it describes (monitorCommit) is RocksDB-only — LMDBTransaction.commit()/startMonitoringTxns() overrides this path entirely and still force-commits fire-and-forget (only harperLogger.debug?.-logged on failure), so LMDB retains a narrower version of the same window. The PR body already discloses this ("the LMDB monitor has a narrower version of the same window..."), so worth making that scope explicit here too, mirroring the existing "RocksDB-write-path only" callout at DatabaseTransaction.ts:374-376.
|
Reviewed; no blockers found. Left one non-blocking suggestion on the new DESIGN.md bullet to make its RocksDB-only scope explicit, since LMDBTransaction retains the narrower pre-fix window (already disclosed in the PR body). |
A transaction that the long-transaction monitor force-commits now resolves only after that commit lands, and rejects if it failed. Before this change, a source-apply (replication / external-source) transaction held open past the limit could tell its caller that its writes were committed while the monitor's native commit was still in flight. If that commit then failed, the error was only logged at debug level.
This is what turned
mainred: Unit Test at 4365c0c5b, Node.js v22 leg,unitTests/resources/txn-tracking.test.js:457"names a source-apply txn that the monitor is not reaping". Aftertransaction({ sourceApply: true })resolved, the test read back the previous test's value (4002 !== 8). The triggering merge, ci(review-coverage): count a Review-Coverage footer only when the pre-push helper could have written it, only touches a CI script, and the failure appears on one leg only. A slow runner disk exposed a real ordering defect; the defect itself is not new.Mechanism
txn.commit()without awaiting it. That commit marks every staged write saved, setsopen = CLOSED, and detaches the native handle before submitting.transaction()callscommit({ doneWriting: true })on the same instance. That call finds no unsaved writes and no handle, so it takes the synchronous path and returns immediately.Reproduced locally by stalling the native commit for 200 ms:
transaction()resolved at about 159 ms andget()returned the value from before the transaction.For the human reviewer
better-alternative-exists, adopted. My first plan tracked any in-flight commit on the instance and cleared it on settle. The reviewer showed that loses a monitor failure that settles before the handler returns: the owner's final commit would still ack. The chosen design is the reviewer's monitor-scoped one. The monitor stores the promise its commit returned asmonitorCommit(the whole logical outcome, including chained stores). Owner commits join it atcommit()entry; retry rounds bypass the join viaoptions.transaction. The monitor clears it on success and keeps it on failure. Explicit mid-scope commits keep their current semantics.abort(). Look hardest here. Round 1 usedabort(). Round 2 (Codex) traced a partially landed multi-store commit whereabort()'s blob cleanup would unlink files that a landed head's audit entries still reference. The monitor's failed commit has already run its terminal cleanup, so the owner now only releases the context. Tradeoff: blob files of writes that never landed stay on disk instead of being deleted. That is also the behaviour onmaintoday, where the phantom success cleared writes without any cleanup.this.next.commit()in the async success path skipped the landed head'sclearWrites/releaseRecordLocks. It now becomes a rejected completion, which is how the synchronous path already handles it. It has a no-op observer, so a later bookkeeping throw cannot leave it unhandled.transaction.tshandles that, and onmainthis case gave the owner a phantom synchronous success.abort()fromonErrorwhile a monitor commit is still in flight can unlink blobs the landing record references.doneWritingcommit clears it. This is deliberate, so a checkpointing handler cannot swallow it and continue.resources/DESIGN.md.Verification
txn-tracking.test.jsgate the real RocksDB native commit once the monitor has submitted it. They cover three orderings: success (the owner stays pending until release, then reads back the new value), failure while the owner waits, and failure that settled before the handler returned (which also asserts the context is released). All three fail onorigin/mainwith the intended assertions and pass with the fix.npm run test:unit:resources(RocksDB): 3649 passing, 0 failing. The LMDB run (HARPER_STORAGE_ENGINE=lmdb) of the same suite: 2806 passing, 0 failing.npm run test:unit:main: 6288 passing, 1 failing. The failure is environmental:gitCredentials.test.jsasserts thatGIT_SSH_COMMAND,GIT_CONFIG_GLOBALandGIT_EDITORare absent, and the agent harness exports them. With those unset, that file shows 19 passing.tscbuild andcheck:design-docsare clean.transaction()→DatabaseTransaction→ RocksDB path.test:integration:allwas not run locally; CI runs it.Refs ci(review-coverage): count a Review-Coverage footer only when the pre-push helper could have written it (the merge where main went red; not the cause)
Signed: Claude Opus 5.5
🤖 Generated with Claude Code
Complexity: complicated
Origin — the dispatch brief this PR was written from
harper main is red: Unit Test at 4365c0c
mainon HarperFast/harper went from green to red. Failing workflows: Unit Test at 4365c0c.Find the merge that broke it and get main green. Walk that workflow's recent push runs on
mainoldest-to-newest to find the FIRST red head SHA and the PR it came from, then pull the failing test names from that run and from the current head. A failure on every matrix leg (Node version, Bun, uWS, Windows) is a regression; one leg is usually a flake.Then either fix it or, when the culprit is a single merge and the fix is not obvious, open a revert and say so — main being green is worth more than the change being preserved. Record the fingerprint on the
initiatives/ci-healthboard doc either way (prose row beginning with the test path, never a bare ref), and add your PR toinitiatives/testing-and-deploy> "CI & build infrastructure" as a bare-ref line so it renders as a card.Dispatch: task
main-red-kriszyp_harper_4365c0c5b_4d7eefda· queued by automation · ran by claude/opus/high · worker kzyp-xps-1Review-Coverage: authored=claude; ran=codex,gemini; adjudicated=domain; declined=cursor-grok,cursor-composer,cursor-kimi,cursor-muse; rounds=5; full=1 @ 426bafd
Human-Review-Need: 3 (decisions: owner-joins-monitor-commit, failure-kept-until-final-commit, release-only-on-final-failure, rocks-only-scope) @ 426bafd