Repository navigation
Fix durable subscriptions skipping records when catch-up replay stops early - #2874
Merged
Merged
Conversation
Collection startTime replay moved subscription.startTime to a record's txnLogKey before sending it, so a send that failed (subscription closed mid-replay) left the cursor past an undelivered record, and a durable resume with exclusiveStart skipped it. RocksDB also gives every record of a transaction the same txnLogKey, so advancing after each send was still wrong: an early return inside a multi-record transaction (a failed send, or a close at the 100-record replay yield) left the cursor on a key whose remaining records a resume excludes. The loop now moves the cursor to a key only when it reaches a record with a different key, and to the last handled key when the replay completes; an early return leaves it before a partly delivered transaction, whose sent part a resume re-delivers. Fixes #2856 Dispatch-Task: harper-2856-replay-cursor-before-send Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RiA9paYnGjRZ7RWvZJMu6x
…or tests Addresses pre-push review round 1: the resume helper waits for the ids a test expects instead of a minimum count (RocksDB re-sends a whole partial transaction, so a count cut the yield test short on a slow runner), a new test closes the subscription while replay waits for its queue to drain, and the design note says the cursor can lag up to one transaction. Dispatch-Task: harper-2856-replay-cursor-before-send Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RiA9paYnGjRZ7RWvZJMu6x
Contributor
There was a problem hiding this comment.
Code Review
This pull request updates the subscription replay logic in Table.ts to ensure that the resume cursor (subscription.startTime) does not advance past a transaction until all of its records have been successfully handled, preventing partially delivered transactions from being skipped upon resumption. These changes are documented in DESIGN.md and thoroughly verified with new unit tests in subscriptionReplay.test.js. There are no review comments to address, and I have no additional feedback to provide.
kriszyp
marked this pull request as ready for review
September 27, 2026 21:09
github-actions
Bot
requested review from
Ethan-Arrowood,
cb1kenobi and
heskew
September 27, 2026 21:09
Contributor
|
Reviewed; no blockers found. Suggestions (non-blocking)
|
dawsontoth
added a commit
that referenced
this pull request
Sep 28, 2026
One conflict, in the startTime replay loop: #2874 wrapped the send in `if (event)` and moved the cursor to handledTxnLogKey; this branch returns when a rejected event closed the subscription. Kept both: the rejected-event branch is now the `else` of #2874's `if (event)`. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This was referenced Sep 28, 2026
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.
A collection subscription replaying from
startTimecould leave its resume cursor (subscription.startTime, which durable MQTT sessions persist and resume from withexclusiveStart) past records it never delivered, so a resumed QoS 1+ session silently skipped them. The cursor now moves to a transaction'stxnLogKeyonly after every in-table record of that transaction has been sent or deliberately skipped. Fixes #2856. The regression came from Skip superseded subscription updates during catch-up and ships only in v5.3.0-beta.3.The issue proposed advancing the cursor after each successful send. That is not enough on RocksDB, the default engine: every record of one RocksDB transaction carries the same
txnLogKey, and a resume excludes that key entirely, so replay that stops inside a multi-record transaction (a failed send, or a close at the 100-record replay yield) would still lose the rest of that transaction. Measurements are under Verification.For the human reviewer
Table.subscribecall sites. Fully reversible: one local variable in one loop.IterableEventQueue.close()discards queued events, andDurableSubscriptionsSession.saveSubscriptions()persists this cursor while dropping the ack-basedstartTimeandacks. A resumed QoS 1 session that subscribes to another topic mid-replay can therefore still lose queued, unacknowledged records (code trace, medium confidence). That belongs to the durable-checkpoint work and is reported as a follow-up.mainhas the same placement. Advancing on raw records is unsafe without an ordering guarantee, becauseRocksTransactionLogStore.getRangecan discover a new origin log mid-scan whose keys are older. Cost of leaving it: after an early stop, a sparse subscriber re-scans other tables' entries on resume, bounded by audit retention.startTimereplay. The non-collection branch sets the cursor to the record's current version before sending its history, which its live-listener gate needs. A failed history send therefore leaves the cursor past undelivered history. The fix has a different shape (restore the cursor on each early return), so it is out of this task's scope and reported as a follow-up.saveSubscriptionspersists (server/DurableSubscriptionsSession.ts:579). They do not prove end-to-end MQTT recovery: no test goes throughsaveSubscriptionsandresume(), and none restarts a process.Planning review before implementation:
Framing-Verdict: chosen-approach-sound(codex,--mode plan, on the transaction-granular design).Changes
resources/Table.ts, collectionstartTimereplay loop: a local tracks the key of the last handled record; on reaching an in-table record with a different key the loop publishes the previous key; out-of-scope records, recordseventFromAuditdrops, and records a filter rejects while the subscription stays active now fall through to the handled mark instead ofcontinue; the final key is published only on normal completion, outsidefinally. Look hardest at lines 5913–5928: noreturninside the loop writes the cursor.resources/DESIGN.md: the cursor rule, its one-transaction lag, and the queue-versus-consumed limit, added to "Subscription version selection".Verification
End-to-end route: not observable through MQTT without a filter that throws, or a subscription that closes and stays in a durable session until the next
saveSubscriptions. The unit tests drive the exact loop and read the exact field that is persisted.RocksDB key sharing, measured on this branch (rocksdb-js 2.10.0, Node 26.2.0), one transaction writing ids 1–3 and then a put of id 4:
exclusiveStartfrom key(1) returnsunitTests/resources/subscriptionReplay.test.js, "cursor after replay stops early". Each test subscribes the way a durable QoS 1+ session does (includeSuperseded: true), stops the replay, then re-subscribes from the stored cursor:npx mocha unitTests/resources/subscriptionReplay.test.js -g "cursor after replay stops early", with and withoutHARPER_STORAGE_ENGINE=lmdb. The base runs used agit archive origin/maintree with its owndist.test:unit:resourceson RocksDB (3251 passing, 0 failing) and on LMDB (2467 passing, 0 failing);test:unit:main(6045 passing; its one failure,gitCredentials.test.js:395, comes from this machine's dispatch-daemon git environment and passes 19/19 with that environment unset);test:integration:all(2213 tests: 2189 pass, 0 fail, 18 skipped; 6 cancelled, all inintegrationTests/server/ollama-backend.test.ts, which needs a local Ollama this machine does not have).lint:required,prettier --checkon changed files andcheck:design-docsare clean.🤖 Generated with Claude Code
https://claude.ai/code/session_01RiA9paYnGjRZ7RWvZJMu6x
— Claude Opus 5.5
Complexity: complicated
Origin — the dispatch brief this PR was written from
Fix harper#2856: table-level subscription replay (the startTime branch in resources/Table.ts, around line 5911 on main) sets subscription.startTime = auditRecord.txnLogKey BEFORE the record is delivered, so when send(event) returns false the loop returns with the resume cursor already past the undelivered record, and a durable subscription resuming from startTime with exclusiveStart:true silently skips it. Move the cursor advance so it happens only after a successful send(), or when the record is skipped (out of scope for thisId, or eventFromAudit returns nothing). Base branch: main.
Acceptance
A unit test that makes send() fail mid-replay for a durable subscription and asserts that resuming from the stored startTime delivers the record that failed; the test fails on current main and passes with the fix. Existing subscription/MQTT replay tests stay green. No change to non-replay (real-time) delivery or to subscriptionResumeLog.
Dispatch: task
harper-2856-replay-cursor-before-send· queued by kriszyp-session/dlc-postmortem · ran by claude/opus/xhigh · worker kzyp-xps-1Review-Coverage: authored=claude; ran=codex,gemini; adjudicated=domain; declined=cursor-grok,cursor-composer,cursor-kimi,cursor-muse; rounds=2; full=1 @ 31e879a
Human-Review-Need: 3 (decisions: cursor-publish-on-key-change, fix-at-replay-vs-ack-checkpoint) @ 31e879a