Skip to content

fix(txn): align ERR_TRY_AGAIN with the IsBusy reset path — reset in place, publish only on real commit#710

Open
kriszyp wants to merge 5 commits into
mainfrom
kris/tryagain-reset-align
Open

fix(txn): align ERR_TRY_AGAIN with the IsBusy reset path — reset in place, publish only on real commit#710
kriszyp wants to merge 5 commits into
mainfrom
kris/tryagain-reset-align

Conversation

@kriszyp

@kriszyp kriszyp commented Jul 15, 2026

Copy link
Copy Markdown
Member

Pairs with the HarperFast/harper PR "retry ERR_TRY_AGAIN on the same transaction" (linked in first comment) — the two must land together; this side releases first as 2.5.0, which is harper's new dependency floor.

Problem

An optimistic commit returns TryAgain (not Busy) when the transaction's snapshot is stranded outside the memtable window — the sequence history the conflict check needs was flushed away (bulk-ingest bursts, compaction). Two defects followed:

  1. Premature transaction-log publish. commitFinished — which advances the committed-read watermark, making log/change-feed entries visible — was gated on !IsBusy(). TryAgain is not Busy, so a failed TryAgain commit published its entries while the data was rolled back: a change-feed entry visible ahead of its data for every reader in that window.
  2. No native retry path. resetTransaction() fired only on IsBusy, so recommitting after TryAgain re-checked the same stranded snapshot forever. harper#1695's source-apply wedge (uncapped retry spinning, replication apply loop frozen) is the field manifestation; harper#1696 worked around it in the JS layer by replaying onto a fresh transaction — which depended on the premature publish of defect 1.

Fix

