Skip to content

Make a transaction's commit wait for the long-transaction monitor's force-commit instead of acking in-flight writes - #2916

Merged
kriszyp merged 5 commits into
mainfrom
fix/commit-waits-for-monitor-force-commit
Sep 29, 2026
Merged

kriszyp merged 5 commits into
mainfrom
fix/commit-waits-for-monitor-force-commit

Conversation

@kriszyp

@kriszyp kriszyp commented Sep 29, 2026 •

Copy link
Copy Markdown
Member

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 main red: 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". After transaction({ 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

  1. The monitor's force-commit branch calls txn.commit() without awaiting it. That commit marks every staged write saved, sets open = CLOSED, and detaches the native handle before submitting.
  2. The handler then returns and transaction() calls commit({ 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 and get() returned the value from before the transaction.

For the human reviewer

  • Planning verdict: 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 as monitorCommit (the whole logical outcome, including chained stores). Owner commits join it at commit() entry; retry rounds bypass the join via options.transaction. The monitor clears it on success and keeps it on failure. Explicit mid-scope commits keep their current semantics.
  • Failure cleanup is release-only, not abort(). Look hardest here. Round 1 used abort(). Round 2 (Codex) traced a partially landed multi-store commit where abort()'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 on main today, where the phantom success cleared writes without any cleanup.
  • Fix to adjacent code that already existed: a synchronous throw from this.next.commit() in the async success path skipped the landed head's clearWrites / 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.
  • Declined: Gemini's point that a synchronous throw inside the monitor's commit now reaches the owner as a rejection instead of a synchronous throw. Write-bearing commits already return promises, transaction.ts handles that, and on main this case gave the owner a phantom synchronous success.
  • Not in this PR (pre-existing, recorded as findings): the LMDB monitor has a narrower version of the same window, limited to multi-store chains and replication confirmation. Separately, abort() from onError while a monitor commit is still in flight can unlink blobs the landing record references.
  • A failed monitor commit is replayed to every mid-scope commit in the scope, and only the final doneWriting commit clears it. This is deliberate, so a checkpointing handler cannot swallow it and continue.
  • The invariant is recorded in resources/DESIGN.md.

Verification

  • New tests in txn-tracking.test.js gate 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 on origin/main with the intended assertions and pass with the fix.
  • A synchronous next-store throw test fails on the prior commit ("the landed head's write set must be cleared") and passes 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.js asserts that GIT_SSH_COMMAND, GIT_CONFIG_GLOBAL and GIT_EDITOR are absent, and the agent harness exports them. With those unset, that file shows 19 passing.
  • oxlint, prettier, tsc build and check:design-docs are clean.
  • End-to-end route: not observable end to end in a deterministic way, because it needs a native commit slower than the handler tail. Covered by unit tests against the real transaction() → DatabaseTransaction → RocksDB path. test:integration:all was 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

main on 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 main oldest-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-health board doc either way (prose row beginning with the test path, never a bare ref), and add your PR to initiatives/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-1

Review-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

kriszyp and others added 5 commits September 29, 2026 12:09
…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>

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@kriszyp
kriszyp marked this pull request as ready for review September 29, 2026 20:27
@kriszyp
kriszyp merged commit 5326165 into main Sep 29, 2026
57 of 59 checks passed
@kriszyp
kriszyp deleted the fix/commit-waits-for-monitor-force-commit branch September 29, 2026 20:27
Comment thread resources/DESIGN.md
- **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()`.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@claude

claude Bot commented Sep 29, 2026

Copy link
Copy Markdown
Contributor

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).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant