Skip to content

Resume a removed or relayed origin from a per-origin cursor - #998

Merged
kriszyp merged 13 commits into
mainfrom
fix/per-origin-resume-cursors
Oct 7, 2026
Merged

kriszyp merged 13 commits into
mainfrom
fix/per-origin-resume-cursors

Conversation

@kriszyp

@kriszyp kriszyp commented Oct 7, 2026 •

Copy link
Copy Markdown
Member

⊙ Problem

After remove_node, every resubscribe on every surviving node asked its peer for the removed node's entire retained transaction log, starting at 0. That covers restarts, deploys and reconnects. Every node added later pulled the same log from 0 in its base copy's tail, then relayed it back out. Any origin a subscription neither lists nor excludes has the same gap: an origin that reaches the subscriber only through a relay has no cursor either.

Idle-peer resume remains deferred to harper-pro#922, the residual of #989 defect B. A reconnect past auditRetention follows the existing bounded base-copy resync. If an idle sender still retains a last local key older than that cutoff, it can copy again on every reconnect; an empty local log uses the copy's wall-clock anchor and reaches the cutoff after another retention window.

❓ Your call: Is this the right scope? Yes: this PR addresses removed and relayed origins; idle-peer resume belongs to #922. It is W4 (harper-pro#434) scope item 1, the per-origin cursor vector fed into startByLog, shaped so W4 extends it. A removed member's earlier writes stay valid, so nothing deletes a log (harper-pro#686).

💡 Solution

Every origin now has a resume position on every connection, behind a new originCursors capability. RocksDB builds advertise it; a peer without it does not exchange cursor vectors or apply incremental cursor floors. Exact relayed-log anchors also bound base-copy tails sent to older peers.

Where Before After
Restart of a survivor Peer resends the removed origin's whole retained log Peer starts that origin 60 s below the survivor's cursor for it
Origin reaching a node only by relay Whole retained log on every resubscribe Same per-origin start
Base copy to a new node Copy tail pulls every relayed log from 0 Tail resumes each relayed log in append order past the key it held at the copy anchor; the new node keeps those keys as cursors. A relayed log whose boundary cannot form starts from 0, as before

The pieces:

⚖️ Alternatives

❓ Your call: The original planning review (codex) returned Framing-Verdict: better-alternative-exists. Its exact append-order anchors for base copies and per-origin seeding cursor were adopted. The owner chose the 60 s overlap for incremental resume (Q2 a); #922's closed floor replaces that residual. The follow-up scope removal is owner-directed and both its initial planning review and required post-review framing recheck returned Framing-Verdict: chosen-approach-sound.

Approach Why it lost
List removed and relayed origins as ordinary nodeSubscriptions entries A listed proxied node resumes from nodes[].seqId, which is the relay's local time, not the origin's key; main deliberately does not use it as a resume bound because it can sit ahead of what was applied. It would also make a removed node a subscription member again
Exclude a removed origin from every subscription Its last writes that had not reached every node would never arrive
Delete or purge the removed origin's log Ruled out on #686: the writes stay valid
Sender-only: start every relayed log near its own last key The sender cannot know what an offline subscriber holds; it is used only where it does know (the copy anchor)
Force base copies for ambiguous per-origin resumes Reuses existing copy boundaries but replaces incremental catch-up with repeated full table copies; this task explicitly preserves the cursor work
Exact append-order resume for incremental per-origin starts Needs RocksTransactionLogStore to mix exact and non-exact starts in one range, plus a per-log fallback; you chose the 60 s overlap (Q2 a)
Seed every unlisted origin from its direct seq row A current member that is unlisted and unexcluded has partial coverage, and its direct cursor does not cover the tables it relays, so origins whose registry row still exists are not seeded

❓ Your call: Seeding a removed origin from its own connection's row assumes that direct subscription covered every table the relay now supplies. That coverage is the advertised one computeExclusionOrigins relied on, not a recorded one. If R's effective send scope was narrower and no relayed R entry reached this node since the upgrade, the seed suppresses relayed rows older than the cursor minus 60 s. I kept your seeding decision; the alternative is no seed, which replays every removed origin from 0 once per connection.

❓ Your call: The removal-only review found that probeNodeRow treats a physically absent registry row as deleted. A named config-route peer can be connected before its registry row arrives, so the unchanged membership predicate may seed a live peer with partial table coverage and suppress older relayed rows. Distinguishing a tombstone from an absent row is a separate cursor change; this scope update preserves the owner-specified cursor functions. Verify this membership case before promoting the PR.

❓ Your call: A per-origin cursor is not bound to the table scope it was recorded under. If a subscription later widens (a table turns on replicate, or a receivesFrom exclusion is removed) and the node reconnects without a base copy, a relayed origin's rows in the newly included table keyed more than 60 s below its cursor are not resent. Before this change those logs restarted at 0, so retained rows still arrived. The direct peer's own rows are already missed this way on main, because skipped records advance its scalar seqId, and nothing resyncs a widened scope. I left both cursors scope-unbound. Binding them to a scope fingerprint would fix both, and is a separate change. DESIGN item 26 records it.

⚠️ Look hardest: ORIGIN_CURSOR_OVERLAP_MS is not a proven bound. The incremental floor remains active for the life of the subscription. An entry appended more than 60 s after its key can therefore be skipped, including while the socket stays connected: this includes a delayed origin commit and an origin entry arriving at a relay over a lagging path. storage.maxTransactionOpenTime defaults to 30 s but can be extended, and commit-time write stalls are unbounded. A listed source's own resume already has this exposure with no overlap at all. #922's closed floor replaces both.

❓ Your call: Each committed frame with progress hands core one small cursor array (takeDurableOriginCursors). Publishing only at sequence updates would avoid it, but a sender sends those only after skipped records, so cursors would go stale. The core apply bench attaches a fresh cursor array per frame from onCommit and measured 147 µs per frame against 146 µs without (see Verification). There is no separate two-node catch-up throughput measurement.

❓ Your call: The closing review also identified same-socket resubscribe risks in the retained sender: resetting liveStartByLog while reusing auditLogIterable can leave a re-admitted origin bounded only by the predicate, walking its full retained log; a superseded loop can resume from back-pressure and overwrite the current loop's waker before parking. The adjudicator reproduced the range-map and waker coordination in deterministic in-memory checks against pinned core; there is no targeted cluster reproduction. This removal preserves the cursor and includeNodes implementation as instructed; assess these cases before promotion.

Product and architecture tour

Per-origin cursor lifecycle

How does a subscriber learn, keep and use its position in every origin's log?

Record, send, apply

The receiver records each origin's highest applied key per peer after commit. It sends those keys on the next subscription request, and the sender bounds each unlisted origin's log by them.

sequenceDiagram
    participant S as Sender (peer P)
    participant R as Receiver
    participant C as core seq row [seq, P]
    S->>R: frame (key K, origin O)
    R->>C: end_txn originCursors [[O, K]] after onCommit
    C->>C: nodes[O].originLogKey = max(old, K)
    Note over R: restart or reconnect
    R->>S: SUBSCRIPTION_REQUEST[4] { O: K }
    S->>S: startByLog O = K - 60 s, predicate floor
    S-->>R: only O entries keyed above the floor
Loading
Before After
An origin the subscription neither lists nor excludes starts at 0 on every resubscribe. It starts ORIGIN_CURSOR_OVERLAP_MS (60 s) below the subscriber's cursor for it; an origin with no cursor still starts at 0.

A cursor is per peer: it covers that subscription's table scope, so it is never borrowed from another peer's row.
An origin with a deleted or physically absent hdb_nodes row is seeded from its own connection's row. This assumes that connection covered the relayed tables; see the open membership concern above.

Base-copy tails resume in append order

Where can the sender prove what the subscriber holds, and so avoid the 60 s assumption?

  • Fresh base copy. The copy holds every relayed entry committed before it starts, so each relayed log resumes past its own last committed key with resumeAfterExactStart. The receiver keeps those anchors (COPY_START[3]) as its cursors once the copy finishes. A relayed log whose boundary does not form loses only its own anchor and starts from 0.
  • Copy-anchored logs drop their key filter, because a later append can carry an older key.

Re-inclusion after remove_node

How do a removed node's last writes reach a survivor that lacks them?

From removal to delivery

  1. Removal — the survivor's hdb_nodes row for R is deleted, and the main thread drops R from its exclusion origins.
  2. Include update — the survivor sends { includeNodes: [R], originCursors: { R: cursor } }.
  3. Floor — the sender puts R's floor into the live range's startByLog and calls addLog.
  4. Admission — core admits R's log at the next transaction boundary, and the sender loop is woken.
  5. Delivery — R's rows keyed above the floor stream to the survivor without a resubscribe.

Files.

✅ Verification

End-to-end route: npm run test:integration -- --concurrency=1 integrationTests/cluster/removedOriginResumeCursor.test.mjs passes all 3 cases on rebuilt dist. An uncommitted two-node smoke also confirms that reconnecting after auditRetention logs a bounded base-copy resync and that a subsequent write converges. The smoke uses positive evidence (forced-copy warning and subsequent convergence); it does not exercise a retention-forced copy with relayed origins, so that combination remains a coverage gap. The retention-admission block is byte-identical to fetched origin/main; 12 cursor/floor/anchor functions, copy-tail code, incremental range, capabilities and the core pointer are unchanged by this scope update. The original fails-on-base evidence for per-origin resume is retained below.

  • integrationTests/cluster/removedOriginResumeCursor.test.mjs. A, B and R form a full mesh, and Q replicates only with A, so Q reaches B only by relay.
    • OLD rows are written, then MID rows more than 60 s later. B stops and R writes LATE rows that reach A only. R is removed on A, and then on B.
    • B gets LATE through A as soon as it removes R, without a restart. No peer sends OLD rows (R's, or Q's through A) when B or A restarts, and B's persisted cursor for Q on its A row covers MID.
    • A node N added afterwards by base copy from A and then B gets no OLD row in either copy's tail. Its restart pulls no OLD row either. R's log on A and B does not grow; N's log may append the overlap-window rows once, bounded by the MID and LATE row count.
    • The oracle is the sender's own wrote record <id> … remoteNode <peer> debug line. The restart checks assert absence without a matching positive send control, and missing log files read as empty; strengthening that oracle remains open. The existing fails-on-base evidence below establishes that it observed the defect in the original runs.
    • Base (a3e1a9d0 + core 5230512f9): 3/3 tests fail. B is resent R's OLD rows on restart, and N's copy tail carries all 20. Fixed build: green on the final head, and 3/3 runs earlier (5/5 before the include-path assertion was added).
  • Unit tests.
  • Full suites. Scope-update verification: harper-pro npm run test:unit: 1668 passing, 2 pending. npm run build, npm run typecheck, and changed-file Prettier check pass. npm run lint reports the same 24 warning signatures as fetched origin/main (no new warnings). Core companion verification before this scope removal: test:unit:resources 4019 passing, 54 pending. Full integration gate on Node 26.2: 217 passing, 7 failing, 3 cancelled, 15 skipped. Fetched main (f7aacb6b, core 5230512f9) reproduces the worker-placement, non-replicated-table copy, and static-redeploy failures. The other three failed files (replicationWedgeRecovery, selectiveTableSubscription, subscriptionSetupRecovery) pass on main and in a focused branch rerun (6/6 cases); the full gate is recorded as failed. Removed-origin integration also passed all 3 cases again within the full run.
  • Apply-path cost (core companion; bench-run, CPU capped at 2 GHz, 20k frames, 3 rounds, median µs per frame):
    • Advancing frames: 146 without cursors, 147 with cursors.
    • Repeated-localTime frames: 99 with no cursors (the same work as main), 99 with a cursor that did not rise, 111 with a rising cursor (one cursor write).

Independent review. Claude, Gemini and Cursor Composer completed full finding passes, followed by harper-domain adjudication. The removal is faithful. The domain reviewer rates the retained session-long 60 s floor as a blocker and membership/sender-lifecycle risks as major; those paths remain unchanged under the owner instruction, and their specific promotion concerns are above. The Pro allocation cost remains unmeasured despite the existing core apply benchmark. Parallel startup failure can also strand test nodes before the suite registers them. The required planning recheck cleared the removal-only framing; review does not imply the draft is ready to merge.

CI on cb31e647. Builds, unit tests (Node 22/24/26), typecheck, lint, YCSB, all non-cluster integration shards, copy-gap regressions, and five of six cluster shards pass. Cluster shard 1/6 fails because both replicateFalseFullCopy suites cannot observe a restart within 60 s; main has the same failure in shard 6/6, at f7aacb6b. Deleting the idle test shifted that file's shard assignment. No new review comments arrived during the watch.

No documentation companion is needed: this scope update changes internal resume admission and adds no public API or configuration.

Refs #989

