Skip to content

cherry-pick: fix(replication): don't treat an unresolved subscription placeholder as a resolved subscription (conflicts → v5.1) - #636

Closed
github-actions[bot] wants to merge 3 commits into
v5.1from
cherry-pick/v5.1/pr-623
Closed

cherry-pick: fix(replication): don't treat an unresolved subscription placeholder as a resolved subscription (conflicts → v5.1)#636
github-actions[bot] wants to merge 3 commits into
v5.1from
cherry-pick/v5.1/pr-623

Conversation

@github-actions

Copy link
Copy Markdown
Contributor

Cherry-pick of PR #623 onto `v5.1` produced conflicts on commit(s): `65dbf7b5c50959cf3ad10f9c407b631407ae5a32 c6c1d8e b2210aa`.

Resolve the conflict markers on branch `cherry-pick/v5.1/pr-623` and merge this PR.

@claude please review branch `cherry-pick/v5.1/pr-623` and suggest a patch that resolves the conflict markers (<<<<<<< / ======= / >>>>>>>) introduced by cherry-picking PR #623 onto `v5.1`. Post the suggested patch as a comment here — do not push.

kriszyp and others added 3 commits July 30, 2026 21:46
…as a resolved subscription

A replication connection for a database can be handed the unresolved placeholder Promise
from createPendingDatabaseSubscription instead of the database's registered
IterableEventQueue, because Replicator.subscribe() (which registers the queue) may not
have run yet on that thread. Two use sites read it as if it were resolved:

- The inbound-record path called `.send()` on the Promise, so every inbound replication
  message threw `<x>.send is not a function` and every record in it was dropped.
- sendSubscriptionRequestUpdate read `auditStore`/`dbisDB` off it, so nodeId came back
  undefined, no `seq` resume cursor resolved, startTime fell back to 1, and the node
  requested a full copy of every database on every restart while a current cursor sat on
  disk.

