Skip to content

Keep LMDB source fills from dropping their transaction-log entry or overwriting the source's own write - #3529

Merged
kriszyp merged 7 commits into
mainfrom
fix/source-fill-drop-per-put-ifversion
Oct 27, 2026
Merged

kriszyp merged 7 commits into
mainfrom
fix/source-fill-drop-per-put-ifversion

Conversation

@kriszyp

@kriszyp kriszyp commented Oct 24, 2026 •

Copy link
Copy Markdown
Member
This Is Fine redraw: a dog sips coffee while a padlock arm flips ledger pages off a conveyor into a burning shredder, and an engineer arrives with a wrench

⊙ Problem

On LMDB, refreshing an invalidated cached record from its source drops its transaction-log entry: the log still says invalidate (or omits delete when 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 ifVersion check 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

  • Fills no longer put their own ifVersion on each store write (recordUpdater put options); LMDB's transaction-level condition covers them.
  • On LMDB, a fill yields to any earlier same-key write in its transaction (admission check), such as a source put, update, invalidate or publish. An update() that changes nothing no longer counts.
  • On LMDB, an invalidated record keeps its old local time only while its log entry exists to be replaced (timestamp choice).

⚖️ Alternatives

  • Drop ifVersion only from the log writes: the primary-row check still let the fill's index and log writes land over the source's write.
  • Run the yield on both engines: RocksDB already sees those writes, and would also yield to a source update() not yet saved.
  • Make lmdb-js ignore ifVersion on 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: recordUpdater drops ifVersion from 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: ⚠️ Look hardest: on LMDB only, the fill's commit handler skips when followsAppliedWrite finds 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: removeWrite goes through detachWrite, which also unlinks the write from its key's chain. Only an LMDB update() 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

Test cases
  • The resolved record replaces the invalidation in the log, and the record's local time points to it.
  • A local write that commits while the fill waits on a gated blob save survives. The GET still returns source data first.
  • A failed blob save releases the record, and a later read fills it.
  • An invalidated record is removed on a source miss (delete logged). A concurrent local write survives a miss.
  • The source writes the key it resolves through its context: put (invalidated and first fill), update (applied on top of the fill on RocksDB), invalidate, publish. A skipped stale source write and an update() that changes nothing do not block the fill.
  • LMDB: the primary and log puts run inside the version-conditioned block. An append after the old log entry is pruned does not take another record's entry. A stale condition read is caught by the block.
  • RocksDB: a write committed between the fill's read and its commit wins through the conflict retry.

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

  • 4cb21fc717fe62a3 minor data-integrity — resources/DatabaseTransaction.ts — RocksDB fill discarded on conflict retry leaves its first attempt's put:source log entry durable — NOT FILED
  • 26bb2fcba9a5f76d minor 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 FILED
  • fd827bbc3c0cd196 nit 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 FILED

Late Grok findings

3 surviving (highest minor) — Grok reviewed 57de6b3
  • minor — resources/Table.ts:9679 — On RocksDB, followsAppliedWrite() counts an earlier same-key write that has not run yet as applied. A source update() is staged with deferSave: true (resources/Table.ts:4852). The fill has no deferSave and no chainsStagedState, so addWrite saves it at once (`resources/DatabaseTransacti
  • nit — resources/DESIGN.md:361 — The note says fill store writes "carry no condition of their own". The LMDB unaudited removal passes existingVersion as lmdb-js's positional ifVersion (resources/Table.ts:10138). Rewrite the sentence or name the exception.
  • nit — 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-1

Late Grok findings

3 surviving (highest minor) — Grok reviewed 987fd15
  • minor — resources/Table.ts:10025 — On LMDB, update(id, {name:'local'}) called through the source context while it resolves an invalidated key gets stored as an INVALIDATED stub (the patch-over-stub rule, resources/Table.ts:5580-5584). followsAppliedWrite then makes the fill yield, so the row stays invalidated. The next read r
  • nit — resources/Table.ts:10023 — The comment, and resources/DESIGN.md:368, say LMDB's existingEntry is always the committed record. That is false on the exclusive fallback: resources/LMDBTransaction.ts:317-321 calls getEntry inside the native write transaction after the earlier same-key writes have been applied, so it sees
  • nit — resources/DESIGN.md:361 — The 17-line note leaves the file at 998 lines against a 1000-line budget. DESIGN.md:10 says to prune a file near its ceiling before adding to it. check:design-docs still passes, so the next addition by anyone will fail it

Not 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

kriszyp and others added 5 commits October 24, 2026 07:18
… 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

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

Copy link
Copy Markdown
Contributor

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 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.

Comment thread resources/Table.ts Resolved
kriszyp and others added 2 commits October 25, 2026 22:23
… 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
kriszyp marked this pull request as ready for review October 27, 2026 14:03
@kriszyp
kriszyp merged commit 1691ea0 into main Oct 27, 2026
61 of 62 checks passed
@kriszyp
kriszyp deleted the fix/source-fill-drop-per-put-ifversion branch 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>
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.

1 participant