Repository navigation
fix(deploy): retry peer-side payload_blob read on transient 503 stall - #1838
Merged
Merged
Conversation
The peer branch of deploy_component's replicated payload read gave the blob content stream a single 20s no-progress window (Blob.stream()'s blobReadTimeout) before failing the whole deploy. That 503 BlobReadError is documented as retryable (resources/blob.ts) and is usually self-healing — a parked/declined blob send on the origin, or event-loop starvation during install — but nothing on the deploy path retried it. readPayloadBlobWithRetry (components/deploymentRecorder.ts) retries a fresh stream() on the retryable 503 with backoff, bounded by the same timeoutMs budget already used to wait for the hdb_deployment row. It only retries while nothing has reached the consumer yet (a stall after partial content fails immediately, avoiding duplicate bytes), and streams chunks through via an async generator + Readable.from() rather than buffering — preserving backpressure for large payloads. 500 corrupt/404 gone errors still fail immediately, matching prior behavior. Root-caused from a harper-pro incident (2026-07-16): content bytes didn't arrive for 60+s despite a live replication connection, the peer's one-shot window failed the deploy, and the content landed seconds later — a manual redeploy then succeeded. Refs #641 Co-Authored-By: Claude Opus <noreply@anthropic.com>
Contributor
|
Reviewed; no blockers found. Remaining suggestion (credentials timeout coercion duplication) has been addressed. |
Contributor
There was a problem hiding this comment.
Code Review
This pull request introduces a robust retry mechanism for reading replicated deployment payloads by implementing readPayloadBlobWithRetry. This helper function handles transient 503 errors (blob stalls) by retrying the stream read with exponential backoff, while ensuring that backpressure is correctly maintained and that retries are safely bounded to prevent data duplication or unnecessary resource consumption. Comprehensive unit tests have been added to verify the retry logic, cancellation behavior, and streaming performance. I have no feedback to provide as there were no review comments.
Co-Authored-By: Codex <noreply@openai.com>
kriszyp
marked this pull request as ready for review
July 17, 2026 00:41
cb1kenobi
reviewed
Jul 17, 2026
Co-Authored-By: Codex <noreply@openai.com>
cb1kenobi
approved these changes
Jul 22, 2026
dawsontoth
added a commit
that referenced
this pull request
Jul 22, 2026
Conflicts in components/operations.js (main's deploy-blob-read-retry work, #1838 / e02efb4 / 11cff13 / f296376): - Import union: keep markDeploymentTerminal (ours) alongside main's new readPayloadBlobWithRetry + coerceTimeoutMs from deploymentRecorder. - deployComponentOneShot payload/credential sourcing: main hardened the inline block with readPayloadBlobWithRetry (retry peer blob read on a transient 503 stall) and coerceTimeoutMs (dedup'd timeout coercion for both the payload-row wait and the credential grace period). This branch had already refactored that block into the sourceExtractionPayload / resolveNodeCredentials helpers, from a pre-fix version. Resolved by keeping the helpers and porting both fixes into them — which also extends the blob-read retry to the two-phase origin AND peer-stage paths (both route through sourceExtractionPayload), not just the one-shot path. deploymentRecorder.test.js (main's retry tests) + full deploy suite: 114 passing. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
The peer branch of
deploy_component's replicated payload read (components/operations.js) gave the blob content stream a single 20s no-progress window (Blob.stream()'sblobReadTimeout) before failing the whole deploy. That 503BlobReadErroris documented as retryable (resources/blob.ts) and is usually self-healing, but nothing on the deploy path retried it.readPayloadBlobWithRetry(components/deploymentRecorder.ts) retries a freshstream()on the retryable 503 with backoff, bounded by the sametimeoutMsbudget already used to wait for thehdb_deploymentrow (one shared deadline covers both phases, so a slow row doesn't double the peer's worst-case wait). It only retries while nothing has reached the consumer yet — a stall after partial content fails immediately, avoiding duplicate bytes on a naive restart — and streams chunks through via an async generator +Readable.from()rather than buffering, preserving backpressure for large payloads. 500 corrupt / 404 gone errors still fail immediately, unchanged from before.Purpose
Root-caused from a harper-pro incident (2026-07-16): an origin replicated the
hdb_deploymentrow (peer wrote the blob header), but content bytes didn't arrive for 60+ seconds despite a live replication connection and no shutdown drain — the peer's one-shot 20s window failed the deploy, and the content landed seconds later (a manual redeploy 17 minutes later succeeded). This converts that failure class to success regardless of the upstream cause (suspected per-connection blob-send backpressure or event-loop starvation on a single-worker origin).This stays on
main/5.2 for now — no v5.1 cherry-pick yet (per Kris): the next 5.1.x patch carries only the diagnostic visibility change (HarperFast/harper-pro#591) to root-cause the send starvation with minimal added complexity; this fix's 5.1 backport gets revisited after that diagnosis (removal follow-up: HarperFast/harper-pro#592).Where to look
components/deploymentRecorder.ts:readPayloadBlobWithRetry/readPayloadBlobChunks— the retry+backpressure+cancellation design. Worth reading the JSDoc; it documents two non-obvious things a first-pass implementation got wrong (caught during review, see below) before landing on the current shape.components/operations.js(~line 465): the peer branch call site — shared deadline math.resources/blob.ts: one-line change, exporting the existingBLOB_UNAVAILABLE_STATUSconstant so the retry helper doesn't duplicate the magic number.Review notes for the human reviewer
This went through a cross-model review (Codex + Gemini + a Harper-domain pass) across two iterations. Two real issues were caught and fixed in the version you're looking at now, noted here since they reflect real design tradeoffs rather than things left open:
Buffer.concat) — flagged as a regression for this repo's supported multi-GB deploy path. Fixed by switching to a pull-basedasync function*+Readable.from(), which also turned out to matter for a second, subtler reason: a naiveReadableStreamwrapper that doesfor await (...) controller.enqueue(chunk)ignoresdesiredSizeand eagerly drains the source regardless of consumer demand, silently reintroducing the same unbounded-memory problem even without an explicit buffer. Covered by a unit test that asserts the source isn't drained ahead of consumer demand.Readable.from()'s cancellation (return()ondestroy()) only takes effect at the generator's nextyield— which never happens while the loop is stuck retrying. Verified empirically thatdestroy()was a no-op until the retry loop's own deadline expired. Fixed by threading the constructedReadableback into the generator (via a mutable cell, since it doesn't exist yet at generator-creation time) so it can check.destroyedexplicitly each iteration.One thing left deliberately out of scope: neither this fix nor the pre-existing code cross-validates the read bytes against
row.payload_hash/payload_size(the row already carries a SHA-256 fromingestPayload). Confirmed viagit show HEAD~1that this integrity gap predates this diff on both the origin and peer paths — raising it here as a known gap, not something this PR should absorb given the intentionally narrow, cherry-pickable scope.Tests
unitTests/components/deploymentRecorder.test.js— 10 new cases forreadPayloadBlobWithRetry: 503-then-success, 503-forever-fails-after-budget, 500-fails-immediately, no-statusCode-fails-immediately, no-retry-after-partial-forward, zero-timeout, backpressure (pull-based, doesn't drain ahead of demand), and bounded retries afterdestroy().deploy-*integration suite (tracking, peer-branch, multipart-stream, payload-reclaim) plustest:unit:main/test:unit:resources— all green aside from pre-existing, unrelated flakes (globalIsolation.test.jssandbox-path issue specific to this worktree'snode_moduleslayout, and one order-dependenttokenAuthentication.test.jsfailure that passes cleanly in isolation).Generated by Claude Opus (dev-agent), via Kris's dispatch queue.
🤖 Generated with Claude Code