fix: surface transfer errors when caching blobs (COMP-2142) - #1100
Open
stefanoboriero wants to merge 1 commit into
Open
fix: surface transfer errors when caching blobs (COMP-2142)#1100stefanoboriero wants to merge 1 commit into
stefanoboriero wants to merge 1 commit into
Conversation
A blob transfer job could report success while uploading a 0-byte object
to the blob cache, producing a cache entry marked COMPLETED that holds no
bytes. The failure only surfaced later, client-side, as a digest mismatch
on pull - and because the bad entry is cached, it is sticky: every
subsequent pull of that layer failed the same way.
The download is not performed by Wave but by a separate Kubernetes Job
with its own pod, DNS config and node selector. The upstream registry is
therefore contacted twice, by two processes in two network contexts: once
by Wave's own probe, once by the transfer pod. Those two requests can
always disagree - a DNS blip, a transient partition, an auth token
expiring in between, a registry 5xx on the second request - and nothing
on the return path checked that the bytes actually arrived.
Four layers each independently swallowed the failure; close all four:
1. ProxyClient.curl() emitted only '-s -X GET', so an HTTP error was
written to stdout with a zero exit status. Add '-f' so HTTP errors
become non-zero exits, plus '--retry' and '--connect-timeout' (sourced
from the existing HttpClientConfig) so transient failures are retried.
Note '--connect-timeout' bounds connection setup only: an upstream
that connects and then stalls mid-body is still capped by the job
timeout, not by this option. '--retry-all-errors' is deliberately
omitted - it would retry a response whose body was already partially
piped to s5cmd, duplicating those bytes in the uploaded object.
'-L' is likewise not added: a redirect is answered by Wave's own probe
in RegistryProxyController.handleDelegate0() before the blob cache
branch is reached, so following redirects in the transfer job does not
fix this bug and would change behaviour for registries that
legitimately redirect.
2. transferCommand() built 'sh -c "curl ... | s5cmd ..."'. A shell
pipeline exits with the status of its last command, so curl's failure
was discarded and s5cmd's zero won. Add 'set -o pipefail', and run it
under 'bash' rather than 'sh': in the s5cmd image
(public.cr.seqera.io/wave/s5cmd:v2.3.0, Ubuntu 24.04) /bin/sh is dash,
which does not implement pipefail and treats the unknown option as a
fatal script error, so the pipeline would never run at all:
$ docker run --rm --entrypoint sh .../s5cmd:v2.3.0 \
-c 'set -o pipefail; echo DATA | cat'
sh: 1: set: Illegal option -o pipefail
exit=2
Under bash the success path exits 0 and passes the data through, and
an HTTP error propagates (exit 22). bash is present at /usr/bin/bash
in that image; note this couples the command to an image providing it,
while wave.blob-cache.s5cmd-image remains configurable.
3. onJobCompletion() trusted state.succeeded(), i.e. exitCode == 0, and
nothing else. Validate the uploaded object before marking the entry
COMPLETED: compare its size against the upstream Content-Length
already captured in BlobEntry.contentLength, and mark the entry
ERRORED on mismatch. This catches empty and truncated transfers
regardless of cause, including causes not yet identified. When the
upstream omits Content-Length the comparison is not possible, so the
check falls back to rejecting only empty objects.
4. Marking the entry ERRORED still left the invalid object in the bucket,
so the next request found it via blobExists() and served it as a valid
CACHED entry - the bad transfer stayed sticky through a different
path. Delete the object on any error path, not just on validation
failure, since a failing pipeline can also leave a truncated object
behind. S3 DeleteObject is idempotent and returns 204 for a missing
key, so the common "nothing was uploaded" case is a silent no-op; the
catch only trips on real problems (missing bucket, credentials,
network), which are logged and swallowed as the entry is errored
regardless.
blobExists() is refactored into a thin wrapper over a new blobSize(),
which returns the stored object length or null. It also catches generic
exceptions, so a failure inside the new validation degrades to "treat as
not uploaded" rather than propagating out of job completion.
Note this prevents new 0-byte entries and cleans up the ones this code
path creates, but does not remediate objects already in the bucket:
blobExists() still reports true for an already-cached empty object, so
buckets already affected need those objects deleted.
Co-Authored-By: Claude Opus 5 (1M context) <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.
A blob transfer job could report success while uploading a 0-byte object to the blob cache, producing a cache entry marked COMPLETED that holds no bytes. The failure only surfaced later, client-side, as a digest mismatch on pull - and because the bad entry is cached, it is sticky: every subsequent pull of that layer failed the same way.
The download is not performed by Wave but by a separate Kubernetes Job with its own pod, DNS config and node selector. The upstream registry is therefore contacted twice, by two processes in two network contexts: once by Wave's own probe, once by the transfer pod. Those two requests can always disagree - a DNS blip, a transient partition, an auth token expiring in between, a registry 5xx on the second request - and nothing on the return path checked that the bytes actually arrived.
Four layers each independently swallowed the failure; close all four:
ProxyClient.curl() emitted only '-s -X GET', so an HTTP error was written to stdout with a zero exit status. Add '-f' so HTTP errors become non-zero exits, plus '--retry' and '--connect-timeout' (sourced from the existing HttpClientConfig) so transient failures are retried. Note '--connect-timeout' bounds connection setup only: an upstream that connects and then stalls mid-body is still capped by the job timeout, not by this option. '--retry-all-errors' is deliberately omitted - it would retry a response whose body was already partially piped to s5cmd, duplicating those bytes in the uploaded object. '-L' is likewise not added: a redirect is answered by Wave's own probe in RegistryProxyController.handleDelegate0() before the blob cache branch is reached, so following redirects in the transfer job does not fix this bug and would change behaviour for registries that legitimately redirect.
transferCommand() built 'sh -c "curl ... | s5cmd ..."'. A shell pipeline exits with the status of its last command, so curl's failure was discarded and s5cmd's zero won. Add 'set -o pipefail', and run it under 'bash' rather than 'sh': in the s5cmd image (public.cr.seqera.io/wave/s5cmd:v2.3.0, Ubuntu 24.04) /bin/sh is dash, which does not implement pipefail and treats the unknown option as a fatal script error, so the pipeline would never run at all:
$ docker run --rm --entrypoint sh .../s5cmd:v2.3.0
-c 'set -o pipefail; echo DATA | cat'
sh: 1: set: Illegal option -o pipefail
exit=2
Under bash the success path exits 0 and passes the data through, and an HTTP error propagates (exit 22). bash is present at /usr/bin/bash in that image; note this couples the command to an image providing it, while wave.blob-cache.s5cmd-image remains configurable.
onJobCompletion() trusted state.succeeded(), i.e. exitCode == 0, and nothing else. Validate the uploaded object before marking the entry COMPLETED: compare its size against the upstream Content-Length already captured in BlobEntry.contentLength, and mark the entry ERRORED on mismatch. This catches empty and truncated transfers regardless of cause, including causes not yet identified. When the upstream omits Content-Length the comparison is not possible, so the check falls back to rejecting only empty objects.
Marking the entry ERRORED still left the invalid object in the bucket, so the next request found it via blobExists() and served it as a valid CACHED entry - the bad transfer stayed sticky through a different path. Delete the object on any error path, not just on validation failure, since a failing pipeline can also leave a truncated object behind. S3 DeleteObject is idempotent and returns 204 for a missing key, so the common "nothing was uploaded" case is a silent no-op; the catch only trips on real problems (missing bucket, credentials, network), which are logged and swallowed as the entry is errored regardless.
blobExists() is refactored into a thin wrapper over a new blobSize(), which returns the stored object length or null. It also catches generic exceptions, so a failure inside the new validation degrades to "treat as not uploaded" rather than propagating out of job completion.
Note this prevents new 0-byte entries and cleans up the ones this code path creates, but does not remediate objects already in the bucket: blobExists() still reports true for an already-cached empty object, so buckets already affected need those objects deleted.