Repository navigation
Description
Follow-up to #3236.
Background
#3236 enforces in DatabaseTransaction that once a RocksDB transaction (or any link in a multi-store chain) has durably written its entry to the transaction log (hasDurableLog(transaction), tracked after a native transaction.commit() attempt with hasStagedLog), it is never aborted on conflict retries, commit-budget deadlines, record-lock lease expiry, or database-close commit suspension, and the caller stays awaiting the commit until it lands (matching sourceApply).
Native rocksdb-js changes and residual edges scoped out of #3236 into this follow-up issue:
-
Native
rocksdb-jsconflict-before-log ordering inTransaction::Commit(e0c5e60a1aaf,9dcabe0ba98a):- In
rocksdb-js2.12.0,Transaction::CommitrunsexecuteLogWork(writing the transaction log entry and settingcommittedPosition) beforetxn->Commit()checks optimistic conflicts on the primary RocksDB column family. - Because
logging.auditLogdefaults totrue, every audited RocksDB write that conflicts on its first native commit attempt already has a durable log entry on disk, so under Never abort a transaction on retry once its entry has been written to the transaction log #3236 it must retry without an attempt cap or 503 deadline until the commit lands. - Reordering or staging
Transaction::Commitinrocksdb-jsso optimistic conflict validation succeeds beforeexecuteLogWorkflushes the log batch (or appending an abort marker that log readers skip) will allow conflicting request-path commits on audited tables to remain unlogged on conflict and preserve the Outstanding-commit wedge recurs on 5.2.6/5.2.7: native commits never settle and checkOverloaded 503s every application write on the thread until restart #2450 503 deadline and 40-attempt cap.
- In
-
Native
rocksdb-jsdurable-log state exposure (committedPosition > 0) and post-log abort refusal/reconciliation (07a62c6fd60d,20be4feb68b1,f933af77479c,03d6ac4d1556ea87,95ccdec124307a0d,6e8fbfce53749284,0533586f774f1afc):- Expose
committedPosition > 0(or a directhasDurableLogaccessor) fromrocksdb-json the nativeTransactionhandle soDatabaseTransactioncan read native log durability directly rather than inferring it fromisRetry && hasStagedLog. - Harden
Transaction::Abort()/Close()inrocksdb-jsto refuse or safely reconcile handles that already hold a durable log entry (covering a first-attempt non-conflict commit failure afterexecuteLogWorkflushed the log entry, a synchronous throw from a customonCommithook during retry re-save, or a store close afterWAIT_FOR_DRAIN_DEADLINE_MS).
- Expose
-
Tracking retry-round pre-commit
completionsawaits duringwaitForDrain()(e554d93b900e0d06):- Extend
outstandingCommitstracking across the full retry round (including any asynchronousthis.completionsawait added by a customonCommithook on retry).
- Extend
-
Propagating
context.timestampwhen creating a chainedImmediateTransactionlink (fd98b84c9b2cb1a1,8c1816872ad4f7f1):- Copy
context.timestampintotimestampandlastContextTimestampwhen constructing a.nextImmediateTransactionlink inTable.ts:txnForContextso the first write to a second database inherits the caller's version.
- Copy
-
Record-lock commit hold exclusion past
commitConflictBudget(e7748380dccb1564):retainForCommitdefers local lock unlock only up tocommitConflictBudget(limitation 2 inresources/record-locks.md); onceTransaction::Commitvalidates conflicts beforeexecuteLogWork, conflicting commits no longer outlivecommitConflictBudget.
-
Direct raw eviction transaction commits in
resources/Table.ts(ba9ebb8c973b5fe6,9ea18b2db862):- Single-record and batched eviction in
resources/Table.ts:4129-4139andresources/Table.ts:9947-9960commit rawRocksTransactionhandles viacommitTrackedRocksTransactionoutsideDatabaseTransactionand calltransaction.abort()ifcommit()rejects withERR_BUSY. WhenreplicateEvictionsis enabled (saveAudit: true),executeLogWork()flushes an'evict'log entry beforetxn_->Commit()checks for conflict, so aborting or replacing the handle onERR_BUSYorphans that'evict'log entry until conflict-before-log ordering lands inrocksdb-jsor raw eviction commits recommit the same handle.
- Single-record and batched eviction in
-
Per-save write-set rescan while a late immediate commit overlaps a retrying retained-iterator replay (
6a76c39decccc6db):- In that overlap
activeCommitHandleis unset and the replay handle differs fromthis.transaction, so every re-save in the round runsbackfillActiveHandleWritesover the whole write set (O(N²) for that round). Ordinary commits and replay rounds backfill once per round; once conflict-before-log ordering lands, the retry rounds that reach this path mostly disappear.
- In that overlap
-
Edge cases of the stalled-commit monitor's logged-retry progress check (
d5b79e28d4818b39,b6538f35ca1ef816,3a5938b9fba8e158,5151c6f2da9439a3):- The check spares a logged retry only while the summed retry counter of the chain's live logged handles keeps moving (
activeDurableLogRetries()/deferForCommitInFlight()inresources/DatabaseTransaction.ts); a counter that stops forLOGGED_RETRY_STALL_MS(2 × the native park timeout) is treated as a stalled native commit. Diagnostics only, no data loss: an advancing or departing sibling can hide a wedged logged handle, and a handover to the next link's handle at an equal total can read as a stall. - The 2 × park window does not cover the up-to-1 s JS retry backoff once
ROCKSDB_JS_PARK_TIMEOUT_MSis under about 1 s, and the parse of that variable differs from rocksdb-js's for non-integers and values under 50 ms. abortNativeTransactionskips a handle flaggedcommitInFlight; a synchronous throw between the flag being set and the commit settling would leave it set (nothing on that path throws today).- Simplest direction if it matters: key the window on the per-handle
retriesof each live logged handle instead of one aggregate, and read the park timeout through rocksdb-js instead of mirroring its parser.
- The check spares a logged retry only while the summed retry counter of the chain's live logged handles keeps moving (
Review-Deferral: e0c5e60a1aaf severity=major category=reliability
Review-Deferral: 9dcabe0ba98a severity=major category=reliability
Review-Deferral: 07a62c6fd60d severity=nit category=code-quality
Review-Deferral: 20be4feb68b1 severity=nit category=code-quality
Review-Deferral: f933af77479c severity=minor category=test-coverage
Review-Deferral: 03d6ac4d1556ea87 severity=minor category=data-integrity
Review-Deferral: 95ccdec124307a0d severity=minor category=test-coverage
Review-Deferral: e554d93b900e0d06 severity=minor category=reliability
Review-Deferral: dd13e9a5c46676bd severity=nit category=test-coverage
Review-Deferral: 6e8fbfce53749284 severity=nit category=maintainability
Review-Deferral: 0533586f774f1afc severity=major category=reliability
Review-Deferral: fd98b84c9b2cb1a1 severity=nit category=correctness
Review-Deferral: 8c1816872ad4f7f1 severity=nit category=correctness
Review-Deferral: e7748380dccb1564 severity=nit category=concurrency
Review-Deferral: ba9ebb8c973b5fe6 severity=minor category=data-integrity
Review-Deferral: 6a76c39decccc6db severity=nit category=performance
Review-Deferral: d5b79e28d4818b39 severity=nit category=reliability
Review-Deferral: b6538f35ca1ef816 severity=nit category=reliability
Review-Deferral: 3a5938b9fba8e158 severity=nit category=reliability
Review-Deferral: 5151c6f2da9439a3 severity=nit category=reliability
🤖 Generated by Google Gemini, Claude Fable 5.1 and Claude Sonnet 5.5; posted via @kriszyp.
Activity
fix(resources): isolate per-handle ImmediateTransaction state and cov…
fix(resources): keep hasLog sticky across retries and revert Table.ts…
fix(resources): isolate per-handle ImmediateTransaction state and cov…
fix(resources): keep hasLog sticky across retries and revert Table.ts…
fix(resources): isolate per-handle ImmediateTransaction state and cov…
fix(resources): keep hasLog sticky across retries and revert Table.ts…
fix(resources): isolate per-handle ImmediateTransaction state and cov…
fix(resources): keep hasLog sticky across retries and revert Table.ts…
fix(resources): isolate per-handle ImmediateTransaction state and cov…
fix(resources): keep hasLog sticky across retries and revert Table.ts…
Never abort a transaction once its entry is written to the transactio…
fix(resources): isolate per-handle ImmediateTransaction state and cov…
fix(resources): keep hasLog sticky across retries and revert Table.ts…
fix: validate optimistic conflicts before the transaction-log write; …
cherry-pick: Never abort a transaction once its entry is written to t…
fix: validate optimistic conflicts before the transaction-log write; …
Prototype: hybrid abort markers and pre-log refusal on write intents …
Prototype: pre-check variants, retry-kind tracking, pre-log refusal r…
kriszyp commentedon Oct 27, 2026
Confirmed on 2.12.0: a refused retained-handle write is replayed and replicated
This is new real-binding evidence for this existing storage issue, discovered while testing the retry-conflict rule for HarperFast/harper-pro#980 (core #3527 / Pro HarperFast/harper-pro#1116). The slot-residency repair is stopped; no serialization or production retry-conflict guard was introduced. This issue already scopes the independent native/core fix, so I am adding the reproduction here instead of duplicating it.
All three requested observations are positive: startup replay applies the abandoned PATCH to the primary and indexes; the real sender transmits it to a slot-0 owner; the real receiver commits it, and its row/index survive a flush and a fresh-process reopen.
ERR_TRANSACTION_ABANDONED; the independently committed PUT remains locally at region 2 / zone 0 / slot 0.readUncommitted: true, after the concurrent PUT and abort. ItsendTxnis true.getDatabases(), invokingresources/replayLogs.ts. InstrumenteddirectCommitSync()shows the PATCH changing zone 0 to 1. Reconciliation of the following PUT leaves region 2 / zone 1 / slot 1; the zone index is 1.replicateOverWS()resumes the origin log at the original PUT's key, sends a slot-0 reference and the abandoned{zone:1}PATCH, then the concurrent PUT and a barrier record. No bulk-copy fallback is used in the successful probe.replicateOverWS()after handshake. The receiver writes the PATCH into the source-origin audit and changes the primary and zone index to 1; its final row in this probe is region 0 / zone 1 / slot 0. A drained flush and a third process confirm the same stored row/index.Reproduction details and limits
Validated core
27ed145464184f6b7d80db293e18fb7702d06349, Pro323bf0c042ee7834e4c51d00e002c777a4e98626, Node 26.2.0, actual resolved@harperfast/rocksdb-js2.12.0 / RocksDB 11.8.1. Pro was rebuilt before probing. NativeforceTryAgainForTesting(1)deterministically exercises the real binding's conflict path; this is not a mocked commit rejection.Table policy is
region === 0 ? 0 : zone, with both region and zone indexed and no retained replica body. The initial PUT is{region:0,zone:0,body:'original'}. PATCH{zone:1}is admitted/audited for slot 0. After its first native commit reports TryAgain, an independent PUT writes{region:2,zone:0,body:'concurrent'}(still slot 0). The native retry now merges to slot 1, and a test-only wrapper around the staged write's commit throws a 409 before the retained handle can submit again. No production guard was added.The PATCH carries an explicit logical timestamp 60 seconds ahead, as in the existing retry-only reproduction. This ensures version reconciliation cannot simply supersede its effect with the concurrent PUT. The scope of this experiment is a post-log abort, not every successful retry or every application write. It demonstrates shared log/replay/replication exposure; it does not claim all TryAgain retries are abandoned.
How the current readers distinguish abandonment
They do not distinguish it in this binding:
TransactionLog.query()reads up to a log-wide_lastCommittedPosition, or physical file extent forreadUncommitted. Its entry loop returns matching timestamp/data plusendTxnfrom the LAST flag. No commit/abandon discriminator is consulted for the returned batch. In this run the committed boundary includes the refused PATCH.RocksTransactionLogStore.getRange()delegates to that native-package reader and handles corruption/exact-start boundaries, not abandoned writes.replayLogs()asks for the unflushed tail withreadUncommitted:true. A complete, decodable PATCH becomes_writeUpdate(..., false, ...)and is committed.endTxnproves the logged batch ended; it does not prove the original RocksDB transaction committed.sendAuditRecord. The real captured wire result demonstrates there is no effective abandonment filter on this path.The reader-first work in HarperFast/rocksdb-js#970 is relevant, but these probes used the installed unmodified 2.12.0 binding, not that PR. This report does not prescribe a new rollback contract or validate the pending fix.
Captured evidence
Primary/index state and transmitted records
{ "beforeCrash": { "slot": 0, "version": 1793106354330.2668, "flags": 234881600, "value": { "id": "retry-abort", "region": 2, "zone": 0, "body": "concurrent" }, "indexes": { "region": [ { "key": 2, "value": "retry-abort" } ], "zone": [ { "key": 0, "value": "retry-abort" } ] } }, "startupReplayCommits": [ { "timestamp": 1793106354272.7883, "operations": [ { "key": "retry-abort", "slot": 0, "skipped": false } ], "after": { "id": "retry-abort", "region": 0, "zone": 0, "body": "original" }, "slot": 0 }, { "timestamp": 1793106354327.3306, "operations": [ { "key": "retry-abort", "slot": 0, "skipped": false } ], "before": { "id": "retry-abort", "region": 0, "zone": 0, "body": "original" }, "after": { "id": "retry-abort", "region": 0, "zone": 1, "body": "original" }, "slot": 0 }, { "timestamp": 1793106354330.2668, "operations": [ { "key": "retry-abort", "slot": 1, "skipped": false } ], "before": { "id": "retry-abort", "region": 0, "zone": 1, "body": "original" }, "after": { "region": 2, "zone": 1 }, "slot": 1 } ], "afterStartupReplay": { "slot": 1, "version": 1793106414325, "flags": 234947265, "value": { "region": 2, "zone": 1 }, "indexes": { "region": [ { "key": 2, "value": "retry-abort" } ], "zone": [ { "key": 1, "value": "retry-abort" } ] } }, "sentToSlot0Owner": [ { "type": "patch", "recordId": "retry-abort", "version": 1793106816709, "nodeId": 0, "value": { "zone": 1 } }, { "type": "put", "recordId": "retry-abort", "version": 1793106756712.71, "nodeId": 0, "value": { "id": "retry-abort", "region": 2, "zone": 0, "body": "concurrent" } }, { "type": "put", "recordId": "barrier", "version": 1793106756720.4285, "nodeId": 0, "value": { "id": "barrier", "region": 0, "zone": 0, "body": "after abort" } } ], "slotReferences": [ [ 130, { "slot": 0, "mapVersion": 1, "mapDigest": "83efbf3e0367b5f5509b37526eb1fc00278adb73ef001933aad8791c284d4b45" }, 2147483648 ] ], "receiverAfterFlushAndReopen": { "primary": { "slot": 0, "version": 1793106816709, "flags": 234881728, "value": { "id": "retry-abort", "region": 0, "zone": 1, "body": "original" }, "indexes": { "region": [ { "key": 0, "value": "barrier" }, { "key": 0, "value": "retry-abort" } ], "zone": [ { "key": 0, "value": "barrier" }, { "key": 1, "value": "retry-abort" } ] } }, "audit": [ { "type": "patch", "slot": 0, "version": 1793106816709, "txnLogKey": 1793106756710.8628, "endTxn": true, "value": { "zone": 1 } } ] } }Reproducible probe scripts
Place these three files together in any scratch directory inside the Pro worktree, and run from the Pro root with its core companion checked out. They use only fresh roots passed on the command line. Build core and Pro first. The
writeprocess intentionally dies by SIGKILL; that is the expected result, not a test-runner failure. A successful probe exit records observations, not a passing assertion that abort is safe.round15-abort.cjs
round15-replay.cjs
round15-replication.cjs
🤖 Generated by OpenAI Codex; posted via @kriszyp.
Skip aborted transaction-log batches in readers and accept their flag…
Skip aborted transaction-log batches in readers and accept their flag…
Skip aborted transaction-log batches in readers and accept their flag…
Skip aborted transaction-log batches in readers and accept their flag…
kriszyp commentedon Nov 3, 2026
Closing as completed. The native hardening this issue tracked shipped in rocksdb-js 2.13.0 and is adopted on
mainfor 5.4.rocksdb-js 2.13.0
transactionLogAbortMarkers; it also addstransactionLogWriteIntents(default off)Harper
Known gap (accepted): retry-dedup certification is kept in memory, per process and per log. After a restart, dedup on a log stays off until that log's pre-restart files are purged, about one audit retention (3 days by default). During that window an increment or CRDT
addcan be double-counted, the same as before #3928.Follow-ups
🤖 Generated by Claude Code (Claude Opus 5.5); posted via @kriszyp.