Skip to content

fix(replication): close on per-record value-decode failure instead of skipping past it (#440)#521

Open
kriszyp wants to merge 4 commits into
mainfrom
kris/inner-decode-close-440
Open

fix(replication): close on per-record value-decode failure instead of skipping past it (#440)#521
kriszyp wants to merge 4 commits into
mainfrom
kris/inner-decode-close-440

Conversation

@kriszyp

@kriszyp kriszyp commented Jul 5, 2026

Copy link
Copy Markdown
Member

What

The inner per-record value-decode catch in onWSMessage (around decodeBlobsWithWrites) previously logged and swallowed a decode failure while the batch resume cursor still advanced past the un-decoded record — maxBatchVersion → the end_txn localTime → the sender's COMMITTED_UPDATE confirm all move forward regardless of whether event was 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_VERSION advance and the end_txn send — so it rides the outer transient-close path introduced by #511 (closeOnInboundMessageErrorclose(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 (the SET_TABLE handshake), 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 helper classifyRecordDecodeError (following #440's decision-helper method) and pinned by recordDecodeErrorRecovery.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; outstandingCommits balanced (incremented only after the record loop, which the throw exits first); copy-mode cursor safe; unknown-tableId still 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.ts resolves the stale txnInProgress on the next beginTxn). 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 current main). Merge #511 first.

Test

unitTests/replication/recordDecodeErrorRecovery.test.mjs — 3 cases pinning both dispositions and the resume-cursor-safety contract.

🤖 Generated with Claude Code

kriszyp and others added 4 commits July 4, 2026 16:01
…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>

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +3485 to +3486
// Boxed (not the bare error) so the re-throw below can't be defeated by a falsy thrown value.
let decodeFailure: { error: unknown } | undefined;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.

Suggested change
// 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 };

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

If you adopt the hasDecodeFailure boolean flag pattern, update this line to set the flag and store the error.

Suggested change
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

If you adopt the hasDecodeFailure boolean flag pattern, update this line to throw the stored error.

Suggested change
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

@claude

claude Bot commented Jul 5, 2026

Copy link
Copy Markdown
Contributor

Reviewed; no blockers found.

@cb1kenobi cb1kenobi left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 };

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems like an overly complicated way of saying:

Suggested change
if (classifyRecordDecodeError(!!tableDecoder) === 'close') decodeFailure = { error };
if (tableDecoder) decodeFailure = { error };

Maybe add a comment?

Base automatically changed from kris/decode-loop-error-close-440 to main July 10, 2026 04:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants