Skip to content

Keep a record's version when a message older than it is published - #4384

Merged
kriszyp merged 6 commits into
mainfrom
fix/late-publish-keeps-record-version
Nov 15, 2026
Merged

kriszyp merged 6 commits into
mainfrom
fix/late-publish-keeps-record-version

Conversation

@ldt1996

@ldt1996 ldt1996 commented Nov 13, 2026 •

Copy link
Copy Markdown
Contributor

⊙ Problem

A message older than the record it was published to (replicated late, or published behind a peer's clock) set the record's version back to its own. A later put, patch or delete that should lose to the record's newer write could then win on that node, so nodes held different values for one record (#3637, reproduced by #4360).

❓ Your call: topic replays and getHistoryOfRecord now include late messages on RocksDB, ordered by version. Is version order right for a resuming subscriber?

💡 Solution

A late message is still logged and delivered, but the record keeps its newer version. On RocksDB, history replays reach the late message through the record's refs, so a resuming subscriber still gets it.

🔧 Changes

  • _writePublish compares with precedesExistingVersion, as puts do, and keeps the record's version and origin for a late message; on RocksDB the record refs its entry, as an out-of-order _writeUpdate does.
  • LMDB's out-of-order walk steps past a late message, whose lower version ended the walk before the newer put.
  • Topic replays (replay) and getHistoryOfRecord also read transactions that refs name, keeping only entries written below the record's version then (lateTransactions). Late entries carry the refs before them, so bursts past the record's ~18 refs stay reachable. A replayed late message that commits during the replay is dropped from the queued live events by origin, key and version.
  • Release note; DESIGN.md states the invariant; record-locks.md drops two lines saying a publish rewinds its record.

⚖️ Alternatives

  • Planning review: better alternative exists (adopted). My first plan found late messages through the head lookup, which stops at the head. Adopted: the head lookup is unchanged; only history walks read referenced transactions.
  • Change no history walk: a topic replay then dropped the late message (measured), a loss for an offline session.
  • A log-head field in the record: a format change every reader decodes, for a link refs already carry.

🤔 What this does not repair

A record lowered before this change keeps that version until its next newer write. A late message is still missed inside a transaction the version chain already visited (one origin transaction writing one record at two versions, which replication does not produce); refs into transactions over 100,000 rows refuse a resume. _writeUpdate still lets a peer replace a record's absent origin.

✅ Verification

Cases

#4360's cases as plain tests; #3637's stale delete; a late burst of 30; two late messages in one transaction; a late message in a 3,000-row transaction; a late message keyed above startTime; a replay that reads 5 log entries for the newest of 20 messages (23 without the late-only rule).

  • test:unit:resources: 8,114 pass on RocksDB; test:unit:lmdb: 6,062 pass. Failing in both runs: rocksOpenConfig and LMDB's startTime replay handoff, which fail on main too, and RocksDB's originClosedFloor, which passed 4/4 in isolation.
  • End to end: not observable without injecting a late commit or skewing a node's clock; the tests drive the methods replication and MQTT sessions call.

Performance: no measurable change (RocksDB, µs/op, fix vs main, 3 runs × 2 rounds): replicated publish 17.6–21.8 vs 17.8–22.1, local publish 21.1–23.1 vs 21.0–23.0, Table.get after a late publish 2.60–2.89 vs 2.73–3.08.

PR QA: skipped @ cea269d — dispatch is not available in this session

🤖 Generated by Claude Opus 5.5 (Claude Code); posted via @ldt1996.

Release target: v5.4 — #3637's divergence affects every 5.x; #4339's version-first duplicate check builds on it

Fixes #3637

Framing-Verdict: better-alternative-exists (fc5179505fa2)

Related PRs: #4360 supersedes (its test-only cases are here as plain tests); #4337 overlaps (the same single-record replay loop); #3137 overlaps (subscription replay and delivery state); 4 others independent
Complexity: complicated

Review-Coverage: authored=claude; ran=gemini,codex; adjudicated=domain; blocked=cursor-composer(not-installed); declined=cursor-grok,muse; rounds=5; full=3 @ cea269d

Review-Attention: deep ~55m (critical: Table.ts; decisions: late-keeps-record-version, tie-counts-late, do-less-alternative, requirement-shift, no-heal-of-rewound-records, perf-cost; raised: degraded review) @ cea269d

@ldt1996 ldt1996 added this to the v5.4 milestone Nov 13, 2026
@ldt1996
ldt1996 marked this pull request as ready for review November 13, 2026 21:45
@github-actions
github-actions Bot requested a review from kriszyp November 13, 2026 21:45
A publish set its record's version to its own, so a late replicated message
(or one published behind a peer's clock) moved the version back and a later
put, patch or delete that should lose could win on that node (#3637). The
message now keeps the record's version and origin and is still logged and
delivered; on RocksDB the record refs its entry. LMDB's out-of-order walk
steps past a late message. Topic replays and getHistoryOfRecord follow refs to
late entries only, bounded and pausing mid-transaction; a replayed late
message is dropped from the queued live events by origin, key and version.

Rebased onto #4337's replay: late messages are collected before the resume
check and merged after the walked entries become events. Cases from #4360.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@ldt1996
ldt1996 force-pushed the fix/late-publish-keeps-record-version branch from e131b38 to d46f47b Compare November 13, 2026 21:54
…and test two late publishes in one frame

A checked resume now refuses when a transaction a ref names holds an entry it
cannot read, as the reconcile walk does, instead of certifying it from one
readable match. Adds the two-late-messages-per-transaction variant of the
late-put case on both engines and drops narrating comments.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ldt1996 pushed a commit that referenced this pull request Nov 14, 2026
…king its version first

A checkpoint-grade resume re-sends the origin's log below its certified floor,
so a node re-applied publishes it had already logged and delivered them to its
subscribers again. A replicated publish is now skipped when the origin's log on
this node already holds that message (same key, table, record id and version),
read once per applying transaction. Stacked on #4384, which
keeps a record's version from moving back, so only a message at or below the
record's version is looked up and an in-order publish reads no log. A retry
never skips a publish whose first attempt staged its entry, and in a log file
from before this process only a record vouches for an entry.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Lavinia and others added 4 commits November 14, 2026 19:00
…king its version first

A checkpoint-grade resume re-sends the origin's log below its certified floor,
so a node re-applied publishes it had already logged and delivered them to its
subscribers again. A replicated publish is now skipped when the origin's log on
this node already holds that message (same key, table, record id and version),
read once per applying transaction. Stacked on #4384, which
keeps a record's version from moving back, so only a message at or below the
record's version is looked up and an in-order publish reads no log. A retry
never skips a publish whose first attempt staged its entry, and in a log file
from before this process only a record vouches for an entry.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e the check applies

An exact-start miss below a log's first entry scans to its end, so a
re-delivery there (retention purged it, or a late-relayed entry appended below
the first) is applied again rather than probed, as _writeUpdate's dedup does.
The release note no longer claims every in-order message is free: the first to a
topic with no record is still looked up. DESIGN.md records when a replicated
publish is skipped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… the first-entry shortcut

A log's first appended entry is not a lower bound on its keys, so skipping the
lookup below it delivered a retained late message twice; it is removed. The
exact-start probe repeated the seek the range read makes, so the range read
alone answers whether the origin transaction is logged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…cords

On a table that keeps its records, a topic's record outlives its messages, so a
missing record means a new topic and no logged message: the first publish to it
reads no log. A table that evicts or expires records, or keeps no audit log,
still checks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@kriszyp kriszyp left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Reviewed with Codex

Comment thread resources/Table.ts
const lateOrigins = new Map<any, number>();
if (lateRefs.length > 0 && inspected <= MAX_PREVIOUS_COUNT_SCAN) {
const budget = { remaining: MAX_LATE_SCAN_ENTRIES };
for (const { pause, key, entries, found, capped } of lateTransactions(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

major — Refuse an incomplete referenced transaction before verifying a resume. Consider a topic with messages at 100 and 300, plus a late transaction at 200 containing two messages. If the log iterator stops after the first message, its range reports a failure/corrupt-frame break, but this consumer discards range. This does not set missingVersion, so a checked resume from 150 can resolve resumeVerified to true after omitting a message. That contradicts the complete-replay contract in docs/reference/resources/resource-api.md:316-328.

The sentinel trigger from the prior review is fixed by resources/Table.ts:2363-2383; the iterator-failure trigger remains. A read-only probe of the extracted production generator returned found: true, capped: false for both corrupt-frame and failed-log ranges containing only the first message. Executing the extracted single-record replay loop delivered that partial history without invoking its refusal callback. Please propagate incomplete/failed range status, then refuse checked replay before delivery/verification. The smallest integration check is the two-message late transaction above, terminating its iterator after the first entry and expecting ResumeHistoryUnavailableError with resumeVerified === false. Full subscription execution was not run in the read-only sandbox.

Comment thread resources/Table.ts
precedesExistingVersion(txnTime, existingEntry, options?.nodeId) < 0;
const ownRefs = late
? isRocksDB
? withAuditRef(existingEntry.additionalAuditRefs, appendedLogKey, options?.nodeId)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

major — Keep the preserved audit head directly addressable when adding late refs. When the current head’s log key equals its version, it normally has no head ref. After N late publications, this branch leaves refs to late messages, whose audit entries link back through earlier refs. resolveAuditHeadSteps searches that entire graph before trying the direct head key (resources/Table.ts:2407-2432). Its synchronous wrapper exhausts every step without yielding (resources/Table.ts:2393-2397), and subscription setup invokes it before the new scan budgets apply (resources/Table.ts:10879). A peer clock ahead of a busy publisher can therefore make even a fresh single-topic subscription perform an unbounded synchronous history scan.

A read-only extracted-helper probe, with the stored ref list truncated while preserving its first and last refs, needed 31 lookups after 30 late messages and 3,001 after 3,000. Please retain a directly reachable head ref, or otherwise resolve the preserved head without traversing all late publications. Verify with a head whose key equals its version, a long late-message burst, and previousCount: 1 before any newer publication. The existing burst test advances the head again before reading (unitTests/resources/latePublishVersionRegression.test.js:213-218), which hides this state.

@kriszyp
kriszyp merged commit ec59eb7 into main Nov 15, 2026
46 checks passed
@kriszyp
kriszyp deleted the fix/late-publish-keeps-record-version branch November 15, 2026 02:01
kriszyp pushed a commit that referenced this pull request Nov 15, 2026
…king its version first

A checkpoint-grade resume re-sends the origin's log below its certified floor,
so a node re-applied publishes it had already logged and delivered them to its
subscribers again. A replicated publish is now skipped when the origin's log on
this node already holds that message (same key, table, record id and version),
read once per applying transaction. Stacked on #4384, which
keeps a record's version from moving back, so only a message at or below the
record's version is looked up and an in-order publish reads no log. A retry
never skips a publish whose first attempt staged its entry, and in a log file
from before this process only a record vouches for an entry.

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.

An out-of-order publish rewinds the record's version, letting a stale delete or write through

2 participants