fix(storage): replay conflict retry on a fresh transaction after ERR_TRY_AGAIN - #1696
Conversation
…TRY_AGAIN Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review
This pull request introduces handling for 'ERR_TRY_AGAIN' errors during database transactions by replaying writes onto a fresh transaction instead of retrying the same stranded snapshot, preventing infinite loops in the replication apply loop. It also adds comprehensive unit tests to verify this behavior. The review feedback suggests improving error handling in the transaction abort catch block by logging the caught error rather than discarding it.
| try { | ||
| transaction.abort(); | ||
| } catch { | ||
| // already released by the failed commit; nothing to clean up | ||
| } |
There was a problem hiding this comment.
When catching and logging expected or benign exceptions, do not discard the caught error. Pass the error object to the logger (even at a debug level) to preserve the root cause for troubleshooting. Please update the catch block to log the caught error using harperLogger.debug.
| try { | |
| transaction.abort(); | |
| } catch { | |
| // already released by the failed commit; nothing to clean up | |
| } | |
| try { | |
| transaction.abort(); | |
| } catch (abortError) { | |
| // already released by the failed commit; nothing to clean up | |
| harperLogger.debug?.('failed to abort transaction during retry', abortError); | |
| } |
References
- When catching and logging expected or benign exceptions (such as database interruptions during shutdown), do not discard the caught error. Pass the error object to the logger (even at a debug level) to preserve the root cause for troubleshooting.
|
Reviewed; no blockers found. |
|
Reviewed via Claude review-queue (full review: harper-1696-27a26a8.md). Requesting changes — the PR's own new test proves the ERR_TRY_AGAIN retry on a fresh transaction drops a concurrent commutative increment (2→1). This is a real correctness bug in the fix, not just a coverage gap. Compatibility with harper#410 (Record caching) — makes this more urgent, not less: harper#410's The caching layer itself doesn't widen the blast radius of this bug — The real issue: #410's Recommend re-scoping this PR (or a companion PR) to fix |
kriszyp
left a comment
There was a problem hiding this comment.
Requesting changes per the comment above — the retry drops a concurrent increment (proven by the PR's own test), and the root cause in Table.ts needs fixing before harper#410 lands or this becomes a routine data-loss path instead of a rare one.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Re-reviewed via Claude review-queue (full review: harper-1696-571a2d2.md). The prior blocker is fixed, and fixed at the right layer. Traced by hand: the real bug was that a failed first attempt's audit-log entry survives One thing worth narrowing before this ships, especially with #410 about to make retries the default path rather than an opt-in rarity: This trades a guaranteed drop for a narrower, less-likely double-apply, which is a real improvement — but it's not risk-free, and no test exercises the cross-write batched case (all three new tests use a single write per transaction). Given crash-safety/data-loss avoidance is the top concern here, I'd lean toward narrowing the guard to "does the found audit entry belong to this attempt's own prior write" rather than blanket-gating on the transaction-wide retry flag — or if that's not cheaply available, at least file a follow-up + add a test for the batched-mixed-write case before #410 lands and makes this the common path rather than the rare one. CI unit tests (including the new sourceApplyConflictRetry.test.js) were still pending at review time — worth confirming green, especially given the PR description already notes 3 unrelated pre-existing failures on main to filter out. |
kriszyp
left a comment
There was a problem hiding this comment.
Prior blocker (dropped commutative increment) is fixed at the root layer — good work. One narrowing request before merge, detailed in the comment above: the !retry dedup guard is transaction-wide, not write-specific, so a batched transaction with a mix of a conflicting write and a genuine re-delivered duplicate could double-apply the duplicate. Not urgent to block on if you'd rather file a follow-up, but flagging as request-changes so it's tracked.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
| // so the lookup would find it and skip the write as "already applied" when the record was never | ||
| // committed. A recommit of the same transaction survived that skip only because the old write | ||
| // batch still carried the put; a fresh-transaction replay (ERR_TRY_AGAIN) would drop the write. | ||
| if (isRocksDB && !retry && dedupVersionCouldBeRetained(txnTime)) { |
There was a problem hiding this comment.
Finding: !retry is this.retries > 0 for the whole DatabaseTransaction, not for this specific write. A DatabaseTransaction batches multiple writes in this.writes; if any one of them triggers ERR_TRY_AGAIN, every write in the batch retries with retry=true. That means a genuine re-delivered duplicate from a different, already-committed transaction that happens to share this batch also has its dedup check suppressed here — and again in isReDeliveredDuplicate() at line 2145 — so it gets re-applied as a double-fold.
The invariant the guard enforces is "this write has an orphaned audit entry from my own failed attempt," which is per-write, not per-transaction. The fix is correct for the common single-write case but admits double-apply for any genuine duplicate co-batched with a retried write.
Per kriszyp's comment: either (a) narrow the check to whether the found audit entry matches this write's own attempt (per-write detection), or (b) add a test for the batched-mixed-write case and file a tracking issue before #410 merges — #410 makes retries the default path for ordinary write-write contention, not just the current replication-only opt-in, which widens the blast radius significantly. All three new tests use a single write per transaction; the batched case is not covered.
… (review) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Fixed in d91876c: the guard is now a per-write sticky marker ( Lavinia, via Claude |
|
The remaining Unit Test failure ( Lavinia, via Claude |
kriszyp
left a comment
There was a problem hiding this comment.
Good fix — this closes a real production wedge (a source-apply commit hitting ERR_TRY_AGAIN previously recommitted the same stranded transaction forever, freezing the replication apply loop for that database). The retry-onto-a-fresh-transaction mechanism is sound, and the sticky appendedAuditEntry flag correctly prevents the replay from mistaking its own orphaned audit entry for "already applied" and silently dropping the write. Well tested — the test suite specifically asserts the successful retry has a different transaction id than the failed one, not just that the outcome is "committed."
One thing worth a quick look before merge: retryTransaction (the fresh transaction created on ERR_TRY_AGAIN) can leak an un-aborted native transaction handle if MAX_RETRIES is exceeded on a non-source-apply transaction (DatabaseTransaction.ts around the MAX_RETRIES check) — when retries exceed the limit for a transaction that isn't neverDropOnConflict, the code throws a ServerError immediately without aborting the just-created retryTransaction. This is a pre-existing pattern (the original transaction was never aborted on this throw path either), so I'm not calling it a blocker, but this PR does add one more transaction-creation site that can hit it, and it's directly adjacent to code already being touched here. Worth a one-line retryTransaction.abort() (best-effort, same try/catch pattern as the existing abort calls) before the throw, while you're in the area. Realistically needs 40+ consecutive retries to trigger, so low likelihood, but a leaked native handle per occurrence isn't free.
(Also: I want to walk back an incoherent comment I left on this thread earlier that just said "see review for detail" with no actual detail — that was a copy-paste mistake on my end, sorry about that. The above is the real finding.)
kriszyp
left a comment
There was a problem hiding this comment.
Correction: the note in my parenthetical above about "an incoherent comment" was meant for a different PR (#1693) and doesn't apply here — pasted into the wrong thread by mistake. Sorry for the noise; the actual review content above stands.
…ries in tests (review) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Both addressed in eedd5f9: The MAX_RETRIES throw now aborts the current transaction first (best-effort, same pattern as the replay's abort), so neither the original nor a fresh replay transaction leaks its handle on the give-up path. On the change-feed coverage: the increments test now counts the record's queryable patch entries and requires both (the concurrent write's and the replayed increment's orphan), which pins the entry-survives-abort invariant for the class where its absence would double-apply on redelivery. The stranded test deliberately does not assert an entry: that write ends fully superseded by the newer concurrent patch, and a superseded plain write takes the pre-existing early-out with no dedicated audit entry (nothing to publish, and a redelivery re-folds to the same no-op). Lavinia, via Claude |
… vs ERR_TRY_AGAIN Resolve the sole conflict in resources/DatabaseTransaction.ts: keep both complementary fixes in the non-coordinated ERR_BUSY/ERR_TRY_AGAIN commit-retry handler. Main's ERR_TRY_AGAIN fresh-transaction replay (#1696) runs first, then record-caching's explicit retry-site isRetry stamp runs after the possible swap so the fresh replay transaction is also marked (else it re-stages audit/change-feed log entries on recommit). The old save()-site isRetry assignment that record-caching removed (leak fix) is confirmed NOT reintroduced. Also align the ERR_BUSY subtest in sourceApplyConflictRetry.test.js with record-caching's coordinatedRetry convergence: a coordinated source-apply transaction resolves commit() with the RETRY_NOW_VALUE sentinel on conflict rather than rejecting with ERR_BUSY, so the commit spy now records the resolved value and the subtest accepts that sentinel (or a raw ERR_BUSY/ ERR_TRY_AGAIN rejection on the uncoordinated path) as the detected-and-retried conflict, while still requiring a later genuine commit to succeed.
…TRY_AGAIN (#1696) * fix(storage): replay conflict retry on a fresh transaction after ERR_TRY_AGAIN Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(storage): keep retries from deduping against their own audit entry Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore: log the swallowed abort error (review) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(storage): per-write sticky own-audit-entry marker for retry dedup (review) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(storage): abort before the MAX_RETRIES throw, pin change-feed entries in tests (review) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: Kris Zyp <kriszyp@gmail.com>
…e reset) Pairs with rocksdb-js "align ERR_TRY_AGAIN with the IsBusy reset path". #1696 worked around a stranded-snapshot ERR_TRY_AGAIN by replaying the writes onto a *fresh* RocksTransaction, because the native layer left the stranded snapshot in place so recommitting the same transaction spun forever. That fix also leaned on rocksdb-js publishing the change-feed entry on the failed commit (the fresh replay carries isRetry and never re-stages it). rocksdb-js now resets the transaction onto a fresh snapshot on a failed TryAgain commit — exactly as it always did for IsBusy — and defers the log publish until a real commit. So the fresh-transaction replay is both unnecessary and wrong: a fresh transaction with isRetry would never publish the now-unpublished entry, re-losing the change-feed entry (#1695). Recommit the SAME transaction instead, like ERR_BUSY: its committedPosition survives the reset (WAL write-once) and its onCommit hook stays attached, so the staged entry publishes exactly once, only when the retry commits. Updates the regression test to assert the retry reuses the same transaction id (reset in place) rather than running on a fresh one; the real compact()-induced ERR_TRY_AGAIN, commutative-increment, and co-batched-duplicate cases all still converge. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…e reset) Pairs with rocksdb-js "align ERR_TRY_AGAIN with the IsBusy reset path". #1696 worked around a stranded-snapshot ERR_TRY_AGAIN by replaying the writes onto a *fresh* RocksTransaction, because the native layer left the stranded snapshot in place so recommitting the same transaction spun forever. That fix also leaned on rocksdb-js publishing the change-feed entry on the failed commit (the fresh replay carries isRetry and never re-stages it). rocksdb-js now resets the transaction onto a fresh snapshot on a failed TryAgain commit — exactly as it always did for IsBusy — and defers the log publish until a real commit. So the fresh-transaction replay is both unnecessary and wrong: a fresh transaction with isRetry would never publish the now-unpublished entry, re-losing the change-feed entry (#1695). Recommit the SAME transaction instead, like ERR_BUSY: its committedPosition survives the reset (WAL write-once) and its onCommit hook stays attached, so the staged entry publishes exactly once, only when the retry commits. Updates the regression test to assert the retry reuses the same transaction id (reset in place) rather than running on a fresh one; the real compact()-induced ERR_TRY_AGAIN, commutative-increment, and co-batched-duplicate cases all still converge. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…e reset) Pairs with rocksdb-js "align ERR_TRY_AGAIN with the IsBusy reset path". #1696 worked around a stranded-snapshot ERR_TRY_AGAIN by replaying the writes onto a *fresh* RocksTransaction, because the native layer left the stranded snapshot in place so recommitting the same transaction spun forever. That fix also leaned on rocksdb-js publishing the change-feed entry on the failed commit (the fresh replay carries isRetry and never re-stages it). rocksdb-js now resets the transaction onto a fresh snapshot on a failed TryAgain commit — exactly as it always did for IsBusy — and defers the log publish until a real commit. So the fresh-transaction replay is both unnecessary and wrong: a fresh transaction with isRetry would never publish the now-unpublished entry, re-losing the change-feed entry (#1695). Recommit the SAME transaction instead, like ERR_BUSY: its committedPosition survives the reset (WAL write-once) and its onCommit hook stays attached, so the staged entry publishes exactly once, only when the retry commits. Updates the regression test to assert the retry reuses the same transaction id (reset in place) rather than running on a fresh one; the real compact()-induced ERR_TRY_AGAIN, commutative-increment, and co-batched-duplicate cases all still converge. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Fixes #1695.
The commit retry handler kept recommitting the same RocksDB transaction. That converges for
ERR_BUSY(each recommit re-tracks the written keys at the current sequence) but never forERR_TRY_AGAIN: the snapshot stays stranded outside the memtable window, so the uncapped source-apply retry spins forever and wedges the database's replication apply loop.On
ERR_TRY_AGAINthe retry now aborts the stranded transaction and replays the writes onto a fresh one; the existing retry re-save path reloads every entry through the new transaction and re-resolves against current state. The transaction-log commit hook is carried over, andisRetrystill keeps log entries from being re-added.ERR_BUSYhandling is unchanged.Three regression tests: the stranded-snapshot test reproduces the field wedge (spins forever without the fix, converges with it), the
ERR_BUSYtest pins the unchanged recommit behavior, and the commutative-increment test shows concurrent CRDT adds survive the fresh-transaction replay.unitTests/resourcesis green exceptcopy-apply snapshot writes,table-reload marker, andCan run txn with three tables and two databases, which fail identically on unfixed main.Lavinia, via Claude
🤖 Generated with Claude Code