fix(prepare): keep the concurrency window full and retry transient failures - #124
Merged
Merged
Conversation
…ilures The prepare phase resolved mappings through an ordered `buffered` queue, which frees a slot only when the head yields. One mapping listing a repository with tens of thousands of tags pinned its slot while every mapping that finished behind it kept its own, so the window sat at 2 or 3 of 16 for minutes while the rest of the config waited. A 95-mapping run spent 625 seconds doing 442 seconds of work, and going from serial to 16-way bought 1.4x rather than the 6.8x the benchmark reported. The benchmark could not see it. Every mapping in it has the same latency, which is the one shape where an ordered queue and a window that refills on completion behave identically: the head is never the straggler. The concurrency tests had the matching gap, asserting the ceiling (`max_in_flight == limit`) but never the floor, and `buffered` reaches the ceiling in the first instant. Mappings and analyze's walks now use `buffer_unordered`, carrying each outcome's index and sorting once every result is in, so config-order bookkeeping is unchanged. On a skewed benchmark, one deep repository per window among shallow ones, the same run costs 4.84s ordered against 1.09s unordered. Raising the achieved concurrency raised throttling, which exposed a second defect: nothing in the prepare phase retried. The engine retries the work it drives through `with_retry`, but the prepare phase runs before the engine exists, so a single 429 failed a whole mapping. A live 95-mapping run lost 5 mappings and exited 1. Tag listing, token exchange, ACR's OAuth exchanges, and analyze's manifest walk now retry themselves; the same run exits 0 and loses none. Retry details that matter: - Backoff is jittered. Up to 16 mappings hit one registry at once, so an unjittered schedule sends every throttled retry back in lockstep. - The auth paths return the throttled response rather than raising it. Raising a 429 with a `status_code()` would let the engine's `with_retry` classify an auth failure as retryable and multiply this retry by its own, aiming four times the requests at a registry already asking for less. - A tag listing draws its retries from one budget for the whole walk. Four per page across up to MAX_TAG_PAGES pages is a bound only on paper. - `ocync_distribution::retry::is_transient_transport` is now the single transient-transport predicate; `ocync_sync::retry::should_retry_transport` delegates to it. `with_retry` moved to `retry.rs` beside its siblings. Measured against public.ecr.aws on 2026-08-28: tag listing draws repeated 429s while paced at 8 requests per second with credentials loaded, halving the AIMD window 11 to 6 to 3. AWS publishes no TPS quota for tag listing at all; the 8 is derived from the authenticated pull quota. Recorded in the ECR Public page and the AIMD table comment rather than replaced with a guessed value, which wants its own measurement. Also corrects docs/src/content/performance.md, which stated a 5-initial AIMD window where DEFAULT_INITIAL_WINDOW is 1.
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
Two defects, found from a v0.8.0 run whose prepare phase was unchanged from before #123 made it concurrent.
The concurrency window collapsed.
resolve_allused an orderedbufferedqueue, which frees a slot only when the head yields. One mapping listing a deep repository pinned its slot, every mapping that finished behind it kept its own, and the window sat at 2 or 3 of 16 while the config waited:Nine mappings did not complete during that final tick. They had finished minutes earlier and were waiting to be released. 625 seconds to do 442 seconds of work.
Nothing in the prepare phase retried. The engine retries the work it drives through
with_retry, but the prepare phase runs before the engine exists. A single 429 failed a whole mapping.Why the benchmark missed it
Two blind spots, both needed:
slow_tag_registry(mappings, delay)gives every mapping the same delay. Under uniform latency an ordered queue and a window that refills on completion are indistinguishable, because the head is never the straggler.resolve_all_never_exceeds_its_concurrency_limitasserts a ceiling.bufferedreaches 16 in the first instant, so that assertion passes while the window collapses behind it.Both are fixed.
skewed_tag_registryplaces deep repositories deliberately, andresolve_all_keeps_its_window_full_while_mappings_waitasserts the floor: while any mapping is unstarted, the window must be full. It fails on the old code withSome((1, 8)), three slots idle and eight mappings blocked.The fixture also warms the AIMD window first. Cold, the window opens at 1, so the first mapping holds the only permit and nothing else can issue regardless of the queue above it. That hid the bug on the first attempt at this test.
Changes
Mappings and both analyze walks use
buffer_unordered, carrying each outcome's index and sorting once every result is in, sowatch_log, the counters, and config-order bookkeeping are unchanged. Blob aggregation inanalyzenow runs in completion order, which the report does not need: every figure it prints is a count, a sum, or a sorted container.The target-listing fan-out at
synchronize.rsis deliberately left asbuffered. Its concurrency equals its item count, so no queue can form.Retry in the prepare phase, through one shape in a new
ocync_distribution::retry:list_tags, including the body read, so a connection reset part way through a page is re-sent rather than failing the listing.token_exchange::exchange, both the/v2/ping and the realm token request.acr::exchange_post.analyze's manifest walk, at its call site viaocync_sync::retry::with_retry, becausemanifest_pullis shared with the engine and a retry further down would multiply the two.Three details are load-bearing:
status_code()and let the engine'swith_retryclassify an auth failure as retryable, multiplying this retry by the engine's own and aiming four times the requests at a registry already asking for less.MAX_TAG_PAGESpages is a bound only on paper.is_transient_transportis now the single transient-transport predicate for the workspace, andocync_sync::retry::should_retry_transportdelegates to it. The previous two copies disagreed: one listedis_timeout() || is_connect(), whichis_request()already covers on the async hyper path, and omittedis_body()/is_decode(), which it does not.with_retrymoved fromengine.rstoretry.rsbesideRetryConfigand theshould_retry*predicates, and is nowpubsoanalyzecan use it.Measurements
Skewed benchmark, 95 mappings, one deep repository per window among shallow ones, same run both ways:
bufferedbuffer_unorderedThe first column is the reported defect: serial to 16-way bought 1.4x.
Live, 95 mappings against
public.ecr.aws,--dry-run:Live, 21 mappings, before and after the concurrency change: byte-identical dry-run output,
md5 451e274b429a43d41ea3270e5dbc968aboth.Registry behavior
Measured 2026-08-28, and recorded in
docs/src/content/registries/ecr-public.mdper the rule in CLAUDE.md: ECR Public draws repeated 429s onTagListwhile ocync paces it at 8 requests per second with credentials loaded, halving the AIMD window 11 to 6 to 3. AWS publishes no TPS quota for tag listing at all; the 8 is derived from the 10 TPS authenticated pull quota, and the read window groups tag listing in with pulls.The value is documented as measured-insufficient rather than replaced with a guess. Lowering it wants its own measurement, and the retry makes the current pacing survivable in the meantime.
Not changed, deliberately
build_clientsis a serial loop, so retrying would turn a 30s hang into a 150s stall of the whole prepare phase. A helper that fails for real already falls back to anonymous.send_with_aimdandmanifest_pullstill do not retry. They are on the engine's path, which already does.get_429_retries_and_succeedspins that layering.Tests
15 new, 1488 passing, 0 failing.
resolve_all_keeps_its_window_full_while_mappings_wait, plus theskewed_tag_registryfixture andprepare_phase_benchmark_skewed_resolutionlist_tags_retries_after_a_429,list_tags_gives_up_after_persistent_429s(pinning the exact attempt count, so an unbounded retry fails it)exchange_retries_a_throttled_token_endpoint,exchange_gives_up_on_a_persistently_throttled_endpointretrying_gives_up_and_surfaces_the_last_error,retrying_does_not_re_run_a_permanent_failure,only_a_throttle_is_transient_among_statuses,backoff_grows_and_is_capped,backoff_is_decorrelated,a_refused_connection_is_transient,a_status_error_is_not_a_transport_failurea_refused_connection_is_transientprovokes a real error from a closed port rather than asserting against a hand-built one, because the predicate has to match the failure that actually shows up: the run that motivated the token-exchange fix died on "error sending request for url (.../token/...)".Every new test was mutation-checked. Reverting
buffer_unordered, zeroing the retry bound, and inverting the transport predicate each fail the test that covers them.Gate:
cargo fmt --check,cargo clippy --workspace --all-targets -- -D warnings,cargo test --workspace,cargo deny check, all green.