The fixes are asymmetric because the requirements are. The record path genuinely needs the
resolved queue, so it waits (awaitPendingSubscription, bounded by
SUBSCRIPTION_RESOLVE_TIMEOUT, pausing socket intake so a peer mid-copy can't queue a
timeout's worth of frames into the message chain). The handshake must NOT wait — a peer
only sends DB_SCHEMA in response to a SUBSCRIPTION_REQUEST, and that schema is what creates
the tables whose registration resolves the placeholder — so resolveDatabaseStores reads the
stores off a local table instead, both being per-database. Empty stores then mean what
`startTime === 1` always assumed: no local tables, i.e. a genuine bootstrap.

Also fixes the placeholder being registered in the module-level databaseSubscriptions map
while being looked up in `options.databaseSubscriptions || databaseSubscriptions`, which
would leave it pending forever for a caller that passes its own map.

Fixes #622

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…scription handler

Addresses the Gemini PR review on #623:

- A non-numeric `replication_subscriptionResolveTimeout` / `replication_pauseStallTimeout`
  reached the timers as NaN, which fails silently in both directions: setTimeout coerces it
  to ~0 (the subscription-resolve bound fires immediately) while every `elapsed >= NaN`
  watchdog comparison is false (the paused-liveness watchdog never fires at all). Flooring
  PAUSE_STALL_THRESHOLD_MS at SUBSCRIPTION_RESOLVE_TIMEOUT had coupled the two, so one bad
  value could disarm the watchdog. positiveMsOr guards the whole expression rather than each
  input, so nothing non-numeric escapes.
- The detached `.then()` that swaps in the resolved subscription had no `.catch()`, so a
  rejection would surface as a process-level unhandled rejection instead of a connection
  error (the DESIGN item-12 / harper-pro#466 trap). The placeholder has no reject path
  today; log and let the receive path's bounded wait close-and-reconnect.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Chris Barber <chris@harperdb.io>
// Waiting unbounded there would silently wedge the serialized message chain with no watchdog to catch it
// — the receive watchdog keeps being reset by the very frames we are not processing — so we close instead
// and let reconnect backoff retry, which costs one log line per attempt rather than one per message.
<<<<<<< HEAD

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Critical: unresolved cherry-pick conflict markers — this file will not parse

The cherry-pick of #623 was never resolved. replicationConnection.ts still contains raw conflict markers (<<<<<<< HEAD / ======= / >>>>>>>) at lines 191, 311, and 1599, and both sides of each conflict were kept. As a direct consequence positiveMsOr is now declared twice (lines 148 and 162). This is a syntax error: the replication module won't load and the worker won't start, so the underlying #623 fix does not actually take effect.

The intended resolution is the c6c1d8e side throughout — the positiveMsOr-guarded SUBSCRIPTION_RESOLVE_TIMEOUT / PAUSE_STALL_THRESHOLD_MS, the shouldCloseSendAuthWatch helper, and the .then().catch() on the placeholder swap. Delete the HEAD/65dbf7b sides and every marker, and remove the duplicate positiveMsOr at line 162.

Do not merge until the conflicts are resolved.


Generated by Barber AI

Comment thread replication/replicator.ts
let subscriptionToTable = databaseSubscriptions.get(request.database);
if (!subscriptionToTable) {
// Wait for it to be created
<<<<<<< HEAD

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Critical: unresolved cherry-pick conflict markers

Lines 639–647 still contain raw conflict markers; both logger.info variants were retained. Keep the template-literal form (Waiting for subscription to database ${request.database}, the b2210aa side), then remove the markers and the string-concatenation duplicate.


Generated by Barber AI

resolveDatabaseStores,
createPendingDatabaseSubscription,
awaitPendingSubscription,
<<<<<<< HEAD

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Critical: unresolved cherry-pick conflict markers in the test file

Raw conflict markers remain at lines 27, 188, and 214, so this test module won't even import. Take the side that keeps the positiveMsOr import and its describe block, and delete all markers — otherwise the new coverage cannot run and CI can't validate the fix.


Generated by Barber AI

Comment thread replication/DESIGN.md

13. **A connection's local subscription may be an unresolved placeholder — never read `send`/`auditStore`/`dbisDB` off it.** A replication connection for a database is set up independently of `Replicator.subscribe()`, which registers that database's `IterableEventQueue` in `databaseSubscriptions` when the first of its tables is set up on this thread. Whichever loses the race, the connection is handed the placeholder Promise from `createPendingDatabaseSubscription` instead. Both use sites got this wrong (harper-pro#622): the receive path called `.send()` on it (a `<x>.send is not a function` per inbound message, every record in it dropped — 329k errors / 500MB of `hdb.log` in 8 minutes on a 12-node cluster), and `sendSubscriptionRequestUpdate` read `auditStore`/`dbisDB` off it, so `nodeId` was `undefined`, no `seq` cursor resolved, `startTime` fell back to `1` and the node **requested a full copy of every database on every restart while a current cursor sat on disk**. The two fixes are asymmetric because their requirements are: (a) the record path genuinely needs the resolved queue, so it waits (`awaitPendingSubscription`, bounded by `SUBSCRIPTION_RESOLVE_TIMEOUT` — nothing else watches a wedged `messageProcessing` chain, since the receive watchdog is reset by the very frames not being processed — and pausing socket intake for the wait, because blocking that chain does **not** stop `ws.on('message')` from appending closures that each retain a whole inbound frame, so a peer mid-copy would OOM the worker before the timeout fired; `PAUSE_STALL_THRESHOLD_MS` is floored above the timeout so the paused-liveness watchdog can't pre-empt the wait); (b) the handshake must **not** wait — an empty node bootstrapping a database it does not have locally would deadlock (the peer only sends `DB_SCHEMA` in response to a subscription request, and that schema is what creates the tables that resolve the placeholder) — so `resolveDatabaseStores` reads the stores off a local table instead, both being per-database (`rootStore.auditStore` / `rootStore.dbisDb`) with the subscription queue only a carrier. Empty stores then mean what `startTime === 1` always assumed: no local tables at all, i.e. a genuine bootstrap. Compare `readDbisCursorSync` (#476/#484) — same "`undefined` masquerades as no resume cursor → spurious full copy" failure, different source of the `undefined`.

13. **A connection's local subscription may be an unresolved placeholder — never read `send`/`auditStore`/`dbisDB` off it.** A replication connection for a database is set up independently of `Replicator.subscribe()`, which registers that database's `IterableEventQueue` in `databaseSubscriptions` when the first of its tables is set up on this thread. Whichever loses the race, the connection is handed the placeholder Promise from `createPendingDatabaseSubscription` instead. Both use sites got this wrong (harper-pro#622): the receive path called `.send()` on it (a `<x>.send is not a function` per inbound message, every record in it dropped — 329k errors / 500MB of `hdb.log` in 8 minutes on a 12-node cluster), and `sendSubscriptionRequestUpdate` read `auditStore`/`dbisDB` off it, so `nodeId` was `undefined`, no `seq` cursor resolved, `startTime` fell back to `1` and the node **requested a full copy of every database on every restart while a current cursor sat on disk**. The two fixes are asymmetric because their requirements are: (a) the record path genuinely needs the resolved queue, so it waits (`awaitPendingSubscription`, bounded by `SUBSCRIPTION_RESOLVE_TIMEOUT` — nothing else watches a wedged `messageProcessing` chain, since the receive watchdog is reset by the very frames not being processed — and pausing socket intake for the wait, because blocking that chain does **not** stop `ws.on('message')` from appending closures that each retain a whole inbound frame, so a peer mid-copy would OOM the worker before the timeout fired; `PAUSE_STALL_THRESHOLD_MS` is floored above the timeout so the paused-liveness watchdog can't pre-empt the wait); (b) the handshake must **not** wait — an empty node bootstrapping a database it does not have locally would deadlock (the peer only sends `DB_SCHEMA` in response to a subscription request, and that schema is what creates the tables that resolve the placeholder) — so `resolveDatabaseStores` reads the stores off a local table instead, both being per-database (`rootStore.auditStore` / `rootStore.dbisDb`) with the subscription queue only a carrier. Empty stores then mean what `startTime === 1` always assumed: no local tables at all, i.e. a genuine bootstrap. Compare `readDbisCursorSync` (#476/#484) — same "`undefined` masquerades as no resume cursor → spurious full copy" failure, different source of the `undefined`.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Low: DESIGN item 13 duplicated

Item 13 now appears twice (lines 117 and 119) — v5.1 already carried this entry, so the cherry-pick re-added an identical paragraph. Drop the duplicate.


Generated by Barber AI

@kriszyp

kriszyp commented Jul 31, 2026

Copy link
Copy Markdown
Member

Closing this as redundant and unsafe to merge.

The fix is already on v5.1. It landed hand-resolved in 1181d48c + 6117f5f0 + db5fffd1. Both halves are present on the branch today — SUBSCRIPTION_RESOLVE_TIMEOUT (replication_subscriptionResolveTimeout, 60s default) and the bounded "await a database subscription that may still be the pending placeholder" helper in replicationConnection.ts.

This branch still carries unresolved conflict markers. replication/replicationConnection.ts has them at L191, L311 and L1599+, including nested <<<<<<< pairs. Diffed against origin/v5.1 it would add 138 lines of marker text into the replication hot path.

That combination matters because the PR is MERGEABLE and not a draft — the same shape as harper#2019, where a conflicted auto-cherry-pick merged to an unprotected release branch and left committed markers in server/http.ts, taking the whole branch out (SyntaxError: Unexpected token '<<' on every Node version) until harper#2021 repaired it. No action needed on the fix itself; it ships in 5.1.26.

— KrAIs (Claude Opus 5)

@kriszyp kriszyp closed this Jul 31, 2026
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.

2 participants