fix(replication): reconcile connected bit UP from shared-memory truth; log corrections (#431)#523
fix(replication): reconcile connected bit UP from shared-memory truth; log corrections (#431)#523kriszyp wants to merge 3 commits into
Conversation
…; log corrections (#431) Completes the truth-reconcile consumer #445 started. The 5s reconcile pass corrected the edge-triggered `connected` bit only downward (truth says down -> flip to false, feeding the wedge re-drive); a connected:false entry whose connect edge was lost or never processed (#289, the founding W1 bug) stayed false until findWedgedNodeUrls force-reconnected a perfectly healthy link 30s later. Now truth CONNECTED + fresh liveness corrects the bit up in place, no disruptive reconnect. Never-connected (undefined) entries are deliberately not up-corrected - they are mid-connect and owned by the connect/retry path. Both directions now log the correction (they are transitions, self-limiting): edge-bit-vs-truth disagreement is exactly the desync class W1 exists to retire, so its production frequency should be observable before deeper watchdog demotion leans on the truth. The rules live in an exported pure helper (reconcileEntryWithTruth), following the file's pure-decision-helper pattern, with unit coverage for both corrections, the liveness>0 gate, wedge-clock preservation, and the undefined-entry exclusions. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review
This pull request introduces the reconcileEntryWithTruth function in subscriptionManager.ts to reconcile the main thread's connection state with the authoritative shared-memory truth in both directions, along with comprehensive unit tests. Feedback suggests adding an explicit return undefined; at the end of the new function to prevent potential compilation errors under strict TypeScript configurations.
| if (entry.connected === false && truth.connected) { | ||
| entry.connected = true; | ||
| entry.disconnectedAt = undefined; | ||
| return 'up'; | ||
| } | ||
| } |
There was a problem hiding this comment.
To prevent potential compilation errors under strict TypeScript configurations (such as noImplicitReturns: true), it is recommended to add an explicit return undefined; at the end of the function.
| if (entry.connected === false && truth.connected) { | |
| entry.connected = true; | |
| entry.disconnectedAt = undefined; | |
| return 'up'; | |
| } | |
| } | |
| if (entry.connected === false && truth.connected) { | |
| entry.connected = true; | |
| entry.disconnectedAt = undefined; | |
| return 'up'; | |
| } | |
| return undefined; | |
| } |
|
Reviewed; no blockers found. |
…ion (#431) Cross-model review caught a blocker in the up-correction this PR adds: the per-(db, peer) shared status buffer is keyed only by (db, peer), but its connection-truth slots (CONNECTION_STATE / LAST_LIVENESS / LAST_ERROR, added by #445) were written by ANY connection resolving that buffer — including a cache-miss retrieval connection (getRetrievalConnectionByName, which connect()s with connection:this) and, via the ungated handshake/receive writes, an inbound server connection from the same peer. In a full mesh, an active inbound B->A stream (or a reused retrieval socket) kept (db,B) stamped CONNECTED with fresh liveness even after the outbound A->B subscription genuinely died. The new up-correction then resurrected the dead entry to connected:true, so findWedgedNodeUrls skipped it -> silent one-directional replication halt / divergence. The inverse (a retrieval connection's close stamping DOWN over a healthy subscription) caused spurious wedge-recovery reconnects. #445's down-only correction failed safe (a stray DOWN is at worst a re-stream); the up-correction is what makes the contamination consequential, so the fix lands with it. Enforce the ownership invariant at every truth-slot write (close, forceReconnect teardown, handshake, receive, pong): only the connection that OWNS the (db,peer) subscription may write slots 9-12, via connectionOwnsTruth() = `connection?.nodeSubscriptions !== undefined`. nodeSubscriptions is set by subscribe() and never cleared, and is never set on a retrieval connection (connect() only) or an inbound connection (no options.connection) — so it is a reliable, write-time ownership marker, checked at write time rather than via the frozen open-time isSubscriptionConnection snapshot. Receive-progress slots (RECEIVED_TIME/VERSION, RECEIVING_STATUS) and latency are left as-is; only the authoritative connection-state slots are owner-gated. This also cleans up the cluster_status view, which reads the same buffer. connectionOwnsTruth is exported with unit coverage pinning the subscription vs retrieval vs inbound cases. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Cross-model review (thorough) found and fixed a blocker — pushedThe Gemini + Codex + Harper-domain adjudication surfaced a real correctness blocker that this PR's up-correction would have introduced, now fixed in 636d825. The hazard: the per-(db, peer) shared status buffer is keyed only by The fix (root-cause, not defensive): enforce the ownership invariant — only the connection that owns the Adjudicated non-issues: Gemini's "up-correction flaps against a fresh disconnect edge" is bounded by the 120s staleness net and subsumed by the ownership fix; the Full |
) Completeness follow-up to the ownership gate: the pause path (sendPing, while pauseReasons > 0) refreshes LAST_LIVENESS_TIME on the shared (db, peer) buffer so a healthy-but-paused link isn't timed out, but it was not owner-gated. A paused inbound or cache-miss retrieval connection sharing the buffer key would keep a dead subscription's liveness fresh — the same masking the ownership gate closes elsewhere. Gate it on connectionOwnsTruth(options.connection) too. This is now the last of the six truth-slot write sites; grep-verified all writes to slots 9-12 are owner-gated. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Focused re-review of the ownership-gate fix — complete & correctRan a scoped re-review (Codex + Harper-domain adjudication) over the newly-gated write sites. Independent completeness check caught one more site the first Codex pass missed and reported clean: the back-pressure-pause liveness write ( The domain pass independently re-derived completeness (not trusting the Codex verdict, which had confabulated) and confirmed:
Verdict: no blockers, no significant concerns. Full One noted follow-up (pre-existing, out of scope, NOT this PR): a retrieval connection actively receiving sets Marking Ready. |
What
Completes the W1 (#431) truth-reconcile consumer that #445 started. The 5s
reconcileWorkerspass corrected the edge-triggeredconnectedbit only downward (shared-memory truth says down → flip to false, feeding the wedge re-drive). This PR adds:connected:falseentry whose connect edge was lost or never processed (Replication: ~9/11 outgoing peers show connected:false in connectionReplicationMap after restart despite being reachable #289, the founding W1 bug) previously stayed false untilfindWedgedNodeUrlsforce-reconnected a perfectly healthy link 30s later. Now truthCONNECTED+ fresh liveness corrects the bit in place — no disruptive reconnect. Never-connected (undefined) entries are deliberately not up-corrected: they're mid-connect and owned by the connect/retry path.How
The correction rules move into an exported pure helper,
reconcileEntryWithTruth(entry, truth, now), following the file's established pure-decision-helper pattern (isReceiveStalled,findWedgedNodeUrls, …). The reconcile loop calls it per (db, peer) entry and owns the logging. Down-correction semantics are unchanged: liveness-reported-at-least-once gate,disconnectedAtstamped only if absent (doesn't restart the wedge clock).Safety notes
state === CONNECTEDand fresh liveness (LIVENESS_STALE_MS), so a worker that died without writing DOWN can't cause a false up-correction beyond the staleness window — and thefindStaleNodeUrlsexited-worker net operates independently of theconnectedbit.Tests
unitTests/replication/reconcileEntryWithTruth.test.mjs— 8 cases: both corrections, thelastLiveness > 0gate, wedge-clock preservation, the undefined-entry up-exclusion, and agreement no-ops. FullunitTests/replication/dir: 266 passing.Part of #430 (epic) · W1 #431 · follows #445.
🤖 Generated with Claude Code