Depends-on: HarperFast/harper#3080

🤖 Generated by Claude Code (original implementation) and OpenAI Codex (scope update); posted via @kriszyp.

Related PRs: #554 overlaps (copy-tail boundaries; reconcile its checks with the relayed-anchor fallback), #717 overlaps (held frames must keep skipping onCommit, where origin cursors are noted), #815 overlaps (capability registry; admission now waits for a transaction boundary), #959 overlaps (sender loop wait and its include waker), #983 overlaps (sender loop waker; moves the core pointer), #940 overlaps (capability registry), #956 overlaps (capability registry; moves the core pointer), #995 overlaps (moves the core pointer), #981 overlaps (moves the core pointer), #992 overlaps (moves the core pointer), #997 overlaps (moves the core pointer), #988 independent, #991 independent, #996 independent, #990 independent, #943 independent (this branch is rebased on it), #987 independent, #984 independent, #986 independent, #979 independent, #985 independent, #942 independent, #955 independent, #800 independent
Complexity: complicated

Origin and current scope

The original dispatch covered #989. The current owner-directed scope addresses removed and relayed origins, preserving per-origin recording/sending, the 60 s overlap floors, includeNodes and exact relayed-log anchors for base-copy tails. It deletes no transaction-log entry, log store or sequence row. The core companion #3080 is merged and the core pointer is unchanged by this scope update.

Dispatch: task harper-pro-989 · queued by kris-session · ran by claude/opus/xhigh · worker kzyp-xps-1

Review-Coverage: authored=codex; ran=cursor-composer,gemini,claude; adjudicated=domain; declined=cursor-grok,cursor-kimi,cursor-muse; rounds=11; full=5 @ cb31e64

Review-Attention: deep ~80m (critical: protocolCapabilities.ts, replicationConnection.ts; decisions: do-less-alternative, removed-origin-coverage, scope-widening-policy; raised: earlier round at this SHA) @ cb31e64

kriszyp and others added 8 commits October 6, 2026 17:14
… replaying their logs from 0

After remove_node, a removed node's log on every survivor was neither a subscription source nor an
excluded origin, so each resubscribe had its peer start that log at 0, resend all of it, and every
node added later pulled it from 0 too. A peer that wrote nothing new also never advanced its
subscribers' cursor, so a reconnect after auditRetention became a full base copy. (harper-pro#989)

