Repository navigation
Keep a newer record's expiry when an older write merges under it - #3774
Merged
Merged
Conversation
Contributor
There was a problem hiding this comment.
Code Review
This pull request ensures that an out-of-order write merged under a newer head preserves the head's expiration time (TTL) rather than overwriting it with a default or stale value, while still logging its own resolved expiry in the audit entry. It updates Table.ts to track and apply this precedence, adjusts RecordEncoder.ts to handle the separate audit expiration metadata, and updates the @expiresAt sweep to respect the stored metadata expiry. Additionally, comprehensive unit tests have been added to verify this behavior across various scenarios, including replicated frames and eviction sweeps. There are no review comments to assess, and I have no feedback to provide.
kriszyp
force-pushed
the
fix/out-of-order-write-keeps-head-expiry
branch
from
October 30, 2026 02:51
3ac1470 to
3465e45
Compare
An out-of-order write (older than the stored record) merged its data under the newer head but replaced the head's expiry with its own: a stale past expiresAt read-hid and evicted a live record, a write with none stripped the head's, and the table default cut an explicit longer one. The record now keeps the head's expiry exactly unless the merge changed the @expiresAt field; the write's own expiry still goes in its audit entry, which replicas and replay apply. Every write to the key in one transaction decides against the same head on both engines. The @expiresAt sweep now decides on the stored deadline reads honor, falling back to the field only for a row stored without one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Dispatch-Task: harper-out-of-order-write-keeps-head-expiry
A RocksDB apply frame can stage a newer write and then an older one to the same key. The older write kept the pre-transaction entry's expiry instead of the staged newer write's; an in-order write that stores a record now becomes the base for later writes to the key. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Dispatch-Task: harper-out-of-order-write-keeps-head-expiry
A write now takes its expiry base from what an earlier write to the key at another version staged, and inherits the base of one at its own version, so a multi-version apply frame ends as separate frames would. The staged entry carries the stored expiry, which replaces the extra base object the previous commit allocated on every in-order write. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Dispatch-Task: harper-out-of-order-write-keeps-head-expiry
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Dispatch-Task: harper-out-of-order-write-keeps-head-expiry
The base choice compared the caller-supplied version option, so two writes in one apply frame stored at the same version counted as different when only one named it. Each write now records the version its commit round ran at. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Dispatch-Task: harper-out-of-order-write-keeps-head-expiry
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Dispatch-Task: harper-out-of-order-write-keeps-head-expiry
Replace the per-write expiry base and version bookkeeping with the record the write's data already merges onto: what an earlier write to the key in the transaction staged (the staged entry now carries its stored expiry), else the stored entry. Nothing extra is stored on an ordinary write, each write decides as if applied alone after the writes before it, and a multi-version apply frame ends as separate frames would. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Dispatch-Task: harper-out-of-order-write-keeps-head-expiry
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Dispatch-Task: harper-out-of-order-write-keeps-head-expiry
…c gains no lines Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Dispatch-Task: harper-out-of-order-write-keeps-head-expiry
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Dispatch-Task: harper-out-of-order-write-keeps-head-expiry
kriszyp
force-pushed
the
fix/out-of-order-write-keeps-head-expiry
branch
from
October 30, 2026 16:50
3465e45 to
c8e957b
Compare
kriszyp
marked this pull request as ready for review
October 30, 2026 21:00
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
Older writes (out-of-order replication, or an explicit older
timestamp) merged fields under newer fields but replaced the record's expiry with their own. A stale pastexpiresAthid a live record; the next read evicted it. A write with noexpiresAtstripped the expiry or cut it to the table default. The@expiresAtsweep also evicted on the raw field when the storedexpiresAtkept the record live.🤔 Is the requirement right?
Yes: expiry follows the fields' last-writer-wins rule unless the merge changed the
@expiresAtfield.Math.maxwould give a record with no expiry the older write's.💡 Solution
@expiresAtfield unchanged keeps the expiry of the record it merged onto, exactly, including none.auditExpiresAtoption), because replicas and crash replay apply it.hasExpired(entry)when a deadline is stored.Refs #3575.
⚖️ Alternatives
🤔 Which record is "the head" inside a transaction (look hardest here)
The record the write merged onto: an earlier same-key write's staged entry, else the stored one.
existingEntrydiffers by engine. So a second older write in one transaction keeps the first one's field renewal even with an explicitexpiresAt, as separate deliveries would.🤔 An older write that changes the
@expiresAtfield still decides the expiryIf the newer record's expiry came from an explicit
expiresAt, an older write that sets the field still replaces it, so delivery order matters, as on main. Fixing it needs the record to store where its expiry came from: #3766.🤔 The audit entry keeps the write's own expiry
Logging the head's expiry instead would give a relayed write a different expiry than its origin logged. A derived audit expiry still comes from the merged record; that predates this PR (#3766).
Other options considered
RecordEncoderwhen a version is reusedexpiresAtfor both the record and the audit entry🔧 Changes
resources/Table.ts: the expiry decision for an older write and theauditExpiresAtit declares and passes toupdateRecord; the stagedexpiresAtthat a later write to the key in the transaction reads comes from main's #3671; and the sweep predicate.resources/RecordEncoder.ts:updateRecordtakes an optionalauditExpiresAt, which sets the audit entry's expiry (append path, and the replacement path) and its presence bit; without it, behavior is unchanged.resources/DESIGN.md: the expiry rule for merged writes, folded into the TTL entry so the file gains no lines, and a corrected note on what the sweep decides on.outOfOrderWriteExpiry.test.js.evictionBlobs.test.jsandcaching-rocks-database.test.jsused to renew a record through an older write'sexpiresAtoption, which no longer renews; they now renew through the@expiresAtfield (or put the expiry on the newer write) and keep their assertions.✅ Verification
outOfOrderWriteExpiry.test.js: RocksDB 14 passing; LMDB 10 passing, 4 RocksDB-only skipped. On origin/main, 7 fail on RocksDB and 5 on LMDB.dede2d3da; the only conflicts were theDESIGN.mdbullet position andTable.ts's staged-entry literal, where main's #3671 already stagesexpiresAt. 13 related test files pass (RocksDB 299, LMDB 182).Full suites, performance, and known gaps
test:unit:resources, RocksDB: 5763 passing, 8 failing. All 8 pass when re-run alone (345/345); they are timeouts on a host at load average ~21.fullTextDerivedIndex, a wall-clock slice deadline with a fake engine and no table writes; it failed 2 of 4 runs on this host).test:integration:all: 3003 passing, 4 failing. All 4 are OllamaBackend tests that need a local Ollama.test:unit:maindid not finish within 55 minutes on this host. The 4 component files that had failures before the cutoff pass 151/151 alone.perf stat, best of 3 underbench-run: origin/main 32.61 G vs branch 32.56 G user instructions (36.9 vs 35.7 µs/op CPU), within the 6% run-to-run spread. Unaudited tables not measured.@expiresAtsweep never reaches a field stored as a date string, and its field fallback still evicts a row stored with no expiry. Both predate this PR and are in The @expiresAt sweep never evicts a record whose field is a date string #3765. The sweep still cannot run on RocksDB (@expiresAt per-record TTL never evicts on RocksDB (default engine) — index.getValues() is LMDB-only #1481); the sweep tests stubgetValues.PR QA: Does a merged out-of-order write keep the newer record's expiry across a crash replay? — queued @ 4988975, qa-pr-harper-3774-4988975
Docs: none needed; the documented
@expiresAtandexpiresAtbehavior is unchanged.🤖 Generated by Claude Opus 5.5 (Claude Code dev-agent); posted via @kriszyp.
Release target: v5.4 — fixes current behavior on main; no customer need for an older-line backport is known
Related PRs: #2155 overlaps (both change the @expiresAt sweep predicate), #3671 overlaps (merged; its staged
expiresAtis kept), 3 others independentComplexity: complicated
Deferred findings
fb657eab6be8572emajor data-integrity —resources/Table.ts— Older field-changing write can override a newer head's explicit expiry — NOT FILED48b59b13c48d1032minor data-integrity —resources/Table.ts— audit expiry derived from merged record's head @expiresAt field — filed An out-of-order write's audit entry logs the newer record's @expiresAt deadline as its own #3766f4a4b633e56e3ceaminor correctness —resources/Table.ts— sweep fallback compares raw @expiresAt field now when no stored expiry, wider than fieldExpiresAtMs; evicts a row reads treat as live — filed The @expiresAt sweep never evicts a record whose field is a date string #376542f0460684266478nit maintainability —resources/Table.ts— Existing expiry-precedence comment repeats the design note — NOT FILEDLate Grok findings
4 surviving (highest major) — Grok reviewed b60281d, ruled against 9c5f795
resources/Table.ts:6812— every in-order staged put/patch allocates a second{ value, expiresAt }object, held onwrite.expiryBaseuntil transaction cleanup (100k-row transaction → 100k extra retained objects). No measurement for this path;write.stagedEntry(resources/Table.ts:6801-6807) could carryexpiresAtinstresources/Table.ts:6812— the base advances only whenprecedesExisting >= 0. Scenario: in one RocksDB multi-version frame, an older write changes the@expiresAtfield and stores the precedence-resolved deadline. A later older write to the same key still compares against the earlier base, so it can restore the head's eresources/Table.ts:6650— a derived audit expiry is read from the merged record, so the audit entry can log the head's@expiresAtdeadline instead of the write's own.resources/DESIGN.md:132now documents this; that narrows the contract but does not change the behavior.resources/Table.ts:12635— the added comments restate nearby code. Sites:resources/Table.ts:12635,resources/DatabaseTransaction.ts:765,unitTests/resources/outOfOrderWriteExpiry.test.js:1-3,unitTests/resources/outOfOrderWriteExpiry.test.js:48,unitTests/resources/evictionBlobs.test.js:226-227. The comments on theNot standing: 1 resolved, 1 merged, 1 separate-issue. Grok ran 27m (27m from launch to report); the fold adjudication (
cursor-grok@b60281def34f-29b424a4, claude) took 1m.Origin — the dispatch brief this PR was written from
(1) In Table._writeUpdate (resources/Table.ts ~5724 and ~6646) an older out-of-order write (precedesExisting < 0) sets expiresAt from options/context/table default, overwriting or stripping the newer head's existingEntry.expiresAt (read-hiding or evicting a live record). When precedesExisting < 0 and the merged @expiresAt field did not change, keep the head's stored expiry exactly (existingEntry.expiresAt, including 'none'); do not use Math.max, which would give a no-expiry head the older write's expiry. (2) runRecordExpirationEviction (~12618) decides expiry from the raw field value; use hasExpired(recordEntry) when recordEntry.expiresAt is defined, falling back to the field value. Do not attempt ISO-string @expiresAt eviction in this PR (unproven reachability; mention it as a follow-up if you confirm it).
Acceptance
Staged test passes 5/5 on rocksdb and lmdb; existing evictionBlobs and expiresAtAttribute suites stay green. Show each new test failing on origin/main sources (forced rebuild) before the fix.
Dispatch: task
harper-out-of-order-write-keeps-head-expiry· queued by finding-triage/wave-43 · ran by claude/opus/xhigh · worker kzyp-xps-1Late Grok findings
2 surviving (highest major) — Grok reviewed 3465e45
resources/Table.ts:6715— An older out-of-order patch that adds the@expiresAtfield and passes an explicitexpiresAt: 0stores expiry 0 over the newer head's live expiry.fieldExpiresAtMsreturns a number for the merged field andNaNfor the head that has no field, so theObject.ischeck is false and the keep-headresources/Table.ts:12863— When a row has no stored expiry metadata, the sweep falls back tovalue[expiresAtName] < Date.now(). That test is wider thanfieldExpiresAtMs(resources/Table.ts:13022), which rejects negative values. Example: a put of{deadline: -1}stores no expiry (resources/Table.ts:6711), reads stiNot standing: 2 separate-issue. Grok ran 19m (19m from launch to report); the fold adjudication (
cursor-grok@3465e4506259-e50ecd09, claude) took 1m.Review-Coverage: authored=claude; ran=cursor-composer,gemini,codex,cursor-grok; late=cursor-grok:adjudicated; adjudicated=domain,domain(codex); declined=muse; rounds=11; full=6 @ c8e957b
Review-Attention: study ~13m (critical: RecordEncoder.ts, Table.ts; decisions: expiry-owner, do-less-alternative) @ c8e957b