Repository navigation
fix(replication): classify gone/corrupt source blobs as permanent via forwarded statusCode (#429) - #443
Conversation
… forwarded statusCode (#429) A confidently incomplete/truncated source blob ("Blob is incomplete") was misclassified as a transient failure on the receiver: core threw a plain Error with no `.code`, sendBlobs forwarded `errorCode: undefined`, and isPermanentSourceBlobErrorCode(undefined) → false → hasBlobGap pinned the resume cursor forever, since every reconnect reproduces the identical error. Same failure class as #403, for *incomplete* blobs instead of *missing* ones (#429). Core PR harper#1425 reworks the blob read paths to throw BlobReadError carrying an HTTP-style statusCode (404 gone / 500 corrupt-incomplete / 503 transient) and no raw fs `.code`. This: - forwards `errorStatus` (the BlobReadError statusCode) alongside `errorCode` from sendBlobs, and - classifies 404 and 500 as PERMANENT (advance the resume cursor past + mark sourceBlobUnavailable → #418/#388 backfill + #386 divergence metric), while 503 stays transient (hold the gap, retry on reconnect). Besides resolving #429, the `errorStatus === 404` arm preserves the already- shipped #405 ENOENT advance-past: once harper#1425 lands, core wraps even ENOENT into a code-less BlobReadError(404), so without forwarding/honoring the status a missing source blob would start wedging again. The `errorCode === 'ENOENT'` arm remains for pre-#1425 senders. A sender that forwards neither stays on the safe hold default (mixed-version clusters). Must ship together with harper#1425. Unit coverage added for the new statusCode arm in blobReplicationFailure.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 updates the replication logic to classify permanent source blob failures using HTTP-style status codes (such as 404 and 500) in addition to file system error codes like 'ENOENT'. This ensures compatibility with newer senders that wrap read errors. Feedback on this change highlights a risk in treating generic 500 status codes as permanent failures, as transient internal errors could be misclassified, potentially leading to skipped blobs and data loss. It is recommended to use a more specific status code, such as 422, to represent confidently corrupt or incomplete blobs.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| export function isPermanentSourceBlobErrorCode(errorCode: unknown, errorStatus?: unknown): boolean { | ||
| return errorCode === 'ENOENT' || errorStatus === 404 || errorStatus === 500; | ||
| } |
There was a problem hiding this comment.
Using 500 (Internal Server Error) to classify a permanent failure is risky because 500 is typically the generic fallback status code for any unexpected internal error or unhandled exception. If a transient error (such as a temporary disk I/O glitch, database lock, or out-of-memory condition) occurs on the sender and is mapped to a generic 500 status code, the receiver will misclassify it as a permanent failure, skip the blob, and advance the resume cursor. This could lead to unintended data divergence or silent data loss.
Consider using a more specific status code (e.g., 422 Unprocessable Entity or a custom status/error code) to represent 'confidently corrupt/incomplete' to avoid conflating it with generic internal server errors. This would require coordination with the core PR harper#1425.
| export function isPermanentSourceBlobErrorCode(errorCode: unknown, errorStatus?: unknown): boolean { | |
| return errorCode === 'ENOENT' || errorStatus === 404 || errorStatus === 500; | |
| } | |
| export function isPermanentSourceBlobErrorCode(errorCode: unknown, errorStatus?: unknown): boolean { | |
| return errorCode === 'ENOENT' || errorStatus === 404 || errorStatus === 422; | |
| } |
References
- When handling transient stream or save failures in a replication system, avoid immediately terminating the connection to force a reconnect if the reconnect will simply re-stream the same failing data and re-trigger the failure. Instead, handle the gap with a bounded tradeoff (such as clamping the resume cursor) until a natural reconnect or restart.
|
Reviewed; no blockers found. |
…urce-blob advance-past
Sibling to replicationBlobSourceUnavailable.test.mjs (the ENOENT/404 path); this
covers the TRUNCATED/incomplete ("Blob is incomplete", 500) source blob, the case
#429 wedged on.
- fixture-blob-truncate-source: on the SOURCE, truncates a deterministic subset of
blob files (by fileId % MODULUS) to KEEP_BYTES on the write stream's `close`, so
the header still records the full size but the body is short. A later
replication read hits core's BlobReadError('Blob is incomplete', 500) (harper#1425).
- replicationBlobIncompleteSource.test.mjs: asserts the receiver advances the resume
cursor past the incomplete blob ("advancing the resume cursor past it") and that the
driving error was the incomplete/500 arm (not ENOENT), proving this classifier path.
Stress-gated (skip unless HARPER_RUN_STRESS_TESTS=1), like its siblings. Requires a
`core` submodule that includes harper#1425's BlobReadError statusCode taxonomy; until
that syncs to harper-pro's pinned core, the stress run is a no-op skip. Validated
locally only up to fixture load — full 2-node end-to-end is blocked in-sandbox by the
same replication-server bind limitation that blocks the existing sibling test; to be
exercised by stress CI once core syncs #1425.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Added the end-to-end integration coverage ( Validation boundary (please read): I built this branch against the harper#1425 core (pointed the worktree's Unit layers are green locally on both sides (core blob suite 37 passing incl. the 500/404 cases; this PR's |
startHarper returns before the replication worker has bound its secure port (observed ~empirically: the listener appears a beat after 'successfully started'), so firing add_node immediately races into ECONNREFUSED. Poll the port until it accepts (30s budget) before connecting the cluster. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Fixes #429. Pairs with harper#1425 — must ship together (see Coordination below).
Problem (#429)
A confidently incomplete/truncated source blob (
Blob is incomplete) was misclassified as a transient failure on the receiver, permanently wedging the replication resume cursor:Core threw a plain
Errorwith no.code, sosendBlobsforwardederrorCode: undefined, andisPermanentSourceBlobErrorCode(undefined)→false→hasBlobGapheld the cursor, expecting a reconnect to re-stream successfully. But a truncated stub at the source never becomes complete, so every reconnect reproduces the identical error and the cursor for that DB is pinned forever. Same failure class as #403, for incomplete blobs instead of missing ones.Fix
harper#1425 reworks core's blob read paths to throw a
BlobReadErrorcarrying an HTTP-stylestatusCode(404 gone / 500 confidently corrupt-incomplete / 503 transient) instead of a bareError. This PR is the harper-pro half:sendBlobsnow forwardserrorStatus(theBlobReadError.statusCode) alongsideerrorCode.isPermanentSourceBlobErrorCode(errorCode, errorStatus)classifies 404 and 500 as permanent → advance the resume cursor past +markSourceBlobUnavailable(→ feat(replication): proactive blob repair sweep for already-committed missing blobs #418/Proactive blob backfill: repair already-committed records whose blobs are missing/corrupt (no recovery once the resume cursor advances past them) #388 backfill + Sustained blob-replication timeouts cause silent, unrecoverable blob loss with no operator signal or self-repair ([customer-cluster]: 344/345 attachments, ~663 MB) #386 divergence metric), while 503 stays transient → hold the gap, retry on reconnect.Everything downstream already existed (
markSourceBlobUnavailable→isUnrecoverableSourceBlobError→ advance + record divergence); this just routes the new error shape into it, exactly as #429 proposed — except keyed onstatusCoderather than the ad-hocERR_BLOB_INCOMPLETE.codethe issue anticipated, since #1425 supplies the richer taxonomy.Why it must pair with harper#1425 (regression guard, not just #429)
Once #1425 lands, core wraps even ENOENT into a code-less
BlobReadError(404). Without this PR forwarding/honoringerrorStatus, a missing source blob would lose itserrorCode: 'ENOENT'signal and the receiver would start holding on it — regressing the already-shipped #405 advance-past (observed live on JJill: "unrecoverable at source … advancing" 1,549×). TheerrorStatus === 404arm preserves it. harper#1425 also setscode: 'ENOENT'on the 404 so an older receiver (pre-this-PR) stays correct too — mixed-version safe in both directions.Compatibility
errorCode: 'ENOENT'(handled);errorStatusisundefined(ignored).Testing
Extended
unitTests/replication/blobReplicationFailure.test.mjsfor the newerrorStatusarm (404/500 permanent, 503 transient, either-signal-suffices, unrelated statuses ignored) — 24 passing.Integration note: the existing cluster fixture only models the ENOENT/404 path. End-to-end coverage of the 500 (#429) path needs core #1425 merged into the
coresubmodule first; left as a noted follow-up (fixture-blob-fail-source-truncate) rather than a half-test.🤖 Generated with Claude Code