Skip to content

Commit 16f8f38

Browse files
committed
fix: byte budget for buffered fallbacks + ECR commit-gate
Two structural fixes for high-concurrency ECR / large-layer workloads surfaced by the perf/profile-harness critical review: Byte budget for buffered uploads GHCR/GAR/ACR fallbacks buffer the entire blob in memory; the prior count-only cap let 64 concurrent 1 GB layers (CUDA/ML images) pin ~64 GB resident. New buffered_blob_bytes (default 512 MB) caps cross-call bytes via a tokio Semaphore; each fallback acquires min(known_size, budget) permits before buffer_stream via a new acquire_buffered_upload_permits helper. ECR consistency gate for manifest commit ECR's manifest validator lags blob PUT-201 by hundreds of ms at high concurrency, surfacing as BLOB_UPLOAD_UNKNOWN on manifest PUT and exhausting the retry budget. New BatchBlobChecker::wait_for_blobs_available polls BatchCheckLayerAvailability with exp backoff (200ms -> 5s cap) before manifest PUT, gated on RetryConfig::manifest_commit_wait (30s prod, 0 in fast_retry). Wired into push_manifests AND discover_and_sync_artifacts. Artifact pipeline parity discover_and_sync_artifacts now uses batch-check pre-population + BLOB_CONCURRENCY-capped parallel blob processing, matching the main image flow, with the same manifest_commit_wait gate before artifact manifest PUT. Diagnostic CLI flags removed --force-http1 and --no-h2-adaptive-window dropped from production CLI. The #[doc(hidden)] pub builder methods are retained for the perf-profile test harness and documented in client.rs + ocync-distribution/CLAUDE.md so future audits don't flag them as dead. Idiomatic cleanups - Provider dispatch in blob_push_stream: if-chain to exhaustive match (new ProviderKind variants fail to compile until handled) - fastrand replaces hand-rolled RandomState jitter (retry.rs) - should_retry_transport keeps is_request/is_body/is_decode only; is_connect/is_timeout are subsets of is_request on the async-hyper path. New test pins the timeout-vs-is_request equivalence. - Option<&Rc<dyn>> -> Option<&dyn> for pass-through trait params - HashSet collect dedup replaces filter-and-insert pattern - buffer_stream doc-vs-code drift fixed - std::cmp::min nesting -> chained .min() across 5 sites 1399 tests passing; cargo deny / fmt / clippy -D warnings clean.
1 parent 71255c3 commit 16f8f38

14 files changed

