Repository navigation
Per-origin subscription checkpoints that resume on any node - #3356
Merged
Merged
Conversation
Contributor
There was a problem hiding this comment.
Code Review
This pull request implements per-origin subscription checkpoints for RocksDB-backed tables, enabling compact checkpoints that bind origin sets with a digest. It introduces the subscriptionCheckpoint.ts module to handle checkpoint validation, encoding, and admission planning, and integrates this mechanism into Table.ts, transactionBroadcast.ts, and DurableSubscriptionsSession.ts to support checkpoint-resumed MQTT durable sessions. Additionally, WAL syncing is added to ensure origin-closed floors are power-loss durable before being advertised. Comprehensive unit tests have been added to verify these changes. I have no feedback to provide as there are no review comments.
This was referenced Oct 20, 2026
kriszyp
marked this pull request as ready for review
October 20, 2026 22:23
dawsontoth
approved these changes
Oct 21, 2026
kriszyp
force-pushed
the
feat/subscription-origin-checkpoints-core
branch
from
October 23, 2026 02:13
f989077 to
b9f5ddf
Compare
kriszyp
force-pushed
the
feat/subscription-origin-checkpoints-core
branch
from
October 23, 2026 03:50
b9f5ddf to
6d4a1eb
Compare
This was referenced Oct 23, 2026
Merged
A reportProgress subscription on RocksDB issues a compact checkpoint
`{ v, scope, time, overlap, exceptions, members }` whose member set is
bound by a digest rather than a list, and `subscribe({ resume })`
admits it on any node: membership, coverage, revocation and retention
are checked, each member's log is sought inclusively at its boundary,
and live delivery is ungated so a transaction committed below the
checkpoint is delivered (harper#2928). Boundaries come from floors the
broadcaster captures before a notify pass and certifies once it
drains; the floor is advertised only after rocksdb-js syncWal().
MQTT durable sessions keep the token beside the scalar position.
harper-pro#922 items 3–4.
Dispatch-Task: hp-922-subscription-checkpoints-resume
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JecFqDDkuL5xZoe9sAQLto
…eceipts under the token From the pre-push review: certifiedFrom is read only from whole routes and a late joiner replays from zero only over one; the own floor qualifies only on an engine with syncWal; the admission epoch is a shared word bumped after the seq row commits; every certified subscription is ungated once live, so a scalar-resumed session's late lower-key commit is delivered before a floor certifies it; a new origin generation starts an origin over instead of leaving it revoked for good, and a completed copy clears a hole. MQTT prunes and compacts receipts by the checkpoint floor, checks a foreign checkpoint before CONNACK, keeps origins on resends and falls back to the scalar on a re-SUBSCRIBE too; the scope names collection or record, onlyChildren is honoured on replay, and checkpointInterval is bounded. Dispatch-Task: hp-922-subscription-checkpoints-resume Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JecFqDDkuL5xZoe9sAQLto
…the seq row The apply loop recorded a sequence update only when it advanced the sequence or carried cursors or floors, so an update that carried only a certifiedFrom mark, a hole or a revocation — the one a receiver sends after a resume request, since a downgraded peer sends none — was consumed and lost. Pinned in replicationSeqCursor.test.js with the qualifier lifetimes: a revocation sticks under a higher certificate, a new origin generation starts the origin over, a copy clears a hole. Dispatch-Task: hp-922-subscription-checkpoints-resume Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JecFqDDkuL5xZoe9sAQLto
…receipts from the first token From the second pre-push round: certifiedFrom is the lowest whole-route start of the qualifying generation; a certificate without a generation clears a route's level-2 qualifiers; a new generation also clears a hole and keeps the certificate's own floor; numeric keys and a collection target without a trailing slash replay direct children; the retention recheck compares the lowest certified start (a log is read in physical order); re-admission after an epoch bump skips the retention test; scalar receipt pruning stops below the checkpoint floor from the call that establishes the first token; record topics are checked before CONNACK; the epoch bump cannot fail the commit; one checkpoint floor per PUBREC. Pinned: no token on an engine without syncWal. Dispatch-Task: hp-922-subscription-checkpoints-resume Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JecFqDDkuL5xZoe9sAQLto
…ed copy marks, one checkpoint per pass From the third pre-push round: certifiedFrom comes from the route that certified the floor (whole routes can cover disjoint intervals), while a late joiner's whole-log replay needs some whole route from the origin's first entry; a repeated copy-completion mark clears a hole; the replay rechecks that zero-start logs keep their first segment; the durable session computes a topic's checkpoint once per pass and derives the floor from it; the no-syncWal test carries a positive control. Dispatch-Task: hp-922-subscription-checkpoints-resume Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JecFqDDkuL5xZoe9sAQLto
…ry build per PUBREC From the fourth pre-push round: a route mark or revocation names the origin generation it was derived under and is ignored once the row's generation moved on; revocations, holes and whole-route starts count only from rows of the generation whose floor qualifies, so a dead peer's row cannot hold a restored origin back; a PUBREC builds the boundary vector once and the position advance reuses it. Dispatch-Task: hp-922-subscription-checkpoints-resume Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JecFqDDkuL5xZoe9sAQLto
…replay needs the certifying route from zero From the fifth pre-push round: a queued event without an origin tag (a scan) holds every origin's boundary, so a replay event still queued after goLive is never certified past; a late joiner or zero boundary is admitted only when the route that certifies the floor began at the origin's first entry (a revoked or floorless origin is never shown whole); rows naming different generations of one origin fail closed; admission refuses a database with no readable generation; a fallback after a refused checkpoint rechecks termination. Measured: one boundary build and encode at 20 origins with 1,000 queued events is about 9 us. Dispatch-Task: hp-922-subscription-checkpoints-resume Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JecFqDDkuL5xZoe9sAQLto
…hold no boundary From the sixth pre-push round: of two generations of one origin the most recently updated peer row's is current (a removed peer's retained row never advances), and the floor, revocations and holes count only from rows of that generation; a retained-snapshot event (`fromScan`) is state, not a log position, so it holds no boundary, while scalar replay and record-history events carry their origin; the before-CONNACK check admits only a token for this table, leaving a re-scoped one to the 409 fallback; a fallback on an ended session drops no topic. Dispatch-Task: hp-922-subscription-checkpoints-resume Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JecFqDDkuL5xZoe9sAQLto
From the seventh pre-push round: a peer row's sequence id is that peer's clock and says nothing about an origin's history, so two generations of one origin are ordered by the highest floor their rows saw instead — floors sample the origin's own clock, a revoked row keeps the floor it was revoked at, and a removed peer's retained row is frozen at its death. Only rows of the current generation speak for the origin. The before-CONNACK scope check reads the table's statics. Dispatch-Task: hp-922-subscription-checkpoints-resume Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JecFqDDkuL5xZoe9sAQLto
The persisted scalar (read by an older reader after a downgrade, or on a 409/400 checkpoint fallback) followed the database-wide dispatch mark, which a lagging origin can still commit below, so a scalar replay could skip that commit. Cap it just below the token's lowest boundary, freeze it at the kept token while none is certified, lower it when a token is stored or a record is restored, and skip its retention check when the token is admitted for this table generation. A resumed subscription's pre-live progress is now just below its lowest boundary, which is exclusive. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
It ran after the before-CONNACK retention check, so a lowered scalar could fail the fallback replay after CONNACK; no release wrote a token without the cap. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…, as live delivery does Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
kriszyp
force-pushed
the
feat/subscription-origin-checkpoints-core
branch
from
October 25, 2026 05:42
672bb78 to
03ebc14
Compare
4 tasks
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.
⊙ Problem
A scalar subscription cursor can skip a transaction committed later at a lower key (harper#2928), and cannot resume on another node. Origin-closed floors exist in seq rows but subscriptions do not consume them.
🤔 Is a per-origin checkpoint the right requirement, and does it belong in one slice?
Yes: the owner confirmed harper-pro#922 items 3–4. Issuance, admission and MQTT share one completeness invariant, so they stay in one PR.
💡 Solution
A
reportProgresssubscription on RocksDB issues a compact per-origin checkpoint.subscribe({ resume })admits it on any node after checking membership, coverage, revocation and retention, then replays each member's log from its boundary. Live delivery skips the key gate, so a lower-key late commit arrives. Boundaries come from floors certified after a drained pass, advertised only once on disk (syncWal); without it no token is issued. MQTT keeps the token beside the scalar position.🤔 Compact form with a membership digest rather than an explicit per-origin vector
An explicit vector grows with the cluster, which #922 exists to avoid; the owner rejected it. The digest binds membership without naming healthy origins. Cost: up to 60 s of replay per healthy origin on resume.
⚖️ Alternatives
🤔 The MQTT scalar never claims more than the token
A downgrade or 409/400 fallback replays from the scalar, but live
progress()follows the database-wide dispatch mark. The session caps the scalar just below the token's lowest boundary, frozen while certificates lapse. A dead origin holds it back: longer replay, never a skip. Publicprogress()is unchanged.🤔 Pre-CONNACK admission checks database, table and table generation only
An admitted token skips the scalar retention check, since the cap can hold the scalar below retention. A resource re-registered under another prefix passes preflight, then resets after CONNACK: no loss, and rare.
Framing-Verdict: chosen-approach-sound
Approaches weighed
Table.subscribe. Rejected.syncWal: one SST per idle database per minute. Rejected.progress()bounded by origin floors instead of the MQTT scalar cap: it changes a public contract for everyreportProgressconsumer. Rejected.--mode planrounds returnedbetter-alternative-exists, each naming corrections inside the same architecture (floor-only replication resume, a durable publication boundary, origin generation identity, sticky revocation, per-origin QoS 2 receipts, exact record matching); all adopted. One point (one durability barrier over log files and metadata) was overruled: Harper's commit contract is WAL-durable across a crash, not power-loss durable, so a lost unsynced log entry is a durability loss absent everywhere, not this slice's.🤔 Open: generation selection orders by the highest floor a generation's rows saw
A restored origin whose clock runs behind its old generation's floors keeps the old generation selected, so its tokens skip restored writes. It needs a clock-behind restore plus stale rows; carried as harper#3357. Your call whether it blocks.
🔧 Changes
resources/subscriptionCheckpoint.ts(new): the token type, membership digest, encoding policy (MAX_OVERLAP_MS), seq-row state reader, strict capture, validation, bounded membership search and admission.resources/transactionBroadcast.ts: per-origin floor capture on a timer, certified after a drained pass;certifiedFloors(); theungatedflag on the live key gate.resources/Table.ts:subscribe({ resume, checkpointInterval, filterKey }), scope identity, admission and the inclusive per-log replay with epoch rechecks,checkpoint(holds), in-bandcheckpointevents, origin tagging of events; the seq-row merge takes level-2 qualifiers, revocations and route marks and bumpsoriginStateEpoch.resources/auditStore.ts,resources/originClosedFloor.ts: persist →syncWal→ publish;originFloorsDurable.server/DurableSubscriptionsSession.ts: the token in the durable entry, origin-aware holds, receipts kept and compacted under the token, resume preference and scalar fallback, the scalar cap, the before-CONNACK admission check.resources/ResourceInterface.ts: the publicSubscriptionRequestandSubscriptionadditions.resources/DESIGN.md,server/DESIGN.md,DESIGN.md: the invariants and their proofs.Product and architecture tour
Product and architecture tour
The checkpoint a consumer holds
What does a client keep to resume on any node without losing a delayed transaction? What does it reveal?
Example: a healthy three-node cluster
C's subscription has taken everything A, B and C wrote up to their certified floors, which lie within 60 s of one another.
Outcome:
{ v: 1, scope, time: 1792509223593, overlap: 4100, exceptions: {}, members: '7f…' }— nothing about A, B or C appears beyond the digest.Example: a lagging origin
B's route to A is a minute behind, so A's boundary is far below the common one.
Outcome:
exceptions: { 'A': 1792509160000 }— only the lagging origin is named, at its own boundary; the digest still binds all three.Where a boundary comes from
How does a node prove that everything below a boundary was delivered?
Floors are certified by the broadcaster, per database
time = max B,S = max(min B, time − 60 s), exceptions belowS, digest over every member's name and generation.Resuming on another node
What must the target node check before it serves a checkpoint, and how does it replay?
Admission and replay on the target node
A checkpoint issued on C is resumed on B, which holds A's history through its own route.
sequenceDiagram participant Client participant B as Node B participant Rows as B's seq rows Client->>B: subscribe({ resume: token }) B->>B: scope matches? (400 / 409) B->>Rows: read origin states B->>B: digest matches own candidates? (late joiner excluded, else 410) B->>B: each member retained, not revoked, whole route from at or below its boundary (410) B->>B: seek each member's log inclusively at its boundary B-->>Client: replayed entries, then live, ungatedTwo generations of one origin (a restored peer) are ordered by the highest floor their rows saw. Floors sample the origin's clock. A revoked row keeps its floor at revocation; a removed peer's retained row freezes at its death. Only current-generation rows speak for the origin.
MQTT durable sessions
How does a durable session carry the token without breaking an older node that reads the same record?
{ qos, topic, startTime, databaseGeneration }; receipts dropped on another node's generation.checkpoint; receipts name origin keys and travel with the token; a 410 resets the session, a 409 falls back to the scalar.✅ Verification
On main
45581f85e:test:unit:resourcespasses (4754), and the session and subscription suites pass. Three new regressions fail on the pre-cap code.Executed evidence
Review scope: all open PRs and 50 of 51 recently merged PRs were scanned; one older merged PR was not scanned.
unitTests/resources/subscribeCheckpoint.test.js: digest and encoding, membership resolution, validation; on RocksDB: issuance names no healthy origin, resume delivers only what follows, an open transaction holds the boundary and is delivered on resume, a late lower-key commit is delivered live, a queued event and a consumer hold lower the boundary, in-band checkpoint events, 400/409/410 refusals, a late joiner admitted only from its first retained entry, strict availability and revocation.unitTests/server/durableSessionCheckpoint.test.js: the token beside the scalar and a reconnect through it, a late lower-key transaction redelivered after reconnect, per-origin holds, QoS 2 receipts under the token, a 410 reset before CONNACK, a 409 fallback to the scalar.unitTests/resources/replicationSeqCursor.test.js: a sequence update carrying only route marks or a revocation reaches the seq row; the qualifier lifetimes above.Original feature gates (before the rebase):
test:unit:resources4420 passing,test:unit:server1422 passing (plus the existingsubscribeProgressanddurableSessionResumesuites).End to end: harper-pro
integrationTests/cluster/subscriptionCheckpointResume.test.mjs(A–B–C plus a capability-off E), 4/4: level-2 certificates and a floor-only resume after restart; a checkpoint issued on C resumes on B and delivers a transaction A held open across it, a foreign member set is refused with 410; an MQTT durable session fails over from C to B the same way; a peer that stops certifying at level 2 is revoked on its receiver and one hop beyond, and no checkpoint is issued while it is.Not observable here: power loss (the
syncWalordering is pinned by the rocksdb-js ticker test and the sync-then-publish order inauditStore.ts); a two-hop relay (strict availability keeps such a node on the scalar by construction).PR QA: skipped @ f989077 — dispatch QA trial not available in this session.
🤖 Generated by Claude Fable 5.1, OpenAI Codex and Claude Opus 5.5; posted via @kriszyp.
Release target: main — new API surface (subscription checkpoints) that depends on rocksdb-js
syncWal()(HarperFast/rocksdb-js#949; the floor stays level 1 and no token is issued until it is pinned)Related PRs: #3137 overlaps (broadcaster delivery), #3307 overlaps (subscription attribute filtering), #3248 overlaps (origin-floor timestamp admission), 37 others independent
Complexity: complicated
Deferred findings
95e3957b5092a007minor data-integrity —resources/Table.ts— scalar-resumed subscription: commit below the start before goLive is lost and certified — NOT FILED2cef88f782de4812minor correctness —resources/Table.ts— goLive ungating redelivers entries the replay or scan already read (scalar and LMDB) — NOT FILED3e23575dcd5989fcminor data-integrity —server/DurableSubscriptionsSession.ts— bound scalar never moves backward; late lower-key commit delivered live and unacked at disconnect is lost on scalar resume — NOT FILED56ed4b88bf5ddc09nit reliability —resources/Table.ts— replay retention check against a fixed lowest start over-rejects — NOT FILED8ab283c116d7f6e4nit test-coverage —unitTests/resources/subscribeCheckpoint.test.js— syncWal stub leaves the auditStore persist-sync-publish tick and two-origin cases untested — NOT FILED43b97769b55b8fffnit correctness —server/DurableSubscriptionsSession.ts— a record with a scalar above its token's cap is loaded unchanged and a 409/400 fallback replays from it — NOT FILEDLate Grok findings
2 surviving (highest minor) — Grok reviewed 1062a65
resources/Table.ts:7649— the new checkpoint replay callseventFromAuditwithout theliveflag. Scenario (code trace, not run): with full records and noincludeSuperseded, origin X's older write A at key La is merged into origin Y's newer write B at key Lb, so the stored row keeps B's version. The subscriber has takenserver/DurableSubscriptionsSession.ts:1421— a scalar position bound to a database generation never moves backward. A late lower-key commit L, which is now delivered live, can therefore be lost on resume if it is still unacknowledged at disconnect.Not standing: 1 accepted-known. Grok ran 17m (17m from launch to report); the fold adjudication (
cursor-grok@1062a65abece-4e3a5855, claude) took 2m.Review-Coverage: authored=claude; ran=cursor-composer,gemini,codex; adjudicated=domain; declined=cursor-grok,muse; rounds=15; full=7 @ 03ebc14
Review-Attention: deep ~85m (critical: Table.ts, auditStore.ts +1; hot: DurableSubscriptionsSession.ts; decisions: do-less-alternative, membership-digest, strict-availability, ungate-all-progress-subscriptions, token-410-resets-session, syncwal-gated-own-floor, public-checkpoint-interval-api, release-target-unclear) @ 03ebc14