Skip to content

[snapshot] Resume a broken chunk stream instead of losing the whole volume - #465

Open
kkozoriz wants to merge 13 commits into
mainfrom
fix/snapshot-download-retry
Open

[snapshot] Resume a broken chunk stream instead of losing the whole volume#465
kkozoriz wants to merge 13 commits into
mainfrom
fix/snapshot-download-retry

Conversation

@kkozoriz

@kkozoriz kkozoriz commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

Problem

A single transient transport failure inside a chunk's Range GET failed the whole block or filesystem volume, even though a byte-exact durable resume checkpoint (fsync'd .part plus offset sidecar) already existed on disk. On the publish path a flaky link therefore threw away an in-progress multi-gigabyte volume transfer.

Stacked on #463.

Fix

  • exporter.IsTransientDataPlaneError - one allow-list deciding what is worth retrying (unexpected EOF, EOF, idle data plane, ECONNRESET/ECONNABORTED/EPIPE/ETIMEDOUT, net.Error timeouts). It fails closed; cancellation is classified first, so a deadline is never mistaken for a retryable error.
  • chunkRetrier re-issues the Range GET from the durable offset the interrupted attempt persisted, with bounded exponential backoff and a no-progress guard. fetchChunkRaw stays a single attempt, so the existing resume contract is untouched.
  • chunkProgressLedger de-duplicates progress credits across attempts, so a retried chunk cannot over-report past the volume's real size.
  • One retrier per volume, shared by every chunk goroutine, with a single aggregate WARN if retries were absorbed. Filesystem volumes download through the same path and get this for free.

Before / After

Probe: an 8 MiB block volume in 1 MiB chunks over 2 workers, against a server that cuts off the first Range GET of every chunk mid-body (unexpected EOF).

BEFORE (f9063c791)

  interruptions : 2
  download      : failed after 67ms
  error         : stream chunk 0 body: unexpected EOF

  VERDICT: LOST - one broken stream discarded the whole volume transfer

AFTER (aa3be4307)

  interruptions : 8
  download      : completed in 4.469s
  reassembled   : sha256 a591762ba183f90b

  VERDICT: RECOVERED - every break resumed from its durable offset, bytes identical

(Source payload sha256 is a591762ba183f90b... in both runs.)

Validation

Beyond the probe: during a real download --publish=true of a 4-volume snapshot from a live cluster, 8 genuine transient interruptions (unexpected EOF) hit the two block volumes' chunked transfer, 4 per volume. Every one recovered on the first retry (attempt=1), the per-volume summary logged retries=4, and the final archive's sha256 matched two independently obtained copies of the same data. Progress reporting across ~458 log lines showed no double-counted bytes.

Tests

  • internal/snapshot/volume/chunk_retry_internal_test.go - retry, backoff, no-progress guard, concurrent retry, context cancellation, attempt-count logging
  • internal/snapshot/volume/block_test.go - retry is per chunk and resumes from the durable offset
  • internal/snapshot/exporter/retry_test.go - transient classification allow-list, including what must stay fatal

… client

Add ValidateHTTPSURL (transport) and NewSafeClientForConfig (safe client)
as the foundational pieces needed to build an HTTPS client against a
published (ingress) DataImport endpoint.

Signed-off-by: Konstantin Kozoriz <konstantin.kozoriz@flant.com>
Add spec.publish to the DataImport built by d8 snapshot upload, align
it alongside spec.ttl on reuse, wait on status.publicURL when publish
is enabled, and switch to a merged TLS trust pool with 401/403
diagnostics on that path.

Signed-off-by: Konstantin Kozoriz <konstantin.kozoriz@flant.com>
Wire the flag through to the DataImport importer, auto-detecting the
upload mode when unset, and document the bearer-token requirement for
the published path.

Signed-off-by: Konstantin Kozoriz <konstantin.kozoriz@flant.com>
SetTLSCAData merged a trust pool but never reset the caller's inherited
insecure-skip-tls-verify/tls-server-name, so Go skipped certificate
verification entirely on that path — any endpoint could receive the
real Kubernetes bearer token. Force verification on unconditionally,
in both the rest.Config and the cloned transport, since client-go may
already have baked the insecure flag into a base transport before this
wrapper runs.