Treat TryAgain exactly like Busy:

  • commitFinished is now gated on status.ok() — entries publish only when the data commit actually succeeds. On a hard error, close()'s commitAborted stops the position pinning the watermark (bytes cannot be unwritten; abandoning a logged transaction remains the loudly-flagged ERR_TRANSACTION_ABANDONED case, unchanged).
  • resetTransaction() fires on IsBusy() || IsTryAgain() — preserves committedPosition (WAL stays write-once, Data loss: IsBusy commit retry orphans an uncommitted log position, pinning the committed watermark → committed reads truncate #668) but takes a fresh snapshot, so the caller's re-run validates against current state and converges.
  • TS adds a TransactionRetryableError base (shared by TransactionIsBusyError / new TransactionTryAgainError) expressing the contract: the native layer reset the handle; re-run the body and retry. Both db.transaction() retry loops now use one instanceof check. TransactionAbandonedError deliberately does not extend the base — retryability is the boundary.
  • The coordinated-retry RETRY_NOW fast path stays IsBusy-only by design: it parks on the conflicting holder's VT-slot lock, and a stranded snapshot has no lock holder to park on (documented in-code).

Validation

  • New test/transaction-log-tryagain-retry.test.ts: converges on the same transaction id, WAL written exactly once, and — the discriminating assertion — the entry is not visible mid-retry (fails under the old !IsBusy gate with expected 1 to be +0).
  • Forced-TryAgain is injected via a gated native test seam (forceTryAgainForTesting, inert unless called) because a real stranded snapshot can't be staged deterministically through the public API (OCC retains flushed-memtable history). The real repro lives in the paired harper PR: its compact()-induced ERR_TRY_AGAIN test converges on the same transaction with this build linked.
  • Full suite: 636 passed / 1 skipped; Data loss: IsBusy commit retry orphans an uncommitted log position, pinning the committed watermark → committed reads truncate #668 IsBusy write-once regression test unchanged and green; type-check/lint/fmt clean.

Cross-model review (Codex ×2 + Harper-domain adjudication; Gemini leg failed — agy hang)

  • Blocker (addressed): harper's dependency floor must encode this fix → released as 2.5.0; harper PR bumps to ^2.5.0 and stays draft until publish.
  • Adjudicated not-new: transactionSync fresh-Transaction-per-attempt lifecycle, worker-thread resetTransaction envelope, and operation-vs-state log semantics are all the pre-existing IsBusy/Data loss: IsBusy commit retry orphans an uncommitted log position, pinning the committed watermark → committed reads truncate #668 design that TryAgain now joins — no new hazard; the publish-gate change is strictly safer.
  • Considered, declined: debug-gating the test seam — CI exercises release builds, and the seam is inert unless explicitly armed (precedent: transactionLogMapCount).
  • Follow-up (pre-existing): async/sync retry-bound off-by-one (attempt < maxRetries vs <=) — present for IsBusy before this change; aligning it changes user-visible retry counts, so it deserves its own decision.

KrAIs, via Claude

🤖 Generated with Claude Code

kriszyp and others added 4 commits July 15, 2026 11:06
An optimistic commit returns TryAgain (not Busy) when the transaction's
snapshot is stranded outside the memtable window — the sequence history the
conflict check needs was flushed. Two problems followed:

1. commitFinished (which advances the committed-read watermark, publishing
   the log/change-feed entries) was gated on !IsBusy. TryAgain is not Busy,
   so a *failed* TryAgain commit published its entries while the record was
   rolled back — a change-feed entry ahead of its data, and a permanent
   phantom if the retry was ultimately abandoned (commitAborted cannot pull
   the watermark back past an already-advanced position).
2. TryAgain was not retried at all: resetTransaction fired only on IsBusy, so
   recommitting re-checked the same stranded snapshot forever (harper#1695).

Treat TryAgain like Busy: publish only on a real commit (gate on status.ok()),
and resetTransaction on IsBusy || IsTryAgain. The reset preserves
committedPosition (WAL stays write-once, #668) but takes a fresh snapshot, so
the caller's re-run validates against current state and converges. TS adds
TransactionTryAgainError and wires it into both db.transaction() retry loops.

Regression test drives the exact path via a gated native test seam
(forceTryAgainForTesting; inert in production) since a real TryAgain is finicky
to stage through the public API (OCC retains flushed-memtable history). It
asserts convergence on the *same* transaction, WAL written once, and — the
discriminating check — that the entry is not visible mid-retry (fails under the
old !IsBusy gate with "expected 1 to be 0").

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Both codes now mean the same thing to callers — the native layer reset the
transaction in place (fresh snapshot, committedPosition preserved) and the
body should be re-run — so express that contract as a common base class and
collapse the two-clause retry predicates to one instanceof check.
TransactionAbandonedError shares the field shape but deliberately does not
extend the base: retryability is the boundary the hierarchy expresses.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…t does not unwrite bytes

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2.5.0 is the paired-fix floor for HarperFast/harper's ERR_TRY_AGAIN
same-transaction retry (harper must require >=2.5.0 so an old native that
neither resets on TryAgain nor defers the failed-commit publish can never be
installed underneath it). The RETRY_NOW comment records that TryAgain is
deliberately excluded from coordinated-retry signalling: it has no conflicting
lock holder to park on, so it takes the reject→reset→retry path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@kriszyp

kriszyp commented Jul 15, 2026

Copy link
Copy Markdown
Member Author

Paired harper PR: HarperFast/harper#1823 (fix(txn): retry ERR_TRY_AGAIN on the same transaction) — draft until this releases as 2.5.0.

KrAIs, via Claude

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

Copy link
Copy Markdown

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 introduces support for retrying transactions that fail with a TryAgain status (due to stranded snapshots) similarly to how IsBusy failures are handled. It refactors error classes by introducing a common TransactionRetryableError base class, updates the native transaction commit paths to reset and retry on TryAgain, ensures log entries are only published upon successful commits, and adds a deterministic test seam along with a regression test. The reviewer suggested checking the return status of txnHandle->txn->Rollback() in the test seam block to prevent ignoring potential severe errors during rollback.

Comment thread src/binding/transaction/transaction.cpp
@github-actions

github-actions Bot commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

📊 Benchmark Results

get-sync.bench.ts

getSync() > random keys - small key size (100 records)

Implementation Rank Operations/sec Mean (ms) Min (ms) Max (ms) RME (%) Samples
🥇 lmdb 1 24.48K ops/sec 40.84 39.36 606.101 0.113 122,415
🥈 rocksdb 2 11.47K ops/sec 87.15 83.91 4,451.978 0.179 57,372

getSync() > sequential keys - small key size (100 records)

Implementation Rank Operations/sec Mean (ms) Min (ms) Max (ms) RME (%) Samples
🥇 lmdb 1 28.58K ops/sec 34.99 33.85 1,348.683 0.115 142,917
🥈 rocksdb 2 11.89K ops/sec 84.13 81.72 634.856 0.055 59,430

ranges.bench.ts

getRange() > small range (100 records, 50 range)

Implementation Rank Operations/sec Mean (ms) Min (ms) Max (ms) RME (%) Samples
🥇 lmdb 1 26.00K ops/sec 38.47 35.37 3,860.779 0.320 129,986
🥈 rocksdb 2 17.10K ops/sec 58.47 50.31 2,001.233 0.139 85,518

realistic-load.bench.ts

Realistic write load with workers > write variable records with transaction log

Implementation Rank Operations/sec Mean (ms) Min (ms) Max (ms) RME (%) Samples
🥇 rocksdb 1 453.83 ops/sec 2,203.491 79.52 47,764.095 13.53 908
🥈 lmdb 2 26.38 ops/sec 37,900.696 449.639 1,203,688.074 136.791 64.00

transaction-log.bench.ts

Transaction log > read 100 iterators while write log with 100 byte records

Implementation Rank Operations/sec Mean (ms) Min (ms) Max (ms) RME (%) Samples
🥇 rocksdb 1 33.02K ops/sec 30.29 14.26 391.137 0.202 165,090
🥈 lmdb 2 446.46 ops/sec 2,239.829 205.525 9,250.066 1.19 2,233

Transaction log > read one entry from random position from log with 1000 100 byte records

Implementation Rank Operations/sec Mean (ms) Min (ms) Max (ms) RME (%) Samples
🥇 rocksdb 1 672.17K ops/sec 1.49 1.30 2,938.364 0.137 3,360,832
🥈 lmdb 2 449.34K ops/sec 2.23 1.18 5,303.19 0.644 2,246,696

worker-put-sync.bench.ts

putSync() > random keys - small key size (100 records, 10 workers)

Implementation Rank Operations/sec Mean (ms) Min (ms) Max (ms) RME (%) Samples
🥇 rocksdb 1 868.61 ops/sec 1,151.261 977.82 1,921.015 0.278 1,738
🥈 lmdb 2 1.16 ops/sec 862,949.877 828,759.323 895,826.699 1.99 10.00

worker-transaction-log.bench.ts

Transaction log with workers > write log with 100 byte records

Implementation Rank Operations/sec Mean (ms) Min (ms) Max (ms) RME (%) Samples
🥇 rocksdb 1 20.24K ops/sec 49.42 30.08 579.675 0.501 40,474
🥈 lmdb 2 828.74 ops/sec 1,206.647 293.194 12,093.084 5.03 1,659

Results from commit c1ba4d4

… write-once assertion

Windows-only CI failure across every runner (Node 22/24/26, Bun, Deno):
`expected 16777216 to be 154`. Root cause is in the new test, not the fix —
the mid-transaction discriminating query (asserting the entry isn't visible
yet on attempt 2) maps the still-active log file, which on Windows
pre-extends the on-disk file to its configured max size via SetEndOfFile
(only ever shrunk back by a reopen recovery scan, never on close — see
transaction_log_file_windows.cpp). The existing #668 test never queries the
active file before its own stat-based check, so it never trips this.

Swap the raw filesystem byte-count assertion for
getStats().totals.entriesWritten, which counts entries actually written by
writeBatch() (skipped entirely by the write-once retry no-op) — a logical,
platform-independent counter unaffected by Windows' file pre-extension.

Also addresses the Gemini PR review comment: check Rollback()'s return status
in the test seam rather than ignoring it — a failed rollback (e.g. underlying
corruption) is a real error and should surface, not be masked behind the
simulated TryAgain.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@heskew heskew added claude-review Trusted-member gesture: opt this PR into Claude review. gemini-review Opt this PR into Gemini AI review (applied by a HarperFast org member) and removed claude-review Trusted-member gesture: opt this PR into Claude review. gemini-review Opt this PR into Gemini AI review (applied by a HarperFast org member) labels Jul 17, 2026
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.

2 participants