Repository navigation
Keep LMDB source fills from dropping their transaction-log entry or overwriting the source's own write - #3529
Merged
Merged
Conversation
… the source's own write An invalidated record's source fill put a per-record ifVersion on its audit puts. The LMDB audit store is unversioned, so the check compared the wrong bytes and silently dropped the resolved put (or source-miss delete) from the transaction log. The primary put's copy only duplicated the enclosing ifVersion block, except when the source wrote the same key through its context: then it blocked the row but left the fill's index entry, and a first fill overwrote that write. The fill's commit handler now yields to an earlier same-key write staged in its transaction (priorStagedWrite), matching what RocksDB already did by reading through the transaction, and recordUpdater no longer adds the per-put condition. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017gfruVFyCm4swyps5CX9MH Dispatch-Task: proposal-kriszyp-td-2026-09-11-resolve-source-double-ifversion-ae8e86
…udit append by its own time priorStagedWrite missed a publish (which restamps without staging) and a RocksDB update() whose deferred save had not run; the fill now yields to any earlier same-key write in its transaction that was not skipped. On LMDB, removing the per-put ifVersion let the fill append an audit entry when the invalidation's entry was gone; that entry was keyed by the last assigned time while the primary kept its old local time, so it could overwrite another record's entry. An invalidated record now keeps its local time only while that entry exists to be replaced. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017gfruVFyCm4swyps5CX9MH Dispatch-Task: proposal-kriszyp-td-2026-09-11-resolve-source-double-ifversion-ae8e86
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017gfruVFyCm4swyps5CX9MH Dispatch-Task: proposal-kriszyp-td-2026-09-11-resolve-source-double-ifversion-ae8e86
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017gfruVFyCm4swyps5CX9MH Dispatch-Task: proposal-kriszyp-td-2026-09-11-resolve-source-double-ifversion-ae8e86
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017gfruVFyCm4swyps5CX9MH Dispatch-Task: proposal-kriszyp-td-2026-09-11-resolve-source-double-ifversion-ae8e86
Contributor
There was a problem hiding this comment.
Code Review
This pull request refactors and documents how conditional writes and local timestamps are handled during source fills on LMDB and RocksDB, ensuring that source fills yield to earlier same-key writes and that audit entries are correctly managed. It also adds a comprehensive test suite to verify these race conditions. Feedback on the changes identifies an issue in the followsAppliedWrite helper function, where checking !prior.skipped is insufficient for deferred updates that were never saved, and suggests checking prior.saved && !prior.skipped instead to ensure only actually applied writes are yielded to.
… write chain RocksDB reads the fill's commit base through its own transaction, so main's version comparison already sees a source write to the same key; only LMDB needs the yield. On LMDB, a source update() that changes nothing is removed at validation, but LMDBTransaction.removeWrite left it in the per-key chain, so the fill counted it as an applied write and the record stayed invalidated. removeWrite now goes through detachWrite, which repairs the chain. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…the optimistic path Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
kriszyp
marked this pull request as ready for review
October 27, 2026 14:03
kriszyp
added a commit
that referenced
this pull request
Oct 27, 2026
#3529 added the same predecessor walk under that name. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Dispatch-Task: pr-maint-563ceb51d0967c6c19dbcab60690aaa4
kriszyp
added a commit
that referenced
this pull request
Oct 27, 2026
#3529 added the same predecessor walk under that name. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Dispatch-Task: pr-maint-563ceb51d0967c6c19dbcab60690aaa4
kriszyp
added a commit
that referenced
this pull request
Oct 28, 2026
…eted records or applying increments twice (#3506) * Stop out-of-order patches on audit:false tables from resurrecting deleted records or re-applying increments On an audit:false table, a patch older than (or tied with) the stored record went through remainingAgainstRecord with no further checks, so: - a patch older than a delete tombstone or a publish to an absent key recreated the record (indexed, with its blobs kept); - a re-delivery at the head's own version re-applied its `add` ops; - a re-delivered out-of-order patch re-applied its ops, because the record's additionalAuditRefs were neither consulted nor carried forward. The branch now skips a tie, a head holding no record, and a write whose (txnTime, nodeId) the head already lists; a merge carries the head's refs forward and adds its own identity. resolveAuditHead ignores refs on an unaudited record, since they address no log entry. LMDB still stores a merge at the merging write's older version (#3388): its entry version is also the ifVersion token LMDBTransaction commits on, so keeping the head's version lets a write staged against the pre-merge entry overwrite the merge. A test pins that. Fixes #3385 Refs #3388 Dispatch-Task: harper-noaudit-patch-tie-null-dup-guards Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Order a patch after a same-transaction publish, and key unaudited refs by the received log key A publish or relocate restamps the head without staging it, so on RocksDB a patch that follows one in the same transaction ties the head (or meets a publish's null value) and the new guards dropped it. They now apply only when every earlier write to the key in the transaction was skipped, as the audited head-tie check does. The identity folded into an unaudited record is the received log key — the origin's log key for an apply, txnTime for a local write — so two applied writes sharing a record version stay distinct. resolveAuditHead no longer ignores refs on unaudited tables: a publish stores real log-key refs there. Dispatch-Task: harper-noaudit-patch-tie-null-dup-guards Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Apply an audit:false write that ties a same-transaction publish in program order A publish or relocate restamps the head without staging it, so on RocksDB a later write to the key in the same transaction ties the head. On an audit:false table that tie sent a put to the "loses" branch and a patch through remainingAgainstRecord, dropping the put and every plain field of the patch. Such a tie now applies as an in-order write; the audited walk already ordered it by log key. Dispatch-Task: harper-noaudit-patch-tie-null-dup-guards Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Record an unaudited receipt only for a merge that folds in a commutative op A re-delivered out-of-order patch can only double-apply its commutative ops; the plain keys it merged are held by the record afterwards and remainingAgainstRecord drops them. Receipts are walked as log keys once auditing is turned on (a subscribe does that), each a transaction-log miss of 0.3-1 ms on a 60k-entry log here, so they are kept to the merges they protect. Dispatch-Task: harper-noaudit-patch-tie-null-dup-guards Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Trim the unaudited out-of-order branch comments Dispatch-Task: harper-noaudit-patch-tie-null-dup-guards Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Restore priorWritesSkipped for the audit:false program-order tie arms still use it. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Dispatch-Task: pr-maint-563ceb51d0967c6c19dbcab60690aaa4 * Pin that a patch before a same-transaction publish still applies on RocksDB Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Dispatch-Task: pr-maint-563ceb51d0967c6c19dbcab60690aaa4 * Drop the source-applied patch-before-publish test: it fails on main too Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Dispatch-Task: pr-maint-563ceb51d0967c6c19dbcab60690aaa4 * Keep unaudited re-delivery receipts in receivedIdentityRefs, not the log-key list Dispatch-Task: pr-maint-563ceb51d0967c6c19dbcab60690aaa4 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * Name receivedIdentityRefs in the no-audit skip list Dispatch-Task: pr-maint-563ceb51d0967c6c19dbcab60690aaa4 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * Apply a replicated audit:false patch whose source transaction publishes the key after it A source-applied patch is deferred to commit, while a publish or relocate of the same key saves when it is added and restamps the head at the origin's id and version. The patch then tied that head with no earlier unskipped write, so the no-audit branch dropped it as a re-delivery (count 10 instead of 14). The program-order check now also applies the tie when the committed head (identityBase) does not tie; a re-delivered transaction still ties it and skips. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Dispatch-Task: pr-maint-563ceb51d0967c6c19dbcab60690aaa4 * Order a restamped audit:false tie against the committed head, not just away from it The previous commit applied the tie in program order whenever the committed head did not tie, including a committed head that is strictly newer: an older replicated patch then overwrote its plain fields. Compare once against the committed head (identityBase) and carry the result: older head applies in program order, newer head takes the ordinary out-of-order merge, a tie skips. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Dispatch-Task: pr-maint-563ceb51d0967c6c19dbcab60690aaa4 * Order every restamped audit:false tie against the committed head on RocksDB A publish or relocate before a replicated patch is an unskipped predecessor, so the tie went straight to program order and an older source transaction overwrote a newer head's plain fields; a re-delivered [publish, patch] also re-applied its increment. On RocksDB the tie is now always decided against identityBase's committed head, wherever the publish sits. LMDB commits pass no committedBy, so identityBase cannot see past the restamp there and LMDB keeps the predecessor exception. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Dispatch-Task: pr-maint-563ceb51d0967c6c19dbcab60690aaa4 * Reuse main's followsAppliedWrite for the audit:false tie checks #3529 added the same predecessor walk under that name. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Dispatch-Task: pr-maint-563ceb51d0967c6c19dbcab60690aaa4 --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
⊙ Problem
On LMDB, refreshing an invalidated cached record from its source drops its transaction-log entry: the log still says
invalidate(or omitsdeletewhen the source misses). History and LMDB replication read that log.Also on LMDB, a source write to the key being resolved gets overwritten by the fill or leaves a stray index entry. RocksDB has neither problem.
🤔 Is this the right scope?
The task asked whether a duplicate
ifVersioncheck could go. That check caused the lost log entries, and removing it exposed the same-key case, so this PR fixes both, on LMDB only. RocksDB's fill path is unchanged: rocksdb-js never read the dropped option.💡 Solution
ifVersionon each store write (recordUpdaterput options); LMDB's transaction-level condition covers them.update()that changes nothing no longer counts.⚖️ Alternatives
ifVersiononly from the log writes: the primary-row check still let the fill's index and log writes land over the source's write.update()not yet saved.ifVersionon unversioned stores: a dependency contract change for Harper's wrong token.🤔 Should the source's own write win over its fill?
On LMDB, yes: the reader gets the source's value and the row keeps the source's write. A source
update()to an invalidated key leaves an invalidated stub the next read refills, as on main but without the stray index entry. RocksDB applies that update on top of the fill, as on main.🔧 Changes
resources/RecordEncoder.ts:recordUpdaterdropsifVersionfrom the primary put and both log puts. On LMDB it keeps the invalidated record's local time only when the log entry to replace exists.resources/Table.ts:followsAppliedWritefinds an earlier same-key write that was not skipped. The stale TODO is gone. The Fix sourcedFrom cache-fill conflict convergence #2065 version comparison is unchanged.resources/LMDBTransaction.ts:removeWritegoes throughdetachWrite, which also unlinks the write from its key's chain. Only an LMDBupdate()that changes nothing is removed this way, so a later write to that key in the same transaction no longer treats it as a predecessor.resources/DESIGN.md: who enforces a fill's condition on each engine, the LMDB-only yield, and the local-time rule.✅ Verification
unitTests/resources/sourceFillConditionalWrite.test.jsruns on both engines (15 LMDB, 13 RocksDB). Against a merge-base build, all 13 RocksDB cases pass and 9 LMDB cases fail.removeWrite.test:unit:resourcesat f66735f: 4706 passing on RocksDB. On LMDB, 3565 passing; 1 timing failure insubscriptionReplay.test.js(no source fill) passed 3 of 3 alone.test:unit:main7765 passing; 24 integration CI shards green;caching.test.js,writeTargetReads.test.js,txn-tracking.test.jsand the A write after ensureLoaded() on a RocksDB caching table is overwritten by the background source fill #2935 reproduction match main.Test cases
deletelogged). A concurrent local write survives a miss.put(invalidated and first fill),update(applied on top of the fill on RocksDB),invalidate,publish. A skipped stale source write and anupdate()that changes nothing do not block the fill.PR QA: Do concurrent invalidate, GET and PUT on an LMDB caching table keep rows, indexes and the transaction log consistent across HTTP workers? — queued @ 9451dbc, qa-pr-harper-3529-9451dbc
Docs: none needed. No API, config or documented behavior changes.
🤖 Generated by Anthropic Claude Code (Claude Opus 5.5); posted via @kriszyp.
🤖 Generated with Claude Code
https://claude.ai/code/session_017gfruVFyCm4swyps5CX9MH
Release target: main — no backport evidence; the LMDB log loss predates the shallow history, so a v5.3 cherry-pick is a customer-need call
Related PRs: #2155 overlaps (also edits the fill's commit handler), #3527 overlaps (adds residency capture to the fill's commit handler), 8 others independent
Complexity: complicated
Deferred findings
4cb21fc717fe62a3minor data-integrity —resources/DatabaseTransaction.ts— RocksDB fill discarded on conflict retry leaves its first attempt's put:source log entry durable — NOT FILED26bb2fcba9a5f76dminor correctness —resources/Table.ts— LMDB fill yields to a source-context update() on an invalidated key; the stub is replaced by the next refill, unlike RocksDB — NOT FILEDfd827bbc3c0cd196nit data-integrity —resources/RecordEncoder.ts— LMDB fill audit lookup precedes the native batch; a retention prune in between lets the replace put re-create the pruned key — NOT FILEDLate Grok findings
3 surviving (highest minor) — Grok reviewed 57de6b3
resources/Table.ts:9679— On RocksDB,followsAppliedWrite()counts an earlier same-key write that has not run yet as applied. A sourceupdate()is staged withdeferSave: true(resources/Table.ts:4852). The fill has nodeferSaveand nochainsStagedState, soaddWritesaves it at once (`resources/DatabaseTransactiresources/DESIGN.md:361— The note says fill store writes "carry no condition of their own". The LMDB unaudited removal passesexistingVersionas lmdb-js's positionalifVersion(resources/Table.ts:10138). Rewrite the sentence or name the exception.resources/Table.ts:9677— The helper's comment names relocate as an applied prior write, but no new test covers a same-key relocate before the fill.Not standing: 1 separate-issue. Grok ran 13m (13m from launch to report); the fold adjudication (
cursor-grok@57de6b30bd40-4040a58d, claude) took 1m.Origin — the dispatch brief this PR was written from
Prove whether the per-put ifVersion on sourced cache fills is redundant, then remove it
Dispatch: task
proposal-kriszyp-td-2026-09-11-resolve-source-double-ifversion-ae8e86· queued by unknown · ran by claude/opus/high · worker kzyp-xps-1Late Grok findings
3 surviving (highest minor) — Grok reviewed 987fd15
resources/Table.ts:10025— On LMDB,update(id, {name:'local'})called through the source context while it resolves an invalidated key gets stored as anINVALIDATEDstub (the patch-over-stub rule,resources/Table.ts:5580-5584).followsAppliedWritethen makes the fill yield, so the row stays invalidated. The next read rresources/Table.ts:10023— The comment, andresources/DESIGN.md:368, say LMDB'sexistingEntryis always the committed record. That is false on the exclusive fallback:resources/LMDBTransaction.ts:317-321callsgetEntryinside the native write transaction after the earlier same-key writes have been applied, so it seesresources/DESIGN.md:361— The 17-line note leaves the file at 998 lines against a 1000-line budget.DESIGN.md:10says to prune a file near its ceiling before adding to it.check:design-docsstill passes, so the next addition by anyone will fail itNot standing: none. Grok ran 16m (16m from launch to report); the fold adjudication (
cursor-grok@987fd1591e98-b3c7c30f, claude) took 2m.Review-Coverage: authored=claude; ran=cursor-composer,gemini,codex; adjudicated=domain; declined=cursor-grok,muse; rounds=7; full=5 @ f66735f
Review-Attention: study ~15m (critical: LMDBTransaction.ts, RecordEncoder.ts +1; decisions: lmdb-yields-to-source-write, batch-admission-only, fresh-local-time-when-audit-missing, detach-applies-to-all-lmdb-noops, design-note-drops-engine-caveats) @ f66735f