Repository navigation
Description
Summary
A replication subscription that parks at connected: true + RECEIVING_STATUS_WAITING with no receive progress is invisible to both reconcile recovery nets, so the wedge is permanent. The stated design intent — "guaranteeing the wedge can never be permanent" (subscriptionManager.ts L119-121) — is not met for this shape.
Observed in production: one user database stopped replicating on two receiving nodes simultaneously and stayed dead for ~21 hours until the containers were restarted manually. Throughout, cluster_status reported connected: true on every leg, get_status reported all components healthy, and the system database kept replicating normally on the same connections — so every health signal read green.
Root cause
Both nets exclude this state:
| Net | Gate | Wedged leg |
|---|---|---|
findWedgedNodeUrls |
entry.connected !== true (L263) |
connected: true → excluded |
findStalledReceivingNodeUrls → isReceiveStalled |
status.status === RECEIVING_STATUS_RECEIVING (L347) |
WAITING (0) → excluded |
export function isReceiveStalled(status, now, thresholdMs) {
return (
status != null &&
status.status === RECEIVING_STATUS_RECEIVING && // <- only Receiving
status.lastReceivedTime > 0 &&
now - status.lastReceivedTime >= thresholdMs
);
}findStalledReceivingNodeUrls was deliberately scoped to the ping-alive base-copy stall (#453), where a follower parks at Receiving. A steady-state (non-copy) subscription that stops receiving parks at WAITING instead, and nothing covers it.
Confirmed by elimination
Verified against the live wedged nodes; every other gate was satisfied, so only the status check can have failed:
entry.connectedwastruefor the full ~21h →findWedgedNodeUrlsexcluded.isDesired/shouldReplicateFromNodewas true:hdb_nodesself row present withreplicates: true, peer rowsreplicates: true,replication.databases: "*", local database present. (selfNodeReplicatescorrectly usesgetSync, so the documented block-cache/Promise-eviction hazard was not in play — though note thesystemdatabase was 6.7 GB here, well inside that danger zone.)lastReceivedTime > 0(frozen at the failure instant) and age ≫RECEIVE_STALL_THRESHOLD_MS(21h vs 15min) → both satisfied.- The once-per-episode guard (
receiveStallReconnectAt == null || lastReceivedTime > receiveStallReconnectAt) was satisfied — the only prior stalled-path kicks on that container were weeks earlier, andlastReceivedTimehad advanced far past them since. - Negative check: the stalled path's distinct log line (
Reconciling replication subscriptions stalled connected:true with no receive progress) appeared zero times across ~21h (~84 reconcile opportunities at the 15-min cadence), but did appear on earlier dates on the same container — so the detector is live code, andstalledByUrlwas genuinely always empty.
Reproduced independently on two receiving nodes, wedged within ~12 seconds of each other.
Trigger
The sender crashed and restarted mid-stream. In its final seconds it emitted a record whose typed-structure definition never arrived, and both receivers threw during value decode:
[error] [replication]: Error decoding replication message, record id: <redacted>
typed structures for current decoder[] <- empty
structures for current decoder[[...]]
The decode handling itself behaved correctly (logged, skipped, advanced the watermark). The connection then dropped 1006, reconnected ~30s later for both databases — and system resumed while the user database never delivered another record.
Note the trigger is generic: any sender crash under load can produce an undecodable record, so this is not specific to one deployment.
Why the obvious fix is wrong
RECEIVING_STATUS_WAITING is the normal idle/between-batches state — a healthy leg with a quiet source sits there too, with an equally old lastReceivedTime. Simply relaxing the status check would force-reconnect every legitimately idle subscription every 15 minutes.
A correct fix needs to distinguish "idle because the sender has nothing" from "wedged while the sender has data".
Constraint on the design
The receiver cannot currently make that distinction locally. The shared status buffer carries no advertised sender head — the positions are CONFIRMATION_STATUS, RECEIVED_VERSION, RECEIVED_TIME, SENDING_TIME, LATENCY, RECEIVING_STATUS, BACK_PRESSURE_RATIO, BLOB_FAILURE_COUNT, LAST_BLOB_FAILURE_TIME, CONNECTION_STATE, LAST_LIVENESS_TIME, LAST_ERROR_CODE, LAST_ERROR_TIME. LAST_LIVENESS_TIME does not help — pongs keep advancing on a wedged-but-open socket exactly as on an idle one.
The sender does hold the discriminating signal: during the incident the sender's own lastCommitConfirmed for both peers stayed frozen at the failure instant while it held newer commits. That is already tracked per peer per database.
Candidate directions
- Sender-side unconfirmed-commit stall detection — sender detects "this peer has unconfirmed commits older than threshold" and terminates its side, forcing the receiver to reconnect and resubscribe. Uses data already tracked; the sender is the only party that can tell idle from wedged. Adds a recovery net on the sending side.
- Advertise the sender head to the receiver (new shared-status position / protocol field) so the receiver can compare its applied watermark against the peer's head and keep recovery where
forceReconnectalready lives. Requires a protocol addition. - Targeted recovery at the decode-failure site — mark the connection for forced reconnect/resubscribe when a value decode throws. Cheap and fixes the observed trigger, but leaves the general blind spot open for any other cause of a
WAITINGpark.
Directions 1 and 2 close the gap; 3 only addresses the observed trigger.
Impact
- Silent: no error after the initial decode failure, and every health surface reads green.
- Unbounded duration: no mechanism recovers it; only a manual restart of the receiving node does.
- Correctness-visible to clients: readers on affected nodes served stale data indefinitely while believing they were current.
Environment
- Reproduced on
harper-pro5.1.26; the gap is present in currentmain(verified atreplication/subscriptionManager.tsL340-351 and L263). - 3-node mesh,
replication.databases: "*", RocksDB storage.
Metadata
Metadata
Assignees
Labels
Type
Fields
Priority
Activity
dillardm89 commentedon Sep 3, 2026
Design note + handoff
Not building this here — it belongs inside the #431 recovery-net design rather than beside it. Handing off to @kriszyp with the analysis and the intended direction recorded. Design note follows so nothing has to be re-derived.
Design assessment
The hard constraint (why the one-line fix doesn't work)
WAITING+ stalelastReceivedTimeis also the exact signature of a healthy idle leg whose source has nothing to send. Relaxing the status check alone would force-reconnect every idle subscription every 15 minutes.Traced in current
main: the receiver cannot discriminate locally. Shared-status positions areCONFIRMATION_STATUS,RECEIVED_VERSION,RECEIVED_TIME,SENDING_TIME,LATENCY,RECEIVING_STATUS,BACK_PRESSURE_RATIO,BLOB_FAILURE_COUNT,LAST_BLOB_FAILURE_TIME,CONNECTION_STATE,LAST_LIVENESS_TIME,LAST_ERROR_CODE,LAST_ERROR_TIME— none advertises the sender's head version.LAST_LIVENESS_TIMEdoes not help: pongs advance on a wedged-but-open socket exactly as on an idle one.The sender holds the discriminating signal. During the incident its own
lastCommitConfirmedfor both peers stayed frozen at the failure instant while it held newer commits — and that is already tracked per peer per database.Intended direction (A + C)
A — sender-side unconfirmed-commit stall detection. Sender detects "this peer has unconfirmed commits older than threshold", terminates its side; receiver reconnects and resubscribes. Uses data already tracked, no protocol change, no mixed-version upgrade concern. Must mirror the existing "progress resets the clock" discipline so it never fires for a merely slow or back-pressured peer.
C — targeted recovery at the decode-failure site. On a value-decode throw, mark the connection for forced reconnect/resubscribe. Small, well-contained, fixes the path actually observed, and is a reasonable release-branch patch candidate on its own.
A closes the general blind spot; C covers the observed trigger cheaply and is backportable. Rejected B (advertise sender head to the receiver) as the primary route: it is the cleanest ownership boundary but costs a wire/shared-status contract change plus rolling-upgrade compatibility work for a mixed-version mesh.
To confirm before building
verify:whether Replication W1: Connection & health — single source of truth #431's W1 truth-shadow work already models "desired-but-not-progressing" in a way that subsumes option A — if so, this is a case to fold in rather than a new net.verify:whether a sender-side reconcile hook already exists to hang A off, or whether it is net-new.Note on scale
The affected cluster ran a 6.7 GB
systemdatabase. That is well inside the danger zone described inselfNodeReplicates' comment (self row evicted past RocksDB block cache → un-awaitedget()returns a Promise →?.replicatessilentlyundefined→ all replication recovery disabled for a still-desired peer). It was not implicated here —knownNodes.tscorrectly usesgetSync— but anyhdb_nodesread path still using plainget()would be exposed at that size.Recover a replication leg wedged at connected:true / WAITING
Resubscribe after an undecodable record so a structure fork cannot si…
Recover a replication leg wedged at connected:true / WAITING
Resubscribe after an undecodable record so a structure fork cannot si…
Re-confirm a blob-durable commit to the sender, and close two rebase …
Bound the stall exemption, gate the peer shape on a capability, and s…
Contain the drain confirmation, key the resync episode on drops, and …
Hold the exemption on real send progress, and cap only a stalled one
Confirm the same watermark the end_txn persists, not the clamped one
52 remaining items
Confirm the same watermark the end_txn persists, not the clamped one
Read why the send iterable stopped instead of inferring it from timing
Keep asking why the iterable stopped, so a spent budget can lapse
Repair the stale iterable in place instead of closing the socket
Re-scan after a repair instead of parking on a wake already spent
Resume from the pre-drain cursor when repairing, not from where the d…
Report close failures, and unit-test the rebuild floor
Correct test docstrings that still described the removed close
Do not advertise a cursor past a break, and wake for a denied rebuild
Gate finalizing on the break itself, and clear the retry timer
Rewind on any break, and discard the frame instead of shipping it dou…
Rewind before anything that can throw, and stop the skip timer publis…
Deliver the prefix before a break, as harper#2087's policy requires
kriszyp commentedon Sep 29, 2026
Agents have put together a pretty massive PR to try to address this. Because the issue really is quite vague without much clear evidence of the actual root cause. And the resulting PR is at an irresponsible level of code and complexity to try to maintain. So I don't think I am going to merge it.
Once we have more concrete evidence on the root cause, I will pick this up again.
Recover a replication send leg stopped at a torn transaction-log frame (