fix(replication): close on per-record value-decode failure instead of skipping past it (#440)#521
fix(replication): close on per-record value-decode failure instead of skipping past it (#440)#521kriszyp wants to merge 4 commits into
Conversation
…swallowing them (#440) An error escaping onWSMessage was logged and swallowed: the rest of the failed frame was silently dropped while later frames kept applying and confirming higher sequence ids — a permanent, undetected gap on the receiver (epic #430 Theme B / workstream #440 land-first safety item). Now the catch latches inbound processing off (queued frames on the messageProcessing chain must not commit past the hole) and closes with 1011 as a transient protocol close, so the normal retry path reconnects and resumes from the last durable cursor, re-streaming everything past it. Extracted as closeOnInboundMessageError with unit coverage, following the file's pure-decision-helper idiom. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…rame-vs-record error boundary (#440) Cross-model review findings on the previous commit: an unknown tableId made the inner decode-catch's own log line throw (unguarded tableDecoder.decoder deref), masking the original error and escaping to the outer close path by accident. Guard the derefs so the intended skip-and-log semantics and truthful diagnostics are restored. Also scope the closeOnInboundMessageError docstring: per-record value-decode failures are still skip-and-logged with the cursor advancing past them (residual tracked in #440 / #432). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…review) Gemini findings: guard the logger access fully (the log must never prevent the close) and make the decode-error log readable when the table decoder is unknown. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… skipping past it The inner catch around decodeBlobsWithWrites in onWSMessage logged and swallowed a per-record value-decode error while the batch resume cursor (maxBatchVersion -> end_txn -> sender COMMITTED_UPDATE) advanced past the un-decoded record regardless of `event` -- a permanent, undetected [error, head] gap on the receiver. This is the #1163/#1453 structure-fork error class (epic harper-pro#430 Theme B; workstream #440). Route it onto the outer transient-close path established by #511: on a resolved-decoder value-decode failure the catch now re-throws (after the existing accepted-blob cleanup, before the cursor advances) so closeOnInboundMessageError closes 1011 -> reconnect + resume from the last durable cursor. The reconnect rebuilds the per-connection table decoder from the peer's re-sent typedStructs/structures (the SET_TABLE handshake), so a forked/stale-structure decode heals on resume; a genuinely undecodable frame degrades to a visible, backed-off reconnect loop rather than silent loss (bounding that loop is W2 / harper-pro#432). An unknown tableId (decoder unresolved -- a distinct, transient schema-propagation case, not a structure fork) stays skip-and-log, since a reconnect wouldn't supply the missing table def. The decision is extracted as the pure helper classifyRecordDecodeError and pinned by recordDecodeErrorRecovery.test.mjs. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review
This pull request improves replication reliability by ensuring that value-decode failures on resolved table decoders trigger a connection close and reconnect rather than being silently skipped, which previously led to permanent data gaps. It introduces a helper function classifyRecordDecodeError to distinguish between resolved decoders (which should close) and unresolved decoders (which should skip and log), accompanied by unit tests. The review feedback suggests a cleaner, more idiomatic alternative to boxing the error in an object, recommending a boolean flag and a separate error variable instead.
| // Boxed (not the bare error) so the re-throw below can't be defeated by a falsy thrown value. | ||
| let decodeFailure: { error: unknown } | undefined; |
There was a problem hiding this comment.
Instead of boxing the error in an object { error } to avoid falsy values in a truthiness check, you can use a simple boolean flag hasDecodeFailure and a separate decodeError variable. This is cleaner, more idiomatic, and avoids the need for explanatory comments about boxing.
| // Boxed (not the bare error) so the re-throw below can't be defeated by a falsy thrown value. | |
| let decodeFailure: { error: unknown } | undefined; | |
| let hasDecodeFailure = false; | |
| let decodeError: unknown; |
| // A resolved-decoder value-decode failure must close, not skip: `maxBatchVersion` (and the | ||
| // end_txn resume cursor) advance below regardless of `event`, so skipping loses the record | ||
| // permanently. Latch it to re-throw onto the outer close path. See classifyRecordDecodeError. | ||
| if (classifyRecordDecodeError(!!tableDecoder) === 'close') decodeFailure = { error }; |
There was a problem hiding this comment.
If you adopt the hasDecodeFailure boolean flag pattern, update this line to set the flag and store the error.
| if (classifyRecordDecodeError(!!tableDecoder) === 'close') decodeFailure = { error }; | |
| if (classifyRecordDecodeError(!!tableDecoder) === 'close') { | |
| hasDecodeFailure = true; | |
| decodeError = error; | |
| } |
| // a window to complete, then unlink the files. (mirrors the pattern at the relocate path above.) | ||
| setTimeout(() => receivedBlobs.forEach(deleteBlob), 60000).unref(); | ||
| } | ||
| if (decodeFailure) throw decodeFailure.error; // re-throw after blob cleanup, before the resume cursor advances past this record |
There was a problem hiding this comment.
If you adopt the hasDecodeFailure boolean flag pattern, update this line to throw the stored error.
| if (decodeFailure) throw decodeFailure.error; // re-throw after blob cleanup, before the resume cursor advances past this record | |
| if (hasDecodeFailure) throw decodeError; // re-throw after blob cleanup, before the resume cursor advances past this record |
|
Reviewed; no blockers found. |
cb1kenobi
left a comment
There was a problem hiding this comment.
My AI review sees nothing wrong with this PR, but I think there's a dash of bloat. Not approving, but also not blocking.
| // A resolved-decoder value-decode failure must close, not skip: `maxBatchVersion` (and the | ||
| // end_txn resume cursor) advance below regardless of `event`, so skipping loses the record | ||
| // permanently. Latch it to re-throw onto the outer close path. See classifyRecordDecodeError. | ||
| if (classifyRecordDecodeError(!!tableDecoder) === 'close') decodeFailure = { error }; |
There was a problem hiding this comment.
This seems like an overly complicated way of saying:
| if (classifyRecordDecodeError(!!tableDecoder) === 'close') decodeFailure = { error }; | |
| if (tableDecoder) decodeFailure = { error }; |
Maybe add a comment?
What
The inner per-record value-decode catch in
onWSMessage(arounddecodeBlobsWithWrites) previously logged and swallowed a decode failure while the batch resume cursor still advanced past the un-decoded record —maxBatchVersion→ theend_txnlocalTime→ the sender'sCOMMITTED_UPDATEconfirm all move forward regardless of whethereventwas produced. The result is a permanent, undetected[error, head]gap on the receiver: the record is never applied and never redelivered.This is the #1163/#1453 structure-fork error class (an "end of buffer" / undecodable-record throw when the receiver's decoder structures diverge from the sender's), and the residual explicitly deferred from #511 — epic #430 Theme B, workstream #440.
The fix
On a resolved-decoder value-decode failure the catch now latches the (boxed) error and re-throws — after the existing accepted-blob cleanup, and before the
maxBatchVersion/RECEIVED_VERSIONadvance and theend_txnsend — so it rides the outer transient-close path introduced by #511 (closeOnInboundMessageError→close(1011)→ reconnect + resume from the last durable cursor).Because the throw precedes every cursor advance, the resume cursor cannot move past the hole, and the sender re-streams from the last durable position on reconnect.
Why close, not skip (the deferred decision)
A reconnect rebuilds this table's per-connection decoder from the peer's freshly re-sent
typedStructs/structures(theSET_TABLEhandshake), so a forked/stale-structure decode heals on resume. Skip-and-log has zero chance of healing a fork (same forked decoder, same connection, forever) and permanently loses the record — so close strictly dominates skip. A genuinely undecodable frame degrades to a visible, backed-off reconnect loop rather than silent loss; bounding that loop with an escalation budget is W2 (#432) territory.An unknown
tableId(decoder unresolved — a distinct, transient schema-propagation case, not a structure fork) still skips, since a reconnect wouldn't supply the missing table def. The disposition is extracted as the pure helperclassifyRecordDecodeError(following #440's decision-helper method) and pinned byrecordDecodeErrorRecovery.test.mjs.Cross-model review (thorough — Codex + Gemini + Harper domain pass)
No blockers. Traced and confirmed: cursor cannot advance past the failed record; accepted blobs cleaned before the throw;
outstandingCommitsbalanced (incremented only after the record loop, which the throw exits first); copy-mode cursor safe; unknown-tableIdstill skips. The re-throw is boxed ({ error }) so a falsy thrown value can't defeat it.Known, pre-existing tradeoff (not introduced here)
Any mid-frame error that reaches the outer catch after ≥1 record was already dispatched can, on reconnect, commit that frame prefix without the failed record (core
Table.tsresolves the staletxnInProgresson the nextbeginTxn). This is inherent to #511's close-resume path — this change only adds a new trigger onto it — and is strictly better than the skip-and-log status quo, which committed the prefix and dropped the record permanently. Under Harper's versioned/convergent source-apply the redelivered records dedup/apply idempotently, so the frame converges to whole once the failed record heals. If strict per-frame atomicity is ever required, the fix (decode-all-before-dispatch, or an abort barrier on reconnect) is a candidate for W2/#432. A related bounded disk-hygiene follow-up: sweep earlier-record accepted blobs on frame abort.Stacking
Stacked on #511 (base
kris/decode-loop-error-close-440, rebased onto currentmain). Merge #511 first.Test
unitTests/replication/recordDecodeErrorRecovery.test.mjs— 3 cases pinning both dispositions and the resume-cursor-safety contract.🤖 Generated with Claude Code