Skip to content

Fix durable subscriptions skipping records when catch-up replay stops early - #2874

Merged
kriszyp merged 2 commits into
mainfrom
fix/subscription-replay-cursor-after-send
Sep 27, 2026
Merged

kriszyp merged 2 commits into
mainfrom
fix/subscription-replay-cursor-after-send

Conversation

@kriszyp

@kriszyp kriszyp commented Sep 27, 2026 •

Copy link
Copy Markdown
Member

A collection subscription replaying from startTime could leave its resume cursor (subscription.startTime, which durable MQTT sessions persist and resume from with exclusiveStart) past records it never delivered, so a resumed QoS 1+ session silently skipped them. The cursor now moves to a transaction's txnLogKey only 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

  1. Requirement: as specified, widened. The issue asked to advance the cursor after a successful send. The PR makes the cursor transaction-granular instead, because the literal fix still fails two of the new tests on RocksDB (A/B build below). Warranted: yes; this is silent record loss on a durable path, in a beta-only regression. Not superseded: Prototype compact subscription resume checkpoints has no Table.subscribe call sites. Fully reversible: one local variable in one loop.
  2. The cursor can lag by one transaction. The loop publishes key T only when it reaches a record with a later key, or when it finishes. After an early close, a resume re-sends the part of the last transaction that was already sent. That is at-least-once delivery, which QoS 1 already is. The alternative is to also publish at yield/drain returns on LMDB, where keys are per record: that saves a few duplicates but needs engine-specific code in the loop. Cheap to change later.
  3. This is a partial durability fix. The cursor still counts events handed to the subscription queue, not events the client consumed or acknowledged. IterableEventQueue.close() discards queued events, and DurableSubscriptionsSession.saveSubscriptions() persists this cursor while dropping the ack-based startTime and acks. 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.
  4. Declined: advance the cursor over other tables' audit records (raised as major by Gemini; adjudicated as a pre-existing nit). main has the same placement. Advancing on raw records is unsafe without an ordering guarantee, because RocksTransactionLogStore.getRange can 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.
  5. Same exposure, not changed here: single-record startTime replay. 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.
  6. Not repaired by deploying this: a durable session that already persisted an over-advanced cursor on v5.3.0-beta.3 still excludes the skipped record.
  7. Proof boundary: the tests prove the table-level cursor, which is the exact field saveSubscriptions persists (server/DurableSubscriptionsSession.ts:579). They do not prove end-to-end MQTT recovery: no test goes through saveSubscriptions and resume(), and none restarts a process.

Planning review before implementation: Framing-Verdict: chosen-approach-sound (codex, --mode plan, on the transaction-granular design).

Changes

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:

engine keys of ids 1, 2, 3 resume exclusiveStart from key(1) returns
RocksDB one shared key id 4 only
LMDB one key per record ids 2, 3, 4

unitTests/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:

test proves base RocksDB base LMDB fix (both)
send fails after 2 deliveries cursor is the last delivered record; resume starts at the failed one fail fail pass
send fails inside a 3-record transaction resume re-delivers the whole partial transaction (RocksDB) or from the failed record (LMDB) fail fail pass
filter rejects the last record a skipped record still moves the cursor on normal completion (guard for the new code) pass pass pass
close at the replay yield inside a 150-record transaction nothing after the yield is skipped fail pass (keys are per record) pass
close while replay waits for the queue to drain nothing from the last sent record on is skipped fail fail pass
  • Command: npx mocha unitTests/resources/subscriptionReplay.test.js -g "cursor after replay stops early", with and without HARPER_STORAGE_ENGINE=lmdb. The base runs used a git archive origin/main tree with its own dist.
  • A/B: the literal fix (assign the cursor after each send) fails the partial-transaction and yield tests on RocksDB.
  • Gates: test:unit:resources on 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 in integrationTests/server/ollama-backend.test.ts, which needs a local Ollama this machine does not have). lint:required, prettier --check on changed files and check:design-docs are 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-1

Review-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

kriszyp and others added 2 commits September 27, 2026 13:30
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
@kriszyp kriszyp added this to the v5.3 milestone Sep 27, 2026

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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
kriszyp marked this pull request as ready for review September 27, 2026 21:09
@kriszyp
kriszyp merged commit eac35f6 into main Sep 27, 2026
60 of 62 checks passed
@kriszyp
kriszyp deleted the fix/subscription-replay-cursor-after-send branch September 27, 2026 21:09
@claude

claude Bot commented Sep 27, 2026

Copy link
Copy Markdown
Contributor

Reviewed; no blockers found.

Suggestions (non-blocking)

  • resources/Table.ts:6059 — the non-collection (single-record) startTime replay branch sets subscription.startTime = localTime ?? entry?.version before the history built below it is actually sent (send(history[--i], true) at line 6094), so a send() failure mid-history leaves the cursor past undelivered records — the same defect this PR fixes for the collection branch. Reachable in production via a durable MQTT subscription to a non-wildcard topic with QoS>0 (DurableSubscriptionsSession.ts:298,308). The PR body already tracks this as a follow-up (item 5); noting it here so it's not lost.

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>
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.

Subscription replay advances the resume cursor before a record is delivered

1 participant