Signed-off-by: Konstantin Kozoriz <konstantin.kozoriz@flant.com>
Add spec.publish to the DataExport built by d8 snapshot download,
upgrade it (one-way, optimistic-locked) on an adopted CR, wait on
status.publicURL when publish is enabled, and switch to a merged TLS
trust pool with 401/403 diagnostics on that path.

Signed-off-by: Konstantin Kozoriz <konstantin.kozoriz@flant.com>
Signed-off-by: Konstantin Kozoriz <konstantin.kozoriz@flant.com>
Signed-off-by: Konstantin Kozoriz <konstantin.kozoriz@flant.com>
…es in one place

Introduce exporter.IsTransientDataPlaneError as the single place that
decides whether a chunk/file transport error is worth retrying. It
fails closed: anything not on the allow-list (io.ErrUnexpectedEOF,
io.EOF, ErrDataPlaneIdle, ECONNRESET/ECONNABORTED/EPIPE/ETIMEDOUT, and
a net.Error reporting Timeout()) is treated as fatal, so a
misclassification costs a loud failure rather than a silent retry loop
masking a real defect.

Cancellation is checked before the net.Error timeout branch:
context.DeadlineExceeded itself satisfies net.Error with Timeout() ==
true, so checking timeouts first would misclassify an intentional
cancellation/deadline as retryable. ErrExportUnauthorized and
ErrContentRangeMismatch are checked next and are never transient: they
describe a request the server actively rejected or a response that
cannot be trusted, not a broken transport worth re-issuing.

syscall.ECONNREFUSED is deliberately excluded: it means the export
never accepted the connection at all, not an abrupted mid-stream
transport. HTTP 5xx statuses are excluded too: RangeGet turns a
non-206 status into an ordinary status error, and none has ever been
observed in production ingress logs.

