Skip to content

[superseded by #475 — recreated on redacted branch] empty-subscription delayed close - #473

Closed
kriszyp wants to merge 2 commits into
mainfrom
kris/jjill-empty-sub-intentional-close
Closed

kriszyp wants to merge 2 commits into
mainfrom
kris/jjill-empty-sub-intentional-close

Conversation

@kriszyp

@kriszyp kriszyp commented Jun 24, 2026 •

Copy link
Copy Markdown
Member

Fixes #471

Summary

In replicateOverWS, when a connection's wire subscription went 0-length, scheduleClose fired
close(1008, ..., intentional=true) unconditionally. intentional=true makes the
NodeReplicationConnection close handler set isFinished/intentionallyUnsubscribed, emit 'finished'
(removing it from the worker connections map), and never reconnect. That finishes a connection whose
subscription emptied spuriously — notably when the harper-pro#470 self-record gate misread collapses
subscribeToNode's nodes.filter(shouldReplicateFromNode) to [] for a still-desired peer whose
database is still present — permanently stranding the peer at connected:false (the trigger half of the
live 4-node preprod wedge).

Where to look — the discriminator (revised after Codex review)

My first attempt keyed the decision on connection.nodeSubscriptions. Codex correctly flagged that as
a no-op
: the wire subscription is a 1:1 .map() of connection.nodeSubscriptions, so at close time
that array is empty in BOTH the genuine and spurious cases (a repopulating subscribe() would have
cleared delayedClose before the timer fired). The only two empty-subscribe([]) sources are:

  1. replicator.assignReplicationSource on database removal — the local database is gone. Terminal.
  2. subscribeToNode's nodes.filter(shouldReplicateFromNode) collapsing to [] while the database is
    still present — spurious. Must stay retryable.

So the fix discriminates on database presence: shouldFinishEmptySubscriptionClose(databasePresent)
= !databasePresent, with databasePresent = getDatabases()?.[databaseName] != null read at close time.
Please sanity-check this discriminator — in particular that there is no genuine-unsubscribe path that
keeps the local database present (the unsubscribe() user path sets intentionallyUnsubscribed directly
and never reaches this delayed close, so it is unaffected). The database-absent (cleanup) behavior is
strictly unchanged from before.

Relationships (open context for the reviewer)

Tests: unitTests/replication/emptySubscriptionClose.test.mjs (database-present ⇒ retryable;
database-gone ⇒ finish); proven red-without/green-with. Full replication unit suite green (181 passing).

Generated by Claude Code (Claude Opus 4.8).

…tion delayed close (#471)

In replicateOverWS, when the wire subscription went 0-length, scheduleClose fired
close(1008, ..., intentional=true) unconditionally. That marks the NodeReplicationConnection
isFinished/intentionallyUnsubscribed, emits 'finished' (removing it from the worker connections
map), and never reconnects. But after a base-copy resync a still-desired peer (its
nodeSubscriptions still populated) can briefly map to a 0-length wire subscription, which
permanently wedged it connected:false with no reconnect (live 4-node preprod).

New pure predicate shouldFinishEmptySubscriptionClose(connection) gates intentional on whether
the peer is genuinely no longer desired: a genuine unsubscribe sets nodeSubscriptions to []
(replicator.assignReplicationSource subscribe([],false) and unsubscribeFromNode->unsubscribe()),
so empty/absent => finish (idle cleanup unchanged); populated => not finished, so the close
handler reconnects and the connection self-heals.

Sibling to harper-pro#466/#467 (outbound reconnect-recovery) and #420 (open-but-idle wedge).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@gemini-code-assist

Copy link
Copy Markdown

Warning

You have reached your daily quota limit. Please wait up to 24 hours and I will start processing your requests again!

@claude

claude Bot commented Jun 24, 2026

Copy link
Copy Markdown
Contributor

Reviewed; no blockers found.

…resence, not nodeSubscriptions

Codex cross-model review: the wire subscription is a 1:1 map of connection.nodeSubscriptions, so at
close time that array is empty in BOTH the genuine-unsubscribe and spurious-empty cases — gating on
it was a no-op for the real path. The only two empty-subscribe([]) sources are database removal
(assignReplicationSource — database gone, genuinely terminal) and subscribeToNode's
nodes.filter(shouldReplicateFromNode) collapsing to [] while the database is still present (spurious,
e.g. the #470 self-gate misread for a still-desired peer). So discriminate on database presence:
finish only when getDatabases()[databaseName] is gone; an empty subscription while the database is
still present stays retryable and self-heals. Preserves the prior behavior exactly for the
database-absent case.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@kriszyp kriszyp closed this Jun 24, 2026
@kriszyp
kriszyp deleted the kris/jjill-empty-sub-intentional-close branch June 24, 2026 06:01
@kriszyp kriszyp changed the title fix(replication): don't finish a still-desired peer on empty-subscription delayed close (#471) [superseded by #475 — recreated on redacted branch] empty-subscription delayed close Jun 24, 2026
@kriszyp

kriszyp commented Jun 24, 2026

Copy link
Copy Markdown
Member Author

Superseded by #475 (recreated on a renamed, redacted branch). This PR's branch was renamed for redaction, which closed it; the equivalent open draft is #475.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant