mirror of
https://github.com/immich-app/yucca.git
synced 2026-09-30 13:33:00 +08:00
fix(michael): converge retried WORM puts when the existing blob matches (#526)
This commit is contained in:
@@ -0,0 +1,77 @@
|
||||
# Restic client retry behavior
|
||||
|
||||
What the restic client does when michael returns an error. Michael's own
|
||||
resilience mechanisms (the S3 backend pool, its retries and circuit breakers)
|
||||
are designed around this contract, so status-code decisions in
|
||||
`packages/michael` should be checked against this document — and this document
|
||||
against restic's source when it moves.
|
||||
|
||||
Sourced from restic `internal/backend/retry/backend_retry.go` and
|
||||
`internal/backend/rest/rest.go` as of August 2026 (restic ≥ 0.17 semantics; the
|
||||
`backend-error-redesign` feature flag is Beta and therefore **on by default**).
|
||||
|
||||
## The retry envelope
|
||||
|
||||
Every backend operation (Load, Save, Stat, Remove, List) is wrapped in a retry
|
||||
layer: exponential backoff starting at 1s, doubling per attempt, capped at 60s
|
||||
per interval, retrying until **15 minutes** have elapsed (hardcoded). There is
|
||||
no attempt cap. A cancelled context aborts immediately.
|
||||
|
||||
Consequences for michael:
|
||||
|
||||
- An outage shorter than ~15 minutes is invisible to users **if** michael fails
|
||||
fast: the operation succeeds on a later retry and the backup completes.
|
||||
- Every response that hangs (e.g. waiting on a dead gateway's dial timeout)
|
||||
burns the client's fixed 15-minute window without advancing it.
|
||||
|
||||
## Permanent vs retryable status codes
|
||||
|
||||
`IsPermanentError` for the REST backend: **404, 401, 403, 416, 507** abort the
|
||||
operation immediately. Everything else — 500, 502, **503**, timeouts, resets,
|
||||
any transport error — is retried for the full window.
|
||||
|
||||
| michael returns | restic does |
|
||||
|---|---|
|
||||
| 500 / 503 | retries with backoff, up to 15 min |
|
||||
| 403 (WORM violation, "already exists") | fails the operation permanently |
|
||||
| 404 on HEAD | treats as "does not exist" (permanent, but often the expected answer) |
|
||||
| 400 | retried (not in the permanent set) — avoid for terminal conditions |
|
||||
|
||||
Restic does **not** read `Retry-After`; its backoff is fixed client-side. The
|
||||
header is still worth sending for other clients, but it cannot pace restic.
|
||||
|
||||
## The per-file Load breaker
|
||||
|
||||
Restic keeps its own circuit breaker per file: a `Load` that exhausts retries
|
||||
on a *non-permanent* error marks that file failed for **one hour**, and every
|
||||
further read of it fails instantly. A transient gateway blip surfaced as a 500
|
||||
can therefore lock a client out of a pack file for an hour mid-restore — which
|
||||
is why michael masks single-gateway failures instead of forwarding them.
|
||||
|
||||
## Save failure recovery, and the WORM trap
|
||||
|
||||
On a failed `Save`, restic issues a cleanup `Remove` for the possibly-partial
|
||||
upload (errors ignored — rest-server reports `HasAtomicReplace=false`), rewinds
|
||||
its pack (restic's own body IS seekable) and re-POSTs.
|
||||
|
||||
On a WORM repository this recovery used to be a trap. If the failure was
|
||||
*ambiguous* — michael reported an error but the gateway committed the object:
|
||||
|
||||
1. the cleanup `Remove` is refused (WORM forbids deleting data blobs);
|
||||
2. the re-POST hits the `If-None-Match: *` precondition → 412;
|
||||
3. michael surfaced that as 403 "Blob already exists";
|
||||
4. 403 is permanent → the backup failed hard, with the blob stored correctly.
|
||||
|
||||
Michael therefore converges this case: a write-once PUT that fails its
|
||||
precondition, for a content-addressed key, where the existing object's size
|
||||
matches the incoming one, returns 200 (see `S3Storage.PutObject`). Blob keys
|
||||
are the content's sha256 and michael verified that hash when the object was
|
||||
first written, so the size check identifies the same blob without re-reading
|
||||
it. The config object's key is not content-addressed and is excluded.
|
||||
|
||||
## Save bodies vs michael's PUT path
|
||||
|
||||
Restic retries `Save` with a rewindable body — but the stream michael proxies
|
||||
to S3 is **not** seekable, which is why the aws-sdk retryer is disabled on
|
||||
michael's PUT path and michael never retries an upload itself: any transient
|
||||
failure surfaces once, cleanly, and restic re-drives the upload end-to-end.
|
||||
Reference in New Issue
Block a user