Repository navigation
blob-client: a rate-limited PUT backs off for seconds, not 300 ms - #1784
Merged
Merged
Conversation
GCS answers 429 when a bucket's write rate grows faster than the
service spreads its load ("Your request distribution is too uneven
across the key-ranges in your bucket"), and 503 when a backend is busy.
S3 answers 503 SlowDown. Each backend folded these into BlobError::Sdk,
so the blob client could not tell "slow down" from a broken connection
and gave both the same 300 ms retry schedule.
- engram-core: new variant BlobError::Throttled(BoxError). The
BlobStorage contract names the mapping.
- engram-storage-gcs: HTTP 429 and 503 map to Throttled. The 404 check
now reads the status through the existing http_status helper, so
both SDK error shapes go through one status read.
- engram-storage-s3: sdk_err maps a 429 or 503 response to Throttled.
- engram-blob-client: Throttled is transient, so every op retries it
on the schedule it had before. No behaviour change in this commit.
- engram-chunk-store: Throttled counts as overload for the adaptive
admission limit, and ChunkStoreError::duplicate copies it.
- engram-testkit: InjectedError::Throttled.
Review pass: no stale comments found in the touched mappers; the GCS
404 check and http_status were two reads of the same status, now one.
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HNhpmz4QPYaVCE7tFna54D
A checkpoint uploads its dirty chunks in a burst. When GCS throttles the burst with 429, the client retried after 100 ms and 200 ms and gave up after about 300 ms in all. If that happens in a diff capture's sparse re-chunk, the capture fails after Firecracker consumed the dirty bitmap, the host drops the checkpoint chain, and the next checkpoint is a full snapshot. No data is lost, but one checkpoint is, and the full snapshot adds more upload load to a bucket that is already throttling. A PUT that fails with BlobError::Throttled now uses its own schedule, ClientPolicy::put_throttled (ThrottleBackoff): - 6 tries in all, 1 s base, doubling, each step capped at 16 s. - The delay before retry n is in [c/2, c), where c = min(1 s * 2^(n-1), 16 s). So the retries wait 15.5 s to 31 s in all before the PUT gives up with the store's error. Why these values: - Google's retry strategy for Cloud Storage says to retry 408, 429 and 5xx with exponential backoff and jitter. Its client libraries start at 1 s with multiplier 2, and cap one step at 30 s or more (Java: 32 s cap, 50 s total). https://cloud.google.com/storage/docs/retry-strategy - Cloud Storage adds capacity as the request rate grows, and that "can take minutes"; a 100 ms retry lands inside the same window. https://cloud.google.com/storage/docs/request-rate - The total stays near half a minute, not the libraries' minutes: a periodic checkpoint holds its sandbox's capture lock while it uploads, so an eviction or a snapshot of that sandbox waits behind it. A throttle that lasts longer than that is a real failure and surfaces as before. - Each delay is at least half its ceiling, so the store always gets a pause; the other half is jitter, so the up to 64 concurrent chunk PUTs of one upload do not retry at the same instant. The jitter is a SplitMix64 hash of a per-client call number and the retry number, not a random draw. The crate had no randomness and documented deterministic backoff; this keeps a run replayable while concurrent calls still spread. Reads do not change. GET, ranged GET, HEAD, DELETE and LIST keep the 100 ms / 200 ms schedule for every transient error, a throttle included: a guest can be waiting on a demand GET. Other transient errors on PUT (a reset connection, a deadline) also keep that schedule. Tests (paused tokio time, no real sleeps, a fake store that answers Throttled N times): - three throttles, then the PUT lands after 3.5 s to 7 s; - a throttle that never clears gives up after 6 tries and 15.5 s to 31 s, with the Throttled error; - a throttled GET still gives up after 3 tries and exactly 300 ms; - each delay is in [c/2, c), delays spread across calls, and the same input gives the same delay. Review pass: the retry loop was a match with a guard that computed the schedule; it now computes the delay once (retry_delay) and the loop has one exit for a final error. The policy doc no longer claims that herd effects do not matter. Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HNhpmz4QPYaVCE7tFna54D
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.
Problem
A periodic checkpoint uploads its dirty chunks to GCS in a burst (up to 64 chunk PUTs in flight). GCS sometimes refuses a write with HTTP 429: "Your request distribution is too uneven across the key-ranges in your bucket". This is a rate limit: the bucket's write rate grew faster than GCS spreads the load. The blob client classed the 429 as a generic SDK error and retried it after 100 ms and 200 ms. It gave up after about 300 ms in all.
When the give-up happens in a diff capture's sparse re-chunk, Firecracker has already consumed the dirty bitmap. The host-agent drops the checkpoint chain (
engram_checkpoint_chain_poisoned_total, which an alert watches), and the next checkpoint is a full snapshot. No data is lost, but one checkpoint is lost, and the full snapshot adds upload load to a bucket that is already throttling.In production logs, one host retried several hundred PUTs in half a day. About one in six first retries failed again, and the host gave up a few times and poisoned its chain twice. The errors came in bursts during large uploads.
Change
1.
core, storage: a refusal for load is its own error. New variantBlobError::Throttled(BoxError). GCS maps HTTP 429 and 503 to it, and S3 maps 429 and 503 (SlowDown). The blob client treats it as transient, and the chunk store's adaptive admission counts it as overload. This commit does not change behaviour: every op retries a throttle on the schedule it had before.2.
blob-client: a throttled PUT backs off for seconds. A PUT that fails withThrottledusesClientPolicy::put_throttled:min(1 s * 2^(n-1), 16 s)[ceiling/2, ceiling)Why these values:
The jitter is a SplitMix64 hash of a per-client call number and the retry number, not a random draw. The crate had no randomness and documented deterministic backoff. A hash keeps a run replayable, and concurrent calls still get different delays.
What stays the same. GET, ranged GET, HEAD, DELETE and LIST keep the 100 ms / 200 ms schedule for every transient error, a throttle included, because a guest can be waiting on a demand GET. A PUT that fails with a non-throttle transient error (a reset connection, a deadline) also keeps the old schedule.
Key layout
Chunk keys are
chunks/sha256/<2 hex>/<62 hex>: one fixed prefix, then the content hash. That is the random naming that the naming-convention guidance recommends, and there is no sequential part. Manifests aremanifests/<uuid v4>/v<n>.json, which is also random. This PR does not change the layout. The 429 comes from the write rate of a burst, not from a hot key range.Upload concurrency (follow-up, not in this PR)
Chunk PUTs already go through the host's adaptive admission limit (
engram-chunk-store/src/admission.rs), together with fetches. Background-class uploads use at mostbulk_capslots. A PUT holds its permit while it backs off, so a throttled burst sends fewer requests, and other classes keep their priority. The tuner sees the throttle only when a PUT gives up, or as a drop in throughput after slow completions.A possible follow-up: report each
Throttledattempt to the admission tuner (from the chunk store or through a small hook in the blob client), so that the limit falls on the first 429 and not after the retries. This needs a seam between the blob client and the admission, and the evidence does not yet show that it is necessary.Eviction captures
An eviction capture is less exposed. It persists its finalize record before the uploads start. A failed upload makes the finalizer re-drive the leg with backoff, up to 10 attempts, and it does not poison the chain. The cost of a throttle there is a later eviction finish. With this PR, a throttled PUT in that leg also waits for seconds, so fewer re-drives are necessary.
Test
engram-blob-clientunit tests (paused tokio time, a fake store that answersThrottledN times, no real sleeps):throttled_put_backs_off_for_seconds_then_lands: three throttles, then the PUT lands after 3.5 s to 7 s of virtual time.throttled_put_that_never_clears_gives_up_within_the_budget: 6 tries and 15.5 s to 31 s, and the error isThrottled.throttled_get_keeps_the_short_schedule: 3 tries and exactly 300 ms.throttle_delays_stay_in_range_and_spread_across_calls: every delay is in[c/2, c), delays spread across calls, and the same input gives the same delay.engram-storage-gcs:status_codes_map_to_blob_errors(429 and 503 map toThrottled, 404 toNotFound, 500 and 403 toSdk).cargo fmt --all --check,cargo clippy -D warnings --all-targetson the six touched crates, andcargo nextest runonengram-blob-client,engram-storage-gcs,engram-storage-s3,engram-core,engram-testkit(192 passed) andengram-chunk-store(212 passed). The CIcargo nextest run --workspacestep runs these tests.SdkErrorwith a raw 503 response needs the smithy test types, and the emulator lanes cannot make MinIO throttle.🤖 Generated with Claude Code
https://claude.ai/code/session_01HNhpmz4QPYaVCE7tFna54D