Repository navigation
Skip a replicated publish the receiving node has already logged, so a restart or reconnect doesn't deliver it twice - #4339
Conversation
a299f1b to
e8e5f81
Compare
kriszyp
left a comment
There was a problem hiding this comment.
Thanks for running this down. Probing the origin's log at the incoming key is the right way to tell a duplicate from a late message, and the restart and reconnect numbers make the case well.
But the premise the per-publish probe rests on: "a late one lowers the record's version". On RocksDB, _writePublish restamps the record unconditionally: recordUpdater stores newVersion as the record's version and log reference (RecordEncoder.ts, "with rocksdb, we simplify to just storing the singular version/timestamp"). A replicated message also runs no precedesExistingVersion check. So a late publish moves the record's version and log reference backward. I think that backward move is the defect, and the duplicates are one symptom of it:
The record then points at an older log entry than the latest message for that topic, until the next publish.
A later replicated put compares its version with the lowered one. Example: a put at 200 is applied, a late publish at 150 lowers the record to 150, then a late put at 170 wins on this node (precedesExistingVersion, txnTime <= existingEntry.version), while the origin keeps the 200 value. We're checking this with a repro now, so treat it as unconfirmed.
If a publish only ever moves the record's version forward (keep the maximum, while still logging and delivering the late message), dedup can check the version first:
a message newer than the record's version is new, so no probe;
one at or below it is late or a duplicate, so probe the origin's log exactly as this PR does.
That takes the 2.3 µs off every in-order publish and makes #4341 unnecessary. It would also cover the "a later-arriving message lowered the record" case listed under "Where a re-sent message is still applied again". The open question is whether walking a topic's message history through its previous-version links still finds a late message once the record's reference never points at it.
I think it would be difficult to justify this significant of performance regression, and so I think we should address the root cause instead. Let me know if you want any help with this. I'm also drafting a PR with a test to reproduce the lower-level issue.
Drafted with a little bit of help from Opus 5.5.
| options?.nodeId, | ||
| options?.version ?? txnTime | ||
| ) && | ||
| (auditHistoryAbortMarked(auditStore, options?.nodeId) || |
There was a problem hiding this comment.
[major] Retained old files also allow duplicate delivery of messages committed into newer files
|
Here is a reproduction demonstrating this is a inbound replication handling issue, not a subscription issue: #4360 |
…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>
e8e5f81 to
6a06693
Compare
… logged Submodule core -> 6a0669337 (HarperFast/harper#4339, stacked on #4384). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
| abortMarkersEnabled() && | ||
| !(retry && write.appendedAuditEntry === true) && | ||
| (existingEntry === undefined | ||
| ? (evictsRecords() || !audit) && auditHistoryAbortMarked(auditStore, options?.nodeId) |
There was a problem hiding this comment.
[major] Newly committed messages can still be delivered twice after restart
The missing-topic trigger from the prior finding remains. On an expiring or evicting audited table, retain an origin's pre-restart file, roll into a new file, commit message A after restart onto a populated topic, then evict that record without purging A's log entry. Re-send A: this branch refuses the lookup because auditHistoryAbortMarked() remains false while the older file survives (resources/auditStore.ts:1913-1922). A is logged and delivered again even though its own batch was written with abort markers and committed in the newer file.
Preserving record versions fixes the late-publish trigger, but cannot supply evidence after eviction removes the record. Scope trust to the matched frame's file/sequence, potentially through a rocksdb-js committed-identity primitive. The discriminating check is that restart/rollover/apply/evict/re-send sequence, expecting one delivery of A. The simulated old-history case at unitTests/resources/replicatedPublishRedelivery.test.js:283-302 retains the topic record, so it does not exercise this branch. This remaining outcome is supported by code tracing; I could not run a database repro in this read-only checkout.
| @@ -11351,10 +11356,30 @@ export function makeTable(options): TableResourceClass { | |||
| (this.constructor as any).source?.publish && !(context as any)?.source | |||
There was a problem hiding this comment.
[major] Newly committed messages can still be delivered twice after restart
(resources/Table.ts:11158 is not part of this PR's diff — anchored to the nearest line this PR's diff can hold)
57c1e38
into
fix/late-publish-keeps-record-version
… logged Submodule core -> 6a0669337 (HarperFast/harper#4339, stacked on #4384). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
⊙ Problem
On 5.4, after a restart, worker restart or replication reconnect, a node delivers replicated MQTT messages to its live subscribers a second time: its resume re-sends the log below the last certified floor (harper-pro#1092), and a re-sent publish was logged and delivered again.
💡 Solution
Stacked on #4384, which keeps a record's version from moving back.
DESIGN.md.⚖️ Alternatives
🤔 Where a re-sent message is still applied again
A duplicate, never a loss: two applies of one origin transaction racing (#4340); an entry purged by audit retention (#4348); markers configured off; a topic record an earlier version moved back, or a deleted topic whose tombstone was cleaned up.
🤔 Decision: one narrow loss case is accepted
5.3 wrote no abort markers. If a 5.3 apply logged a message and failed for good just before the upgrade, and that transaction is re-sent after another write moved its topic's record past it, the message is skipped. Trusting no pre-restart file would close this, but would re-apply every superseded message after each restart until those files are purged.
✅ Verification
replicatedPublishRedelivery.test.js: re-delivery cases fail on main and pass; new cases check that an in-order publish to a held topic and the first publish to a new topic read no log (test).relayedHeadTieDuplicate.test.jsexpects a re-delivered publish to read its entry once.test:unit:resources8,194 pass on RocksDB,test:unit:lmdb6,098; failing in both runs onlyrocksOpenConfig, LMDB's startTime replay handoff and Replay a long run of patches to a record in linear time #4337's bounded-history case, which fail on main too, andoriginClosedFloor, which passes in isolation.Performance: no cost on in-order publishes (min µs/op over 7×20k replicated publishes, two rounds): held topic 17.5–18.0 vs main 17.9–18.0; new topic 16.8–16.9 vs 16.8–16.9. The unstacked check cost 20.3–20.7 on the same held-topic run.
PR QA: skipped @ 6a06693 — dispatch is not available in this session
🤖 Generated by Claude Opus 5.5 (Claude Code); posted via @ldt1996.
Release target: v5.4 — fixes a regression in the 5.4 betas (harper-pro#1092's floor-only resume); 5.3 has no floor-only resume
Framing-Verdict: option-set-too-narrow (c11e475ee1be)
Related PRs: #4384 overlaps (this PR's base: a record's version never moves back); #3137 overlaps (publish delivery in resources/Table.ts); 2 others independent
Complexity: complicated
Review-Coverage: authored=claude; ran=gemini,codex,gemini-carbon; late=gemini-carbon:adjudicated; adjudicated=domain; blocked=cursor-composer(not-installed); declined=cursor-grok,muse; rounds=4; full=2 @ 6a06693
Review-Attention: deep ~30m (critical: Table.ts; decisions: do-less-alternative, requirement-shift, uncertified-history-trust, perf-cost; raised: degraded review) @ 6a06693