fix(replication): classify undecodable records + hold on unknown table id (#537)#545
fix(replication): classify undecodable records + hold on unknown table id (#537)#545kriszyp wants to merge 2 commits into
Conversation
There was a problem hiding this comment.
Code Review
This pull request introduces a classification system for replication decode errors, distinguishing between missing shared structures (which trigger a specific metric and warning) and other unexpected decode failures (which are logged as errors). It also adds unit tests to verify this classification logic. The reviewer pointed out a potential issue where a secondary TypeError could be thrown if tableDecoder is undefined inside the catch block, and suggested using optional chaining to prevent masking the original error.
| if (classifyReplicationDecodeError(error) === 'skip-missing-structure') { | ||
| // Missing shared structure (harper#1163): genuinely absent on this node, re-copy re-ships the | ||
| // same undecodable bytes. Surface it through the same metric core's local-read path fires so | ||
| // the drop is alertable rather than laundered as emptiness. Kept concise: this is the #537 | ||
| // flood path (an interrupted copy can replay thousands of these), so we do NOT stringify the | ||
| // decoder's full struct dictionaries per record — the metric carries the volume and `error` | ||
| // already names the specific missing structure. | ||
| recordAction(true, DECODE_MISSING_STRUCTURE_METRIC, databaseName + '.' + tableDecoder.name); | ||
| logger.warn?.( | ||
| connectionId, | ||
| `Skipping replication record referencing a shared structure missing on this node (permanent; see harper#1163), table: ${tableDecoder.name}, record id: ${id}`, | ||
| error | ||
| ); | ||
| } else { | ||
| // Unexpected post-root-cause decode failure — log loudly for investigation. Still skipped (the | ||
| // record is undecodable on this node); the cursor advances past it below. Pass the decoder | ||
| // dictionaries as objects (not eager JSON.stringify) so the formatting cost is paid only when | ||
| // the log actually emits, not on every dropped record. | ||
| logger.error?.( | ||
| connectionId, | ||
| 'Error decoding replication message, record id: ' + id, | ||
| 'typed structures for current decoder', | ||
| tableDecoder.decoder.typedStructs, | ||
| 'structures for current decoder', | ||
| tableDecoder.decoder.structures, | ||
| 'encoded message', | ||
| auditRecord.encoded.subarray(0, 1000), | ||
| auditRecord, | ||
| error | ||
| ); | ||
| } |
There was a problem hiding this comment.
If tableDecoder is undefined (e.g., due to an unknown tableId or transient schema-propagation lag), attempting to access properties on it directly inside the catch block will throw a secondary TypeError. This secondary error will escape the catch block, masking the original error and potentially crashing the connection or process.
Using optional chaining (tableDecoder?.name and tableDecoder?.decoder) prevents this double-throw and ensures that the original error is logged correctly.
if (classifyReplicationDecodeError(error) === 'skip-missing-structure') {
// Missing shared structure (harper#1163): genuinely absent on this node, re-copy re-ships the
// same undecodable bytes. Surface it through the same metric core's local-read path fires so
// the drop is alertable rather than laundered as emptiness. Kept concise: this is the #537
// flood path (an interrupted copy can replay thousands of these), so we do NOT stringify the
// decoder's full struct dictionaries per record — the metric carries the volume and error
// already names the specific missing structure.
recordAction(true, DECODE_MISSING_STRUCTURE_METRIC, databaseName + '.' + (tableDecoder?.name ?? 'unknown'));
logger.warn?.(
connectionId,
"Skipping replication record referencing a shared structure missing on this node (permanent; see harper#1163), table: " + (tableDecoder?.name ?? "unknown") + ", record id: " + id,
error
);
} else {
// Unexpected post-root-cause decode failure — log loudly for investigation. Still skipped (the
// record is undecodable on this node); the cursor advances past it below. Pass the decoder
// dictionaries as objects (not eager JSON.stringify) so the formatting cost is paid only when
// the log actually emits, not on every dropped record.
logger.error?.(
connectionId,
'Error decoding replication message, record id: ' + id,
'typed structures for current decoder',
tableDecoder?.decoder?.typedStructs,
'structures for current decoder',
tableDecoder?.decoder?.structures,
'encoded message',
auditRecord.encoded.subarray(0, 1000),
auditRecord,
error
);
}|
Reviewed; no blockers found. |
Reproduction / empirical validationRan the harper#1163 structure-divergence cluster harness and probed the actual decode path to confirm this fix targets the real failure mode. Harness baseline: Which decode-failure mode does the replication receive path hit? The replication decoder is a raw
Permanent vs recoverable: confirmed permanent, for the right reason. The sender emits #537 signature match: #537 is a count gap ("thousands of rows below source"), not a values-corruption gap — consistent with mode A (records thrown + skipped, cursor advancing), not mode B (records applied with wrong values, count intact). Latent, out of scope: mode B (diverged same-id structure) would silently corrupt if the — KrAIs (Claude Opus 4.8), with Kris |
…e id (#537) The receive-apply path wrapped record decode in a bare catch that logged and skipped EVERY decode failure, advancing the resume cursor past it without any classification or metric — silently sealing the skipped range and, for the genuinely-unrecoverable missing-structure case (harper#1163), never surfacing it. Two receive-path failures are now handled by their true nature: 1. Decode failure = PERMANENT (skip + surface). Transient conditions are handled upstream; every known decode source is root-caused, so one reaching this catch is unrecoverable old-version/corrupt data no re-copy heals (verified: the replication decoder is a raw StructonPackr that THROWS "Could not find typed structure" on an absent structure, and the sender always sends TABLE_FIXED_STRUCTURE before the dependent record — so a throw means the structure is genuinely absent, not merely unsynced). classifyReplicationDecodeError routes the missing-structure subclass to the same `decode-missing-structure` metric core's local-read path fires (alertable, concise for the #537 flood), everything else to a lazy error log. Both skip + advance. 2. Unknown table id = TRANSIENT (hold, don't seal). tableDecoders is populated only by TABLE_FIXED_STRUCTURE, which precedes the first record over an ordered WS, so a missing decoder means a missed structure sync or un-propagated schema (#1497) — recoverable on reconnect. The old code logged, fell through, and double-threw on tableDecoder.decoder inside the catch, escaping it and wedging the leg. Now it holds: close + reconnect + resume from the durable cursor (which re-sends TABLE_FIXED_STRUCTURE), without advancing past the record. This also makes tableDecoder provably non-null in the decode catch. Adds classifyReplicationDecodeError + unit coverage. harper-pro-only. Cross-model reviewed (Codex + Gemini + Harper-domain adjudication); reproduced against the harper#1163 divergence cluster harness (4/4 pass, mode-A throw confirmed, gap permanent, fix surfaces it). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
9a63188 to
f3a742a
Compare
… component tests (#537) Two tests for the decode-classify fix landed in the parent commit: Test 1 — Integration: poisonedCopyCursorDataLoss.test.mjs Characterizes the cursor-trust symptom the #537 decode-drop exploited. A receiver node presents a copyCursor with afterKey='row-0500' (injected via HARPER_TEST_INJECT_COPY_CURSOR_JSON) against a 1000-row source. The leader trusts the cursor, skips rows row-0001..row-0500, and delivers only the tail; B ends up with 500 rows and believes itself current — the gap is permanent. copyStartTime is set to 24h in the future to suppress audit replay (which would otherwise backfill the skipped range and hide the symptom). Passes 1/1 (~24s). Marked as a characterization/regression-guard: this still- present cursor-trust behavior is correct for a legitimate interrupted copy; it only becomes a data-loss vector when the cursor is wrong. Adds a minimal test hook (maybeInjectCopyCursorForTest) following the existing HARPER_TEST_COPY_STALL_ONCE_DB / HARPER_TEST_REPLICATION_WEDGE_DB pattern. The hook only fires when the env var is set and never overrides a real cursor. Test 2 — Unit (component fallback): decodeDropMissingStructure.test.mjs Drives the real StructonPackr encode/decode path (not synthetic errors) to confirm the full classification chain for a genuine missing-structure failure: StructonPackr encode auto-learns struct 0 → decode with empty typedStructs throws "Could not find typed structure 0" → isMissingStructureError → true → classifyReplicationDecodeError → 'skip-missing-structure'. Also pins DECODE_MISSING_STRUCTURE_METRIC = 'decode-missing-structure'. Passes 6/6 (< 5ms). Does NOT cover: full cluster end-to-end (no real replication frames), the readAuditEntry + getValue binary path (direct decoder call only), or actual recordAction metric firing (the branch condition is what's verified). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Test coverage added (and an honest note on the gap)Two tests landed (commit 1.
2. Honest gap: we do not yet have a full end-to-end test that induces a decode-drop during a live cluster copy and asserts the — KrAIs (Claude Opus 4.8), with Kris |
Fixes #537 (root-cause + observability). Supersedes the approach in #538 — see Relationship to #538 below.
Problem
On the replication receive-apply path, record decode was wrapped in a bare
catchthat logged and skipped every decode failure, letting the resume cursor advance past the dropped record: silent (no metric), undiscriminating, and — for an unknown table id — actually a double-throw wedge (the catch re-threw ontableDecoder.decoderand escaped). This is the mechanism behind #537's "laggards plateaued thousands of rows below the source … believing themselves current."Fix — handle each receive-path failure by its true nature
1. Decode failure = permanent → skip + surface. Transient conditions are handled upstream (blob-gap
hold, backpressure), and every known decode source has been root-caused, so one reaching this catch is unrecoverable old-version/corrupt data no re-copy heals (re-fetching re-ships the same undecodable bytes; holding would wedge the leg).classifyReplicationDecodeErrorchooses how loudly to surface the drop:skip-missing-structure(isMissingStructureError): fires the samedecode-missing-structuremetric core's local-read path already uses — alertable instead of silent. Kept concise (no per-record struct-dictionary dump) because Interrupted bulk copies can persist a resume cursor over an undelivered range #537's trigger replays thousands of these.skip: any other (unexpected, post-root-cause) failure — loud error log, decoder dictionaries passed as objects (lazy) not eagerlyJSON.stringify'd per record.Both verdicts skip + advance — correct, because the record is genuinely undeliverable.
2. Unknown table id = transient → hold, don't seal.
tableDecodersis populated only byTABLE_FIXED_STRUCTURE, which the sender always emits before the first record of a table (length-gated send at ~2882) over an ordered WS. So a missing decoder means a missed structure sync (reset/edge case) or un-propagated schema (#1497 family) — recoverable on reconnect, categorically unlike an undecodable record. The old code logged, fell through, and double-threw ontableDecoder.decoderinside the catch → escaped → wedged the leg. Now it holds:close(1011)→ reconnect → resume from the durable cursor (which re-sendsTABLE_FIXED_STRUCTURE), without advancing past the record, so nothing is lost. Also makestableDecoderprovably non-null in the decode catch.Adds
classifyReplicationDecodeError+ unit coverage. harper-pro-only (isMissingStructureErroralready exported from coreRecordEncoder.ts).Relationship to #538
#538 detects a post-copy row-count shortfall and forces a fresh full copy. For this failure class that is counterproductive: the shortfall is permanently undecodable records, so a forced re-copy re-ships the same bytes, re-skips them, and re-fires on the next reconnect — a loop that never converges (and its fuzzy
max(100, 10%)estimate-vs-estimate count excludes TTL tables). This PR fixes the root cause at the layer where it happens. Recommendation: close #538 (or repurpose its count check as observability, not a re-copy trigger). Discussion posted on #538.Reproduction / validation
Ran the harper#1163 divergence cluster harness + probed the decode path:
integrationTests/cluster/typedStructReplicationDivergence.test.mjs→ 4/4 pass; the new unknown-table hold path does not trigger in normal operation.StructonPackr→ absent structure throws ("Could not find typed structure N") →isMissingStructureError→skip-missing-structure. (CoreRecordEncoder's "returns null" is a different call site — #1163 local reads.)TABLE_FIXED_STRUCTUREbefore dependent records, so a throw = structure genuinely absent from the sender's synced binary = corrupt/old-version = unrecoverable.TABLE_FIXED_STRUCTUREseeding ever failed — separate guard.Cross-model review (thorough: Codex + Gemini + Harper-domain adjudication)
tableIddouble-throw and Gemini's per-recordJSON.stringifyhot-path cost: both addressed in this revision (hold at the source; concise/lazy logging).#src/*subpath mapping; 10 existing tests use it).Test
npm run test:unit— classifier suite 5/5; related replication suites green. Divergence integration 4/4. Build emits.KrAIs (Claude Opus 4.8), with Kris
🤖 Generated with Claude Code