Signed-off-by: Konstantin Kozoriz <konstantin.kozoriz@flant.com>
A single transient transport failure anywhere inside a chunk's Range GET
used to fail the whole block/file volume, even though an exact
byte-durable resume checkpoint (fsync'd .part + offset sidecar) already
existed on disk. Reuse that same mechanism as the retry unit: on a
transient error, re-issue the Range GET from the durable offset the
interrupted attempt persisted, with bounded exponential backoff, instead
of surfacing the failure to the caller immediately.

chunkRetrier wraps fetchChunkRaw (unchanged: it stays a single attempt,
preserving the resume contract several existing tests pin down) with a
wait.ExponentialBackoffWithContext loop. Cancellation is checked before
error classification so an aborted request never gets mistaken for a
retryable transport error. A no-progress guard stops attempts that keep
advancing zero bytes well before the backoff budget would otherwise be
spent on a link that is never going to deliver.

chunkProgressLedger de-duplicates onProgress credits across attempts: each
fetchChunkRaw attempt re-credits its own resume prefix, so without the
ledger a retried chunk would over-report progress past the volume's true
size.

downloadBlockChunks creates one chunkRetrier per volume, shared by every
chunk goroutine, and logs a single aggregate WARN if any retries were
absorbed. stageChunkedFile downloads every non-empty file through the same
downloadBlockChunks path, so the filesystem volume path gets this fix for
free.

Signed-off-by: Konstantin Kozoriz <konstantin.kozoriz@flant.com>
…ky links

--per-volume-concurrency default stays 4; the help text now hints that
lowering it to 1 helps on a distant/flaky link where long-lived chunk
streams keep breaking, now that a broken stream retries in place rather
than failing the whole volume.

Signed-off-by: Konstantin Kozoriz <konstantin.kozoriz@flant.com>
TestDownloadBlockChunks_RetryIsPerChunk only ever flakes one chunk out of
several clean ones, which never actually exercises concurrent increments
to chunkRetrier.recovered or concurrent chunkProgressLedgers feeding the
shared onProgress sink at the same time.

Add two internal tests that drive several chunks through fetchChunk truly
concurrently (real goroutines, one shared retrier and onProgress sink):
- TestChunkRetrier_ConcurrentChunksIndependentRecoveredCount flakes every
  chunk exactly once at the same time and asserts recovered lands on the
  exact expected count, part-file contents are not cross-chunk corrupted,
  and the shared progress sink sums to exactly the total raw bytes with no
  loss or double-count.
- TestChunkRetrier_ConcurrentContextCancelStopsAllRetries cancels a
  context shared by several chunks that are all mid-backoff at once and
  asserts every goroutine stops promptly instead of riding out its sleep.

Both pass under -race -count=5.

Signed-off-by: Konstantin Kozoriz <konstantin.kozoriz@flant.com>
…unk retry logging

wait.Backoff.Cap forces chunkRetrier.fetchChunk's retry loop to stop one or
more attempts short of the policy's declared Steps budget, but the code
compared against Steps in two places: the WARN guard (which never actually
suppressed the last attempt's log line once Cap cut the loop short) and the
exhausted-budget error message (which always reported the declared budget,
never the real attempt count). Remove the guard entirely — the closure has
no way to observe Cap's early cutoff since wait.ExponentialBackoffWithContext
mutates its own copy of backoff — and report the real attempt count in the
error message instead. Also unwrap the %w chain before taking %T in the
non-retryable-error diagnostic, since fetchChunkRaw always wraps its errors
and the previous code only ever logged *fmt.wrapError.

Signed-off-by: Konstantin Kozoriz <konstantin.kozoriz@flant.com>
…t, not wall clock

TestChunkRetrier_ConcurrentContextCancelStopsAllRetries and
TestChunkRetrier_ContextCancelStopsRetryImmediately raced a
time.AfterFunc-scheduled cancel() against goroutines that had not
necessarily issued their first HTTP request yet, so the retry loop's very
first backoff iteration could already observe a cancelled ctx and make zero
requests instead of the expected one per chunk (~60% flake rate on the
concurrent test under -race -count=5). Both tests now signal over a channel
once each doer has actually seen its first request, and cancel only after
every goroutine has reached that point; elapsed-time measurement starts
right before cancel() so it actually reflects time-to-stop.

Also add table-driven coverage for rootCause (nil, unwrapped, single- and
double-wrapped errors), note its known limitation on multi-wrap
(Unwrap() []error) chains, and reword the per-attempt WARN log to state a
plain fact ("chunk transfer interrupted by...") instead of "retrying",
since it fires on the terminal attempt too, where no retry follows.

Signed-off-by: Konstantin Kozoriz <konstantin.kozoriz@flant.com>
@kkozoriz kkozoriz changed the title [snapshot] snapshot download retry [snapshot] Resume a broken chunk stream instead of losing the whole volume Aug 30, 2026
@kkozoriz
kkozoriz marked this pull request as ready for review August 31, 2026 18:11
@kkozoriz
kkozoriz requested a review from ldmonster as a code owner August 31, 2026 18:11
kkozoriz added a commit that referenced this pull request Aug 31, 2026
…463

The team merges with "Squash and merge", which drops the #462 -> #463 -> #464
ancestry. Git then falls back to origin/main as the merge base and sees #462's
changes -- already present here -- as competing additions, producing two
conflicts that a plain merge chain never hits:

  CONFLICT (content): internal/snapshot/transport/http.go
  CONFLICT (add/add): pkg/libsaferequest/client/http_test.go

Both were purely structural, so reshape them instead of changing behaviour:

  - http_test.go is created by #462 too, so an add/add conflicts unless both
    sides match byte for byte. Restore it to #462's exact content and move the
    7 SetTLSCAData tests and their 3 helpers to a new http_tls_test.go, which
    exists on neither side of main and merges as a clean single-side add.

  - The "keep in sync" note sat at the end of a doc comment block #462 had just
    added, so the two additions were adjacent and conflicted. Move it just
    inside SetTLSCAData, a region #462 does not touch.

No production code changes: pkg/libsaferequest/client/http.go is untouched and
internal/snapshot/transport/http.go now matches #463 apart from the relocated
comment. All 8 tests are preserved and still pass under -race.

Verified from origin/main: squash #462, squash #463, merge #464, merge #465 --
all four clean, then build, vet and go test -race clean over
./internal/snapshot/... and ./pkg/libsaferequest/...

Signed-off-by: Konstantin Kozoriz <konstantin.kozoriz@flant.com>
@kkozoriz kkozoriz self-assigned this Sep 1, 2026
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.

1 participant