This is W4 (harper-pro#434) scope item 1, the per-origin cursor vector:

- A new `originCursors` capability, advertised by RocksDB builds, gates all of it. A peer without it
  sees and sends exactly what it did before.
- The receiver records, per connection, the highest origin log key it has durably applied for every
  origin (core stores it in the seq row's nodes[], companion harper change), and sends those cursors on
  SUBSCRIPTION_REQUEST[4] and with an includeNodes update. A removed origin is seeded from its own
  connection's row.
- The sender starts each origin the subscription neither lists nor excludes
  ORIGIN_CURSOR_OVERLAP_MS (60 s) below its cursor: in the incremental range's startByLog, in the
  subscription predicate, and on the includeNodes path. An origin with no cursor still starts at 0.
- A fresh base copy resumes every relayed log in append order past the key it held at the copy
  anchor, and the receiver keeps those keys as its cursors, so a cloned node neither pulls a removed
  node's log in its copy tail nor on its first restart.
- The retention check no longer forces a base copy when the requested start is still a retained
  entry of the sender's local log and every other log in scope is covered by a cursor: rocksdb-js
  keeps a log's newest file past retention, so an idle peer's last entry survives.

Nothing deletes or purges a transaction log, an entry, or a seq row. The 60 s overlap is not a
proven bound on late commits; harper-pro#922's closed floor replaces it.

Refs #989

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012bLQzwWfPVJaF1PzfyxTD7
Dispatch-Task: harper-pro-989
… re-included origin live

Review round 1 fixes for the per-origin cursor vector:

- The retention bypass no longer infers coverage from a log's oldest retained key, which out-of-order
  appends defeat. It builds a range that resumes the local log and every other in-scope log past
  the exact entry its cursor names (resumeAfterExactStart), probes that every boundary forms, and
  keeps the base copy otherwise. Logs resumed that way drop their key filter in the predicate, as do
  relayed logs a base copy resumes past its anchors.
- An includeNodes update now wakes an idle sender, and core's addLog re-reads log membership, so a
  removed origin's last rows reach a survivor without a resubscribe.
- COPY_START announces only the anchors the tail resumes past. The receiver records them without
  minting ids, under a guard, so a failure cannot strand copy finalization.
- The sender applies a cursor vector or include cursors only from a peer that advertised
  originCursors.
- The data-frame end_txn attaches its cursors from onCommit, which core now reads after awaiting it.

Refs #989

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012bLQzwWfPVJaF1PzfyxTD7
Dispatch-Task: harper-pro-989
… in the replication design note

Refs #989

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012bLQzwWfPVJaF1PzfyxTD7
Dispatch-Task: harper-pro-989
…er the design note

Review round 2 follow-ups: a superseded sender loop no longer clears the live loop's waker, the
per-origin cursor design note becomes item 26 (main added its own item 25), and it notes that a
subscription listing more than one source keeps the base copy past retention. Bumps core to the
commit that admits a re-included log at the next pull.

Refs #989

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012bLQzwWfPVJaF1PzfyxTD7
Dispatch-Task: harper-pro-989
…still close the sender

Review round 3 follow-ups: a frame almost always carries one origin, so it is held as a scalar and a
second one allocates. The sender's wait for the next transaction propagates a rejection to the
subscription's error path instead of swallowing it. The design note records the seeding residual (a
removed origin's direct coverage is the advertised one, not a recorded one).

Refs #989

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012bLQzwWfPVJaF1PzfyxTD7
Dispatch-Task: harper-pro-989
Refs #989

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012bLQzwWfPVJaF1PzfyxTD7
Dispatch-Task: harper-pro-989
…o hoist the seq updater

One relayed log that could not form its exact boundary made the copy tail drop every relayed
anchor, so a cloned node pulled every relayed log from 0 again on its first restart. The
probe now names the logs that failed, and only those lose their anchor.

Core: the seq-row updater is defined once per subscription and decided after onCommit.

Dispatch-Task: harper-pro-989
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012bLQzwWfPVJaF1PzfyxTD7
…upt-frame fallback

Dispatch-Task: harper-pro-989
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012bLQzwWfPVJaF1PzfyxTD7

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

Copy link
Copy Markdown

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 implements per-origin resume cursors (harper-pro#989) to allow removed and relayed origins to resume incrementally instead of starting from zero. It introduces the originCursors capability, records origin progress on the receiver, transmits cursor vectors during subscription requests, and applies a 60-second overlap floor on the sender. Additionally, it updates retention checks to support incremental resumes for idle peers and adds comprehensive integration and unit tests. The review feedback highlights a potential crash in collectSeqRows where entry.key is accessed outside of the try-catch block, suggesting wrapping both key and value extraction to ensure robustness against decoding errors.

Comment thread replication/replicationConnection.ts
kriszyp and others added 4 commits October 6, 2026 20:49
…nd to table scope

Dispatch-Task: harper-pro-989
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012bLQzwWfPVJaF1PzfyxTD7
…ursor change

harper#3080 merged as f7b766981; this moves the gitlink from its branch head to that
default-branch commit.

Dispatch-Task: pr-maint-cb1e21c58954232c3e525c7f2ee4b45d
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…the send predicate pays one check

A RocksDB subscription with an exclusion list allocated an empty floor array even with no
cursors, so every sent record looked one up. Also corrects the boundary-probe docstring
for a corrupt frame, and names the sender's sendsTo exclusion as a way a scope widens.

Dispatch-Task: pr-maint-cb1e21c58954232c3e525c7f2ee4b45d
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Remove the retained-anchor retention bypass and its exclusive tests. Keep per-origin cursors, overlap floors and base-copy relayed anchors unchanged; idle-peer resume belongs to #922.

Dispatch-Task: harper-pro-998-drop-idle-resume

Co-Authored-By: GPT-5 Codex <noreply@openai.com>
@kriszyp kriszyp changed the title Resume a removed or relayed origin from a per-origin cursor, and resume an idle peer past auditRetention without a base copy Resume a removed or relayed origin from a per-origin cursor Oct 7, 2026
@kriszyp kriszyp modified the milestones: v5.4, v5.3 Oct 7, 2026
@github-actions

github-actions Bot commented Oct 7, 2026 •

Copy link
Copy Markdown
Contributor

Release cherry-pick v5.3: failed

The cherry-pick job for v5.3 did not complete. It is unclear whether v5.3 was updated — check before re-running.

Run: https://github.com/HarperFast/harper-pro/actions/runs/37662733435

Re-run once the cause is addressed (workflow_dispatch, pr_number=998).

@kriszyp kriszyp modified the milestones: v5.3, v5.4 Oct 8, 2026
kriszyp added a commit that referenced this pull request Oct 8, 2026
…eep a proven resume point

A receiver's resume cursors moved only on applied frames and the sender's
position trailers, so a connected origin that wrote nothing never advanced them;
once a cursor was older than auditRetention the next reconnect was upgraded to a
bounded base copy, on every reconnect for an idle sender (harper-pro#989 defect
B, deferred to #922 by #998).

Between peers that both advertise the new additive originFloors capability, a
sender now carries the floor core closed for its own log (harper#3085), and the
relayable floors it holds for the origins it relays, as an optional third
element of SEQUENCE_ID_UPDATE. Floors are captured before a pass scans and sent
after it drained, so every entry below a floor has gone out first; an idle
connection is woken every ORIGIN_FLOOR_INTERVAL_MS to reach that point, with one
whenNextTransaction reaction per transaction generation. The receiver validates
them, records whether it receives the origin directly with full coverage, fences
them in the update's own onCommit, holds them under the blob rule, and core
stores them as nodes[].closedFloor beside the applied cursors. A resume request
starts at max(position, the float below the floor) while the peer still
certifies. Relay propagation is one hop beyond a direct full-coverage link. A
peer without the capability is on the old protocol unchanged.

Dispatch-Task: hp-922-phase1-origin-floors
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hhrw3yq9XSUgnNo3ymfe4B
kriszyp added a commit that referenced this pull request Oct 9, 2026
…eep a proven resume point (#1011)

* Certify origin-closed floors to peers so an idle origin's receivers keep a proven resume point

A receiver's resume cursors moved only on applied frames and the sender's
position trailers, so a connected origin that wrote nothing never advanced them;
once a cursor was older than auditRetention the next reconnect was upgraded to a
bounded base copy, on every reconnect for an idle sender (harper-pro#989 defect
B, deferred to #922 by #998).

Between peers that both advertise the new additive originFloors capability, a
sender now carries the floor core closed for its own log (harper#3085), and the
relayable floors it holds for the origins it relays, as an optional third
element of SEQUENCE_ID_UPDATE. Floors are captured before a pass scans and sent
after it drained, so every entry below a floor has gone out first; an idle
connection is woken every ORIGIN_FLOOR_INTERVAL_MS to reach that point, with one
whenNextTransaction reaction per transaction generation. The receiver validates
them, records whether it receives the origin directly with full coverage, fences
them in the update's own onCommit, holds them under the blob rule, and core
stores them as nodes[].closedFloor beside the applied cursors. A resume request
starts at max(position, the float below the floor) while the peer still
certifies. Relay propagation is one hop beyond a direct full-coverage link. A
peer without the capability is on the old protocol unchanged.

Dispatch-Task: hp-922-phase1-origin-floors
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hhrw3yq9XSUgnNo3ymfe4B

* Skip the floor parse on a sequence update that carries no vector

Dispatch-Task: hp-922-phase1-origin-floors
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hhrw3yq9XSUgnNo3ymfe4B

* Forward a relayed floor only while its origin certifies, drop one for an origin excluded mid-pass, and re-arm one idle timer per loop

A relay forwarded an origin's stored floor indefinitely: an origin that
reconnects on an older build may write below a floor it saved, so its floor is
now forwarded only while its current socket advertises the capability, read from
a new shared-status slot the receiver writes at NODE_NAME (zero after a restart
until the origin reconnects). A floor captured before a pass is not certified
when a SUBSCRIPTION_UPDATE excluded that origin during the pass, since its scan
was cut short. The idle wait keeps one timer per loop and re-arms it per wait
instead of allocating one per pass, returns on wsClosed as well as closed, and
a range failure that latches emission off logs one warning. The outgoing vector
is built from a Map so an origin named like a prototype key is still
certified; the vector-less sequence update skips the parse; comments trimmed.

Dispatch-Task: hp-922-phase1-origin-floors
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hhrw3yq9XSUgnNo3ymfe4B

* Certify a floor that rose, not one already certified: the rewritten emission loop had the test inverted

Dispatch-Task: hp-922-phase1-origin-floors
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hhrw3yq9XSUgnNo3ymfe4B

* Record a peer's floor capability with its record-lock capability, recheck it at emission, and wake an idle sender on close

The capability slot was written at NODE_NAME, which on an initial inbound
socket runs before the database is known, so it was never set there and a
downgraded origin kept being forwarded. It is now written where the record-lock
capability is: once the socket's database is known and again when its
subscription resolves. A relayed floor captured before a pass is rechecked
against the slot at emission. The idle timer wakes the sender unconditionally
and the subscription's close handler wakes it too, so a closed loop settles
instead of waiting for the next transaction.

Dispatch-Task: hp-922-phase1-origin-floors
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hhrw3yq9XSUgnNo3ymfe4B

* Clear a retiring socket's floor-capability slot unless it was superseded

Dispatch-Task: hp-922-phase1-origin-floors
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hhrw3yq9XSUgnNo3ymfe4B

* Build a commit callback only for a floor-carrying update or a blob drain, and let only the subscription link own the capability slot

While floors were held by the blob rule, every data frame's position trailer
built an onCommit closure that core awaited on its serialized apply loop; a
plain trailer now stays allocation-free, and only an update that carries floors
or a blob-drain re-emit pays for the callback. The peer's floor-capability slot
is now written and cleared only by this node's subscription link to the peer —
the socket that receives its certificates — when its status buffer first
materializes after the handshake and with the record-lock capability, never by
an inbound or retrieval socket, and never after the instance retired.

Dispatch-Task: hp-922-phase1-origin-floors
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hhrw3yq9XSUgnNo3ymfe4B

* Let only the direct subscription link own the peer floor-capability slot, not a failover subscription through the peer

Dispatch-Task: hp-922-phase1-origin-floors
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hhrw3yq9XSUgnNo3ymfe4B

* Narrow the floor-emission failure guard to boundary ranges only, and skip idle-wait setup when a commit already landed

Round 8's fresh full review, run after the rebase force-push, found two bugs
pre-dating the rebase: the floor-emission latch treated exactStartFailures as
a scan failure even on an ordinary (non-boundary) range, where core reports
"missing" for an empty or not-yet-populated log as the normal, healthy case —
permanently disabling certification on a connection's first reconnect scan. A
framing recheck (--mode plan) found my first fix over-broad: gating
failedLogs/corruptFrameStop behind the same boundary check as
exactStartFailures would also disable genuine corruption/failure detection on
an ordinary range, letting a floor certify past an unrecoverable gap. Only
exactStartFailures needed the boundaryLogName guard; failedLogs and
corruptFrameStop stay unconditional, matching the adjacent boundary-continue
check's existing distinction.

Also: the idle wait unconditionally rearmed the floor timer, allocated the
wait Promise and entered try/finally before checking whether a commit had
already rotated the transaction generation during the scan. The generation
check (a reference comparison) now runs first, and `continue`s straight into
a rescan on mismatch, skipping that setup entirely on a busy connection.

integrationTests/cluster/idleOriginFloorResume.test.mjs: the existing
post-restart reconnect case asserted base-copy avoidance and record delivery
but never that floor certification kept advancing afterward — which is
exactly the ordinary-range path where the first bug fires, so the test would
have passed with the regression still in place. Added floor-advancement
assertions for B and C after their restarts.

Dispatch-Task: pr-maint-822e916f82093eb27c349f4115db8aaf
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* Clear the idle-wait timer on every exit path, so an exiting pass cannot wake a superseding loop

The generation-check fix left clearTimeout(floorTimer) reachable only through
the do-while loop's natural exit. Both in-loop early returns (socket closed,
a timed wake landing after close) and any exception reaching the outer .catch
skipped it, leaving a per-incarnation, unref'd timer pending; when it later
fired it called the connection-wide wakeSender, which a superseding
subscription loop may have since reassigned to its own resolve, giving that
later loop one spurious wake. Wrapping the loop in try/finally guarantees the
clear runs on every path, matching how the existing waker-ownership check
already protects wakeSender itself across supersession.

DESIGN.md item 27 is corrected to match: exactStartFailures only withholds
certification on a boundary range; failedLogs/corruptFrameStop withhold it on
every range kind.

Dispatch-Task: pr-maint-822e916f82093eb27c349f4115db8aaf
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

---------

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.

1 participant