Lines changed: 1140 additions & 162 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ aws-sdk-ecrpublic = { version = "1", default-features = false, features = ["beha
1717
clap = { version = "4", default-features = false, features = ["derive", "std", "help", "usage", "error-context"] }
1818
google-cloud-auth = { version = "1.10", default-features = false }
1919
bytes = { version = "1", default-features = false }
20+
fastrand = { version = "2", default-features = false, features = ["std"] }
2021
hex = { version = "0.4", default-features = false, features = ["alloc"] }
2122
http = { version = "1", default-features = false }
2223
schemars = { version = "1.2", default-features = false, features = ["derive", "std"] }

crates/ocync-distribution/CLAUDE.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,18 @@ Provider names:
9696

9797
`build_registry_client` (`src/cli/mod.rs`) calls `ocync_distribution::install_crypto_provider()` at the top -- production main does this too, but the dispatch entry point is also reached from tests that bypass main, so the install must be idempotent there.
9898

99+
## Perf-harness test hooks on `RegistryClientBuilder`
100+
101+
Three `#[doc(hidden)] pub` builder methods exist on `RegistryClientBuilder` solely for in-repo perf and A/B harnesses (primarily `crates/ocync-sync/tests/perf_profile.rs`):
102+
103+
- `allow_invalid_certs(bool)` -- terminates TLS at a `testcontainers` `registry:2` with a self-signed cert.
104+
- `force_http1(bool)` -- compares HTTP/2 vs HTTP/1.1 throughput.
105+
- `http2_adaptive_window(bool)` -- A/Bs HTTP/2 adaptive flow-control window sizing.
106+
107+
These are **intentionally retained**. They look unused from a production-code grep because production code never reaches them (no CLI flag, no env var, no config setting). Removing them as "dead code" deletes load-bearing diagnostic infrastructure: any future perf investigation that needs to reproduce the HTTP/2 stall investigation (or any successor) would have to re-introduce the same plumbing from scratch.
108+
109+
If you're tempted to delete them, search `tests/perf_profile.rs` first -- it references all three by name.
110+
99111
## Commands
100112

101113
```bash

crates/ocync-distribution/src/blob.rs

Lines changed: 214 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -115,9 +115,12 @@ async fn expect_status(
115115

116116
/// Buffer an entire byte stream into a `Vec<u8>`.
117117
///
118-
/// When `capacity_hint` is `Some(n)`, the buffer is pre-allocated to `n` bytes
119-
/// to avoid reallocations. Used by fallback upload paths (GAR, GHCR, monolithic
120-
/// threshold) that cannot stream chunks to the registry.
118+
/// When `capacity_hint` is `Some(n)`, the buffer is pre-allocated to
119+
/// `min(n, MAX_BUFFER_PREALLOC)` bytes -- the manifest-declared size
120+
/// is capped at [`MAX_BUFFER_PREALLOC`] because the hint is
121+
/// attacker-controllable; larger streams grow the `Vec` organically via
122+
/// amortised doubling. Used by fallback upload paths (GAR, GHCR, ACR)
123+
/// that cannot stream chunks to the registry.
121124
async fn buffer_stream(
122125
stream: impl Stream<Item = Result<Bytes, Error>>,
123126
capacity_hint: Option<u64>,
@@ -127,7 +130,7 @@ async fn buffer_stream(
127130
// `with_capacity` plus the per-target streaming-blob concurrency
128131
// budget multiplies into arbitrary memory pressure.
129132
let cap = capacity_hint
130-
.map(|s| std::cmp::min(s as usize, MAX_BUFFER_PREALLOC))
133+
.map(|s| (s as usize).min(MAX_BUFFER_PREALLOC))
131134
.unwrap_or(0);
132135
let mut body = Vec::with_capacity(cap);
133136
futures_util::pin_mut!(stream);
@@ -307,33 +310,49 @@ impl RegistryClient {
307310

308311
// Registry-specific upload fallbacks, detected via the canonical
309312
// provider detection (handles case-insensitivity, ports, trailing dots).
313+
//
314+
// A `match` (rather than an `if`-chain) so that adding a new
315+
// `ProviderKind` with its own upload quirks fails to compile until
316+
// the dispatch is updated, instead of silently falling into the
317+
// default streaming-PUT path.
310318
let provider = self.base_url.host_str().and_then(detect_provider_kind);
311-
312-
// GHCR fallback: single PATCH (no Content-Range) to avoid the
313-
// multi-PATCH corruption bug.
314-
if provider == Some(ProviderKind::Ghcr) {
315-
return self
316-
.blob_push_stream_ghcr(repository, expected_digest, known_size, stream)
317-
.await;
318-
}
319-
320-
// GAR fallback: buffer entire stream and use monolithic push.
321-
// GAR (Artifact Registry) does not support chunked uploads; legacy
322-
// gcr.io hosts support it fine, so only Gar triggers this path.
323-
if provider == Some(ProviderKind::Gar) {
324-
return self
325-
.blob_push_stream_gar(repository, expected_digest, known_size, stream)
326-
.await;
327-
}
328-
329-
// ACR fallback: chunked PATCH under ACR's ~20 MB streaming-PUT body
330-
// limit. Buffers the stream so the digest can be verified before any
331-
// PATCH fires (avoids wasted upload bandwidth on corruption), then
332-
// splits into ACR_PATCH_CHUNK_SIZE chunks.
333-
if provider == Some(ProviderKind::Acr) {
334-
return self
335-
.blob_push_stream_acr(repository, expected_digest, known_size, stream)
336-
.await;
319+
match provider {
320+
// GHCR: single PATCH (no Content-Range) to avoid the
321+
// multi-PATCH corruption bug.
322+
Some(ProviderKind::Ghcr) => {
323+
return self
324+
.blob_push_stream_ghcr(repository, expected_digest, known_size, stream)
325+
.await;
326+
}
327+
// GAR: buffer entire stream and use monolithic push.
328+
// GAR (Artifact Registry) does not support chunked uploads;
329+
// legacy gcr.io hosts support it fine, so only Gar triggers
330+
// this path.
331+
Some(ProviderKind::Gar) => {
332+
return self
333+
.blob_push_stream_gar(repository, expected_digest, known_size, stream)
334+
.await;
335+
}
336+
// ACR: chunked PATCH under ACR's ~20 MB streaming-PUT body
337+
// limit. Buffers the stream so the digest can be verified
338+
// before any PATCH fires (avoids wasted upload bandwidth on
339+
// corruption), then splits into ACR_PATCH_CHUNK_SIZE chunks.
340+
Some(ProviderKind::Acr) => {
341+
return self
342+
.blob_push_stream_acr(repository, expected_digest, known_size, stream)
343+
.await;
344+
}
345+
// Default: streaming PUT below. Every other ProviderKind
346+
// (Ecr, EcrPublic, Gcr, DockerHub, Chainguard) uses the
347+
// default path; new variants must be added here explicitly.
348+
Some(
349+
ProviderKind::Ecr
350+
| ProviderKind::EcrPublic
351+
| ProviderKind::Gcr
352+
| ProviderKind::DockerHub
353+
| ProviderKind::Chainguard,
354+
)
355+
| None => {}
337356
}
338357

339358
debug!(
@@ -420,11 +439,14 @@ impl RegistryClient {
420439
host = self.base_url.host_str().unwrap_or("unknown"),
421440
"GAR does not support chunked uploads; buffering entire blob in memory"
422441
);
423-
// The streaming-blob permit caps how many in-flight buffered blobs
424-
// hold memory for this target -- functions both as an h2-stream cap
425-
// for short PATCH/PUT requests and as a memory-pressure cap for the
426-
// buffered blob body.
427-
let _stream_permit = self.acquire_streaming_blob_permit().await;
442+
// Two complementary caps for buffered fallback uploads:
443+
// 1. `streaming_blob_sem` bounds the *count* of in-flight
444+
// buffered uploads (also doubles as h2-stream cap).
445+
// 2. `buffered_blob_bytes_sem` bounds total *bytes* held
446+
// across in-flight buffered uploads.
447+
// The helper acquires both in the right order; the returned
448+
// tuple drops bytes_permit before stream_permit on scope exit.
449+
let _permits = self.acquire_buffered_upload_permits(known_size).await;
428450
let body = buffer_stream(stream, known_size).await?;
429451
let actual_digest = self.blob_push(repository, &body).await?;
430452

@@ -456,9 +478,9 @@ impl RegistryClient {
456478
"GHCR multi-PATCH chunked upload is broken; buffering blob for single-PATCH upload"
457479
);
458480

459-
// Bounds both h2-stream concurrency (PATCH+PUT are short streams)
460-
// and buffered-body memory pressure for this target.
461-
let _stream_permit = self.acquire_streaming_blob_permit().await;
481+
// Count cap (h2-stream) + byte cap (cross-call memory budget).
482+
// See `blob_push_stream_gar` for the layering rationale.
483+
let _permits = self.acquire_buffered_upload_permits(known_size).await;
462484
let scopes = [Scope::pull_push(repository.as_str())];
463485
let upload_url = self
464486
.initiate_blob_upload(repository, &scopes, "blob push ghcr initiate")
@@ -539,9 +561,9 @@ impl RegistryClient {
539561
"ACR rejects streaming PUT above ~20 MB; buffering blob for chunked PATCH upload"
540562
);
541563

542-
// Bounds both h2-stream concurrency (each PATCH is its own short
543-
// stream) and buffered-body memory pressure for this target.
544-
let _stream_permit = self.acquire_streaming_blob_permit().await;
564+
// Count cap (h2-stream) + byte cap (cross-call memory budget).
565+
// See `blob_push_stream_gar` for the layering rationale.
566+
let _permits = self.acquire_buffered_upload_permits(known_size).await;
545567

546568
let raw = buffer_stream(stream, known_size).await?;
547569
let actual_digest = Digest::from_sha256(Sha256::digest(&raw));
@@ -569,7 +591,7 @@ impl RegistryClient {
569591
// (PUT-without-prior-PATCH on an empty blob is valid).
570592
let mut offset: u64 = 0;
571593
while offset < total_len {
572-
let chunk_len = std::cmp::min(ACR_PATCH_CHUNK_SIZE as u64, total_len - offset);
594+
let chunk_len = (ACR_PATCH_CHUNK_SIZE as u64).min(total_len - offset);
573595
let chunk = body.slice((offset as usize)..((offset + chunk_len) as usize));
574596
let range_end = offset + chunk_len - 1;
575597
// OCI spec Content-Range format: `{start}-{end}` (NOT RFC 7233).
@@ -1471,4 +1493,154 @@ mod tests {
14711493

14721494
assert_eq!(result, digest);
14731495
}
1496+
1497+
/// Pins the byte-budget queueing contract for the buffered fallback
1498+
/// paths (GHCR / GAR / ACR).
1499+
///
1500+
/// Two concurrent GHCR uploads, each declaring half the budget plus
1501+
/// a few bytes, would together exceed the byte budget if they ran
1502+
/// fully overlapped. The first upload is delayed by the wiremock
1503+
/// mock (200 ms PATCH/PUT response delay) so the second must wait
1504+
/// on the byte-budget permit before its own POST initiates.
1505+
///
1506+
/// Asserts: the second upload's POST start time is at least
1507+
/// `delay` after the first upload's POST start time. This is the
1508+
/// observable signal that the byte-budget semaphore actually queued
1509+
/// the second upload behind the first.
1510+
#[tokio::test]
1511+
async fn buffered_upload_byte_budget_queues_concurrent_uploads() {
1512+
use std::sync::Arc;
1513+
use std::sync::Mutex as StdMutex;
1514+
use std::time::Instant;
1515+
use tokio::time::Duration;
1516+
use url::Url;
1517+
use wiremock::{Mock, MockServer, ResponseTemplate, matchers};
1518+
1519+
let server = MockServer::start().await;
1520+
let port = Url::parse(&server.uri()).unwrap().port().unwrap();
1521+
let post_times: Arc<StdMutex<Vec<Instant>>> = Arc::new(StdMutex::new(Vec::new()));
1522+
let post_times_clone = Arc::clone(&post_times);
1523+
1524+
// Two distinct repos so the POST paths don't collide; record the
1525+
// POST timestamps so we can verify the ordering after the fact.
1526+
Mock::given(matchers::method("POST"))
1527+
.and(matchers::path_regex(r"^/v2/repo-[ab]/blobs/uploads/$"))
1528+
.respond_with(move |req: &wiremock::Request| {
1529+
post_times_clone.lock().unwrap().push(Instant::now());
1530+
// Return a Location pointing back at the same upload path so
1531+
// the PATCH+PUT chain can complete.
1532+
let uri_path = req.url.path();
1533+
let upload_path = format!("{uri_path}upload-id");
1534+
ResponseTemplate::new(202).append_header("Location", upload_path)
1535+
})
1536+
.mount(&server)
1537+
.await;
1538+
1539+
// PATCH delays 200ms so the first upload holds its byte permit
1540+
// long enough for the second to definitively wait. PUT is fast.
1541+
let patch_delay = Duration::from_millis(200);
1542+
Mock::given(matchers::method("PATCH"))
1543+
.respond_with(
1544+
ResponseTemplate::new(202)
1545+
.append_header("Location", "/v2/done")
1546+
.set_delay(patch_delay),
1547+
)
1548+
.mount(&server)
1549+
.await;
1550+
Mock::given(matchers::method("PUT"))
1551+
.respond_with(ResponseTemplate::new(201))
1552+
.mount(&server)
1553+
.await;
1554+
1555+
// 6 MB blobs: large enough that two together (12 MB) exceed an
1556+
// 8 MB budget. Use distinct contents per upload so the digests
1557+
// differ.
1558+
let blob_a = vec![0xAAu8; 6 * 1024 * 1024];
1559+
let blob_b = vec![0xBBu8; 6 * 1024 * 1024];
1560+
let digest_a = test_digest(&blob_a);
1561+
let digest_b = test_digest(&blob_b);
1562+
1563+
// Use the GHCR fallback path (single-PATCH then PUT, buffered),
1564+
// with a small 8 MB byte budget. Two 6 MB uploads must serialize.
1565+
let base_url = Url::parse(&format!("http://ghcr.io:{port}")).unwrap();
1566+
let client = Arc::new(
1567+
crate::client::RegistryClientBuilder::new(base_url)
1568+
.resolve(
1569+
"ghcr.io",
1570+
std::net::SocketAddr::from(([127, 0, 0, 1], port)),
1571+
)
1572+
.buffered_blob_bytes(8 * 1024 * 1024)
1573+
.build()
1574+
.unwrap(),
1575+
);
1576+
1577+
let repo_a = RepositoryName::new("repo-a").unwrap();
1578+
let repo_b = RepositoryName::new("repo-b").unwrap();
1579+
1580+
let client_1 = Arc::clone(&client);
1581+
let blob_a_clone = blob_a.clone();
1582+
let digest_a_clone = digest_a.clone();
1583+
let h1 = tokio::spawn(async move {
1584+
client_1
1585+
.blob_push_stream(
1586+
&repo_a,
1587+
&digest_a_clone,
1588+
Some(blob_a_clone.len() as u64),
1589+
data_stream(&blob_a_clone, 65536),
1590+
)
1591+
.await
1592+
});
1593+
1594+
// Deterministic synchronization (no head-start race): wait until
1595+
// task 1's POST has been recorded -- by that point task 1 has
1596+
// already acquired the byte permit, so task 2 is guaranteed to
1597+
// see the budget pressure when it tries. Polls a shared
1598+
// `post_times` Mutex on a short cadence and bails out after a
1599+
// generous deadline if task 1 never makes progress (treating
1600+
// that as a separate test failure).
1601+
let wait_for_first_post = async {
1602+
loop {
1603+
if !post_times.lock().unwrap().is_empty() {
1604+
return;
1605+
}
1606+
tokio::time::sleep(Duration::from_millis(5)).await;
1607+
}
1608+
};
1609+
tokio::time::timeout(Duration::from_secs(5), wait_for_first_post)
1610+
.await
1611+
.expect("first upload POST must fire within 5s (task 1 likely stuck)");
1612+
1613+
let client_2 = Arc::clone(&client);
1614+
let blob_b_clone = blob_b.clone();
1615+
let digest_b_clone = digest_b.clone();
1616+
let h2 = tokio::spawn(async move {
1617+
client_2
1618+
.blob_push_stream(
1619+
&repo_b,
1620+
&digest_b_clone,
1621+
Some(blob_b_clone.len() as u64),
1622+
data_stream(&blob_b_clone, 65536),
1623+
)
1624+
.await
1625+
});
1626+
1627+
let r1 = h1.await.unwrap();
1628+
let r2 = h2.await.unwrap();
1629+
assert!(r1.is_ok(), "first upload should succeed: {:?}", r1.err());
1630+
assert!(r2.is_ok(), "second upload should succeed: {:?}", r2.err());
1631+
1632+
let times = post_times.lock().unwrap();
1633+
assert_eq!(times.len(), 2, "both POSTs must have fired");
1634+
let gap = times[1].duration_since(times[0]);
1635+
// Task 2 cannot POST until task 1 releases the byte permit,
1636+
// which happens when task 1's `blob_push_stream` returns
1637+
// (after PATCH-delay + fast PUT). The lower bound is
1638+
// patch_delay minus a generous CI margin (100 ms) since we
1639+
// already eliminated the head-start race above.
1640+
let lower_bound = patch_delay.saturating_sub(Duration::from_millis(100));
1641+
assert!(
1642+
gap >= lower_bound,
1643+
"second POST started after only {gap:?}; expected the byte budget to queue it for at least ~{lower_bound:?} behind the first (patch_delay = {patch_delay:?})"
1644+
);
1645+
}
14741646
}

0 commit comments

Comments
 (0)