Repository navigation
Keep a record's version when a message older than it is published - #4384
Conversation
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>
e131b38 to
d46f47b
Compare
…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>
…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>
…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>
| 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( |
There was a problem hiding this comment.
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.
| precedesExistingVersion(txnTime, existingEntry, options?.nodeId) < 0; | ||
| const ownRefs = late | ||
| ? isRocksDB | ||
| ? withAuditRef(existingEntry.additionalAuditRefs, appendedLogKey, options?.nodeId) |
There was a problem hiding this comment.
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.
…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>
⊙ 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).
💡 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
_writePublishcompares withprecedesExistingVersion, 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_writeUpdatedoes.getHistoryOfRecordalso 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.DESIGN.mdstates the invariant;record-locks.mddrops two lines saying a publish rewinds its record.⚖️ Alternatives
🤔 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.
_writeUpdatestill lets a peer replace a record's absent origin.✅ Verification
latePublishVersionRegression.test.js: 23 pass on RocksDB, 13 on LMDB (10 RocksDB-only); on main 18 RocksDB and 7 LMDB fail.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:rocksOpenConfigand LMDB's startTime replay handoff, which fail on main too, and RocksDB'soriginClosedFloor, which passed 4/4 in isolation.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.getafter 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