Data loss: an IsBusy commit retry orphans an uncommitted log position, permanently pinning the committed watermark → committed reads truncate
Summary
When an optimistic transaction's Commit() returns IsBusy and is retried, the log position recorded by the first writeBatch is orphaned in uncommittedTransactionPositions and never erased. Because the committed-read watermark (lastCommittedPosition) is computed as uncommittedTransactionPositions.front() (the earliest in-flight position), that orphaned position becomes a permanent floor: every subsequent committed read on that log is capped at the orphaned offset, even though the log file physically contains all the later entries. The result is silent, permanent truncation of committed reads with no error.
Impact
- Silent permanent data loss for any consumer of committed log reads after an IsBusy retry. We hit it as a replication backlog never reconciling (Harper harper-pro#426): a follower resumes from a valid cursor
T, the leader's resume iterator drains a short prefix and then returns done hundreds of records short of head; the follower sits permanently behind, connected: true, no error.
- Not limited to replication — any committed read bounded by
lastCommittedPosition (audit-log reads, time-travel, etc.) truncates at the pinned offset.
- No self-heal: the orphaned position is the
front() minimum forever, and no flush path advances past it.
Root cause (verified on main)
src/binding/transaction/transaction.cpp (async path; sync path is the same shape at ~490/510):
writeBatch(..., txnHandle->committedPosition) (~:268) inserts position A into uncommittedTransactionPositions and durably writes A's bytes to the log file; committedPosition now references A.
Commit() returns IsBusy.
commitFinished(...) is gated on !IsBusy() (~:300) → skipped. A is not erased.
resetTransaction() (~:319) recreates the txn but does not reset/erase committedPosition (src/binding/transaction/transaction_handle.cpp — clears txn/logEntryBatch/snapshotSet only).
- The retry's
writeBatch overwrites committedPosition with a new position B. A is now orphaned — no handle references it, so neither commitFinished nor commitAborted can ever target it.
- The retry's
commitFinished(B) erases B, but uncommittedTransactionPositions.front() is still A, so *lastCommittedPosition stays pinned at A (src/binding/transaction_log/transaction_log_store.cpp ~:527-531, and commitFinished ~:740).
Reader side — src/transaction-log-reader.ts: committed reads iterate while (position < size) where size is the low-32 of lastCommittedPosition (re-read live each next()). Once lastCommittedPosition is pinned at A, next() returns done at A. No throw, no warn.
Note: this also affects the coordinatedRetry path. On IsBusy it still takes the same resetTransaction() route (~:289 saves VT slots, then the shared IsBusy handling at ~:300/:316), so the orphan occurs there too. VT coordination may reduce how often IsBusy fires, but residual conflicts still pin the watermark.
Versions affected
Confirmed in 2.1.0 (the old quadratic-backoff retry path) and main / 2.2.0 (the coordinatedRetry / VerificationTable line). harper #410 (record-caching) routes IsBusy through coordinatedRetry and bumps to ^2.2.0, so it carries this bug forward rather than fixing it.
Reproduction
Harper-pro stress test backlog-recovery (4-node mesh, one node offline 30 min under churn, then catches up). Reproduces intermittently (~50% at OFFLINE=30 / KEYS=800). Captured signature: the offline node resumes from real cursors, receives a burst of ~247 records covering only the first ~10s of a 30-min backlog, then stops — identically on two independent leaders (deterministic boundary, not a race) — and never converges. The backlog is physically present in each leader's log file past the watermark.
A direct rocksdb-js repro: run a write under contention so an optimistic Commit() returns IsBusy and is retried, then read the committed log range — it truncates at the orphaned position. (A DEBUG_LOG build shows the Commit failed with IsBusy, resetting transaction line at the orphaning point.)
Suggested fix (design — has a subtlety)
commitAborted(pos) already does the right teardown (positionErase(pos) + recompute lastCommittedPosition from front()). The minimal fix is to call it for the orphaned position on the IsBusy branch (when committedPosition.logSequenceNumber > 0) before resetTransaction() overwrites the reference.
Subtlety: writeBatch durably writes A's bytes to the log file before the commit outcome is known. Simply erasing A's tracking and advancing the watermark past it would expose A's aborted bytes to committed readers (the retry re-writes the same records at B → duplicates in the log). So the fix must also account for the orphaned bytes. Options:
- (a)
commitAborted(committedPosition) on the IsBusy branch + ensure A's aborted log bytes are skipped/truncated by the reader (e.g. an abort marker, or truncate back to A on abort).
- (b) Make the IsBusy retry reuse position A instead of allocating B (no orphan, no duplicate bytes).
This intersects the log-bytes/committed-watermark invariant that the VerificationTable / coordinatedRetry work owns, so it's best designed alongside that.
Confidence
High on the orphan→pinned-watermark→truncation chain (code-verified on main and 2.1.0, reader + native store). The one open item is a direct trace tying a specific captured stall to an IsBusy retry vs. another never-commitFinished path — but all such paths orphan the position identically, so the fix is the same.
🤖 Filed by Claude on behalf of Kris.
Data loss: an IsBusy commit retry orphans an uncommitted log position, permanently pinning the committed watermark → committed reads truncate
Summary
When an optimistic transaction's
Commit()returnsIsBusyand is retried, the log position recorded by the firstwriteBatchis orphaned inuncommittedTransactionPositionsand never erased. Because the committed-read watermark (lastCommittedPosition) is computed asuncommittedTransactionPositions.front()(the earliest in-flight position), that orphaned position becomes a permanent floor: every subsequent committed read on that log is capped at the orphaned offset, even though the log file physically contains all the later entries. The result is silent, permanent truncation of committed reads with no error.Impact
T, the leader's resume iterator drains a short prefix and then returnsdonehundreds of records short of head; the follower sits permanently behind,connected: true, no error.lastCommittedPosition(audit-log reads, time-travel, etc.) truncates at the pinned offset.front()minimum forever, and no flush path advances past it.Root cause (verified on
main)src/binding/transaction/transaction.cpp(async path; sync path is the same shape at ~490/510):writeBatch(..., txnHandle->committedPosition)(~:268) inserts position A intouncommittedTransactionPositionsand durably writes A's bytes to the log file;committedPositionnow references A.Commit()returnsIsBusy.commitFinished(...)is gated on!IsBusy()(~:300) → skipped. A is not erased.resetTransaction()(~:319) recreates the txn but does not reset/erasecommittedPosition(src/binding/transaction/transaction_handle.cpp— clearstxn/logEntryBatch/snapshotSetonly).writeBatchoverwritescommittedPositionwith a new position B. A is now orphaned — no handle references it, so neithercommitFinishednorcommitAbortedcan ever target it.commitFinished(B)erases B, butuncommittedTransactionPositions.front()is still A, so*lastCommittedPositionstays pinned at A (src/binding/transaction_log/transaction_log_store.cpp~:527-531, andcommitFinished~:740).Reader side —
src/transaction-log-reader.ts: committed reads iteratewhile (position < size)wheresizeis the low-32 oflastCommittedPosition(re-read live eachnext()). OncelastCommittedPositionis pinned at A,next()returnsdoneat A. No throw, no warn.Versions affected
Confirmed in 2.1.0 (the old quadratic-backoff retry path) and
main/ 2.2.0 (thecoordinatedRetry/ VerificationTable line). harper #410 (record-caching) routes IsBusy throughcoordinatedRetryand bumps to^2.2.0, so it carries this bug forward rather than fixing it.Reproduction
Harper-pro stress test
backlog-recovery(4-node mesh, one node offline 30 min under churn, then catches up). Reproduces intermittently (~50% atOFFLINE=30 / KEYS=800). Captured signature: the offline node resumes from real cursors, receives a burst of ~247 records covering only the first ~10s of a 30-min backlog, then stops — identically on two independent leaders (deterministic boundary, not a race) — and never converges. The backlog is physically present in each leader's log file past the watermark.A direct rocksdb-js repro: run a write under contention so an optimistic
Commit()returnsIsBusyand is retried, then read the committed log range — it truncates at the orphaned position. (ADEBUG_LOGbuild shows theCommit failed with IsBusy, resetting transactionline at the orphaning point.)Suggested fix (design — has a subtlety)
commitAborted(pos)already does the right teardown (positionErase(pos)+ recomputelastCommittedPositionfromfront()). The minimal fix is to call it for the orphaned position on the IsBusy branch (whencommittedPosition.logSequenceNumber > 0) beforeresetTransaction()overwrites the reference.Subtlety:
writeBatchdurably writes A's bytes to the log file before the commit outcome is known. Simply erasing A's tracking and advancing the watermark past it would expose A's aborted bytes to committed readers (the retry re-writes the same records at B → duplicates in the log). So the fix must also account for the orphaned bytes. Options:commitAborted(committedPosition)on the IsBusy branch + ensure A's aborted log bytes are skipped/truncated by the reader (e.g. an abort marker, or truncate back to A on abort).This intersects the log-bytes/committed-watermark invariant that the VerificationTable /
coordinatedRetrywork owns, so it's best designed alongside that.Confidence
High on the orphan→pinned-watermark→truncation chain (code-verified on
mainand 2.1.0, reader + native store). The one open item is a direct trace tying a specific captured stall to an IsBusy retry vs. another never-commitFinishedpath — but all such paths orphan the position identically, so the fix is the same.🤖 Filed by Claude on behalf of Kris.