fix(replication): hold and reconnect on a sync blob-setup fault instead of skipping the record as undecodable - #717
fix(replication): hold and reconnect on a sync blob-setup fault instead of skipping the record as undecodable#717heskew wants to merge 4 commits into
Conversation
…ad of skipping the record as undecodable (#715) receiveBlobs runs synchronously inside the decode blob callback — stream creation, in-flight registration into the root store, and the synchronous saveBlob start — so a local, typically transient fault there (EMFILE, directory creation, a store error) surfaced through the decode catch and was classified as a permanent decode failure: decode-drop counted, resume cursor advanced, a still-deliverable record sealed under it. All of the transient-vs-permanent care (hasBlobGap latch, #403 source-unavailable split, #683 watchdog) lives on the async save path and never saw it. - tag sync throws from the blob callback as BlobSetupError (cause chained) - classifyReplicationDecodeError: new 'hold-blob-setup' verdict, checked before the skip classes so a wrapped fault cannot demote to a skip - catch routes the hold verdict exactly like the missing-decoder hold: DECODE_HOLD metric, inbound latched off first (#440), close(1011), reconnect resumes from the durable cursor and re-requests the record - remove dead shouldCloseOnRecordDecodeFailure and realign the stale closeOnInboundMessageError coverage note and unit tests with the shipped disposition (its documented re-throw policy was never called) Surfaced by kriszyp's post-approval review on #691. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T4VjVDt6egBUxmGiASuahs
…tion trimmed, identity constraint documented on BlobSetupError The instanceof-across-decode-stack ledger question is traced closed: the blob extension invokes the callback bare (core/resources/blob.ts:2105), msgpackr's unpack catches annotate and rethrow the same object, and decodeBlobsWithWrites rethrows raw — identity holds; the follow-up e2e injection test will pin it against future core drift. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T4VjVDt6egBUxmGiASuahs
There was a problem hiding this comment.
Code Review
This pull request refactors replication error handling by introducing BlobSetupError to distinguish transient local blob-store faults from permanent decode errors. Instead of skipping records on local blob setup failures, the system now holds and reconnects to retry. The review feedback suggests enhancing the robustness of the error classification by using a custom property check (e.g., isBlobSetupError) rather than relying solely on instanceof checks, which can fail if errors are wrapped or cloned.
|
Reviewed; no blockers found. |
…ototype-identity loss Gemini review: instanceof alone is fragile across module instances / clones. The classifier now matches the brand first with instanceof as same-realm fallback; brand-without-prototype pinned by a unit test. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T4VjVDt6egBUxmGiASuahs
… note Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T4VjVDt6egBUxmGiASuahs
|
I verified the load-bearing claims directly: the hold branch mirrors the missing-decoder hold including the latch-before-close ordering, and returns before the blob unlink, the watermark, and the maxBatchVersion advance; the transient-gap rule in receiveBlobs' catch does leave stubs for the reconnect re-stream to overwrite, so keeping accepted blobs on hold is consistent with existing policy; the identity chain (bare callback invocation in core, msgpackr rethrowing the same object, decodeBlobsWithWrites rethrowing raw) checks out; and shouldCloseOnRecordDecodeFailure has no production caller, so the removal is safe. Ruling on the four decision points: (1) accept the throw-site proxy; if a data-dependent throw ever lands there, the failure mode is a loud reconnect loop rather than a silent seal, and the classifier isolation makes narrowing to an errno allowlist cheap later. (2) Keep the wrapper; the own-property brand plus the traced chain is enough, and the closure latch remains available if drift is ever caught. (3) Hold-forever is the right #715 semantics. (4) The bounded blob leak is acceptable given re-delivery re-saves the same fileIds. Two things before this leaves draft:
Lavinia, via Claude (I reviewed this before posting) |
receiveBlobsruns synchronously inside the decode blob callback, so a local, typically transient blob-store fault (EMFILE, directory creation, a store error registering the in-flight marker) surfaced through the decode catch and was classified as a permanent decode failure —decode-dropcounted, resume cursor advanced, a still-deliverable record permanently sealed under it on the follower. This ships in v5.2.2. The fix tags such throws asBlobSetupErrorat the only place they can originate, adds ahold-blob-setupverdict toclassifyReplicationDecodeError(checked before the skip classes), and routes it through the existing missing-decoder hold:DECODE_HOLDmetric, inbound latched off first (#440),close(1011), reconnect resumes from the durable cursor and re-requests the record. Also removes the deadshouldCloseOnRecordDecodeFailurehelper and realigns its stale docs/tests with the shipped disposition. Fixes #715. Surfaced by @kriszyp's post-approval review on #691.For the human reviewer
receiveBlobs's prologue is treated as a local transient fault, rather than inspecting the fault itself (an errno allowlist). A data-dependent throw there (e.g.getFileIdon a malformed-but-decoded blob ref) would hold-loop instead of skip-and-count. Chosen for simplicity and because the prologue's known failure modes are local; the classifier is isolated, so revisiting is cheap.instanceofacross the decode stack (settled by trace, veto-able). The wrapper must cross core's decode stack identity-intact. Traced closed against current core: the blob extension invokes the callback bare (core/resources/blob.ts:2105), msgpackr's unpack catches annotate and rethrow the same object,decodeBlobsWithWritesrethrows raw; constraint documented on the class. The alternative (closure-local fault latch, no identity dependence) remains strictly more robust to future core changes — say the word and I'll switch. The proposed follow-up e2e injection test pins identity against drift either way.Verification
injectedKeyCustody/procfd test) fails identically on untouched main — pre-existing macOS platform limitation.test:integration:all): 131 tests — 118 pass, 12 skipped (stress-gated), 1 failure (can track child process CPU time) reproduced identically on an untouched origin/main worktree — pre-existing platform failure, not from this change.DECODE_HOLD, no cursor advance, record delivered after reconnect) is the proposed follow-up e2e via amaybeInjectDecodeFailureForTest-style hook (see Sync faults from receiveBlobs setup are classified as permanent decode failures — cursor advances past a recoverable record #715 design note).Review coverage
Authored by Claude (Fable 5). Cross-model review at 62c9098: codex ✓ (graded leg), Harper-domain adjudication ✓ (adjudicated severity: minor; the codex major — hold return bypassing blob cleanup — was dropped as not-real with traced evidence: transient-gap rule +
retireInstancesweep + crash-safe unwired-stream destroy). gemini ✗ (no-output), cursor-grok ✗ (git fetch timeout), cursor-composer pruned. Step-6 planning-mode CLI did not run before coding (design note went to #715 instead); the implementation-mode full review above is the outside coverage. HEAD since moved only by a comment-level amendment (8357f67) answering the ledger's identity question — no behavior delta since the reviewed SHA.Human-Review-Need: 4 @ 8357f67