Add short-TTL bundle cache with singleflight de-duplication - #192
Add short-TTL bundle cache with singleflight de-duplication#192bdehamer wants to merge 8 commits into
Conversation
Admission bursts for the same image — e.g. a workload rolling out across many clusters at once — produce many concurrent validations for the same reference. Each makes its own OCI round-trip, and that concurrent load is what overwhelms the registry and pushes fetches past the ~5s admission timeout (surfacing as the provider's reason=canceled). Log analysis showed ~59% of fetch failures are correlated cross-cluster same-manifest bursts. This fronts the OCI fetch with: - a short-TTL in-memory result cache, so repeat validations of a stable digest are served from memory, and - singleflight de-duplication, so a burst of concurrent identical fetches collapses into a single upstream request whose result is shared with every waiter. The shared fetch is detached from the triggering caller's context so it can run to completion and warm the cache even if that caller's admission request was already cancelled. Configurable via -bundle-cache-ttl (default 60s; 0 disables). Failed fetches are not cached. Adds cache hit / miss / dedupe / size metrics. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: adc26119-a4fc-4118-82b4-0601ae564252
Refine CachingBundleFetcher per the package-security#5198 design so the two mechanisms have the correctness properties they should: - Singleflight stays keyed on the requested reference (safe under any tag mutability; concurrent-only coalescing, no stale-serve window). - The persisted TTL cache is now digest-only. OCI tags are mutable, so serving a tag from a persisted entry within the TTL could return a verification result for a digest the tag no longer points to -- an admission bypass. Tag refs still get singleflight; they simply never read from or write to the time cache. A successful tag fetch warms a digest-keyed entry so a later by-digest request can be served. Also bound the cache with -bundle-cache-max-entries (default 4096, 0 = unbounded), evicting the soonest-to-expire entry when full, and expose aaop_bundle_cache_evictions_total. Ref: github/package-security#5198 (comment) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
9043e27 to
1737d43
Compare
There was a problem hiding this comment.
Pull request overview
Adds in-memory OCI bundle caching and singleflight de-duplication to reduce registry load and admission latency.
Changes:
- Adds a bounded, digest-keyed TTL cache and concurrent fetch coalescing.
- Exposes cache configuration flags and Prometheus metrics.
- Adds cache behavior, cancellation, eviction, and concurrency tests.
Show a summary per file
| File | Description |
|---|---|
README.md |
Documents caching behavior, configuration, and metrics. |
pkg/metrics/prom.go |
Defines cache and singleflight metrics. |
pkg/fetcher/cache.go |
Implements caching and fetch de-duplication. |
pkg/fetcher/cache_test.go |
Tests cache behavior and lifecycle. |
go.mod |
Promotes x/sync to a direct dependency. |
cmd/aaop/aaop.go |
Adds flags and integrates the caching fetcher. |
Review details
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Suppressed comments (1)
pkg/fetcher/cache.go:263
- This gauge update can race with a store after the sweep unlocks, allowing the sweep's older count to overwrite the newer count. Publish the count before releasing c.mu to preserve mutation order.
n := len(c.cache)
c.mu.Unlock()
metrics.BundleCacheEntries.Set(float64(n))
- Files reviewed: 6/6 changed files
- Comments generated: 5
- Review effort level: Balanced
…fix cache metric accuracy, document tag singleflight semantics - aaop.go: always construct CachingBundleFetcher so singleflight runs regardless of TTL; a non-positive -bundle-cache-ttl now disables only the persisted time cache (and its janitor), not the whole decorator. Startup log distinguishes "bundle cache enabled" from "singleflight de-duplication only". - aaop.go: validateBundleCacheFlags rejects negative -bundle-cache-ttl and -bundle-cache-max-entries at startup (log.Fatal), matching configureBundleFetcher. 0 keeps its meaning (ttl=0 → time cache off, max-entries=0 → unbounded). - cache.go: publish the BundleCacheEntries gauge while holding the lock in store and sweep so concurrent updates match the serialized mutation. - cache.go: singleflight.Result.Shared is true for the leader too, so BundleFetchDeduped over-counted by 1 per flight. Track the leader and only count joiners → an N-caller flight records N-1 dedupes. - cache.go: document that tag singleflight shares the leader's tag→digest resolution within the in-flight window, subsumed by the inherent admission→kubelet-pull TOCTOU; digest refs give a strong binding. Tag singleflight is kept deliberately (avoids the resolve storm). - Tests: singleflight still coalesces with the time cache disabled (ttl=0) and digest fetches are not time-cached then; concurrency test asserts N-1 dedupes; flag validation rejects negatives; janitor not started at ttl=0. - README: ttl=0 = singleflight-only, negative flags rejected, tag-singleflight window rationale. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Review details
Suppressed comments (3)
Previously missed (2) — in code that hasn't changed since the last review.
pkg/fetcher/cache.go:109
- An already-canceled request still reaches
DoChan, whose detached closure starts a new registry fetch before the laterselectreturns the cancellation. This is especially costly for multi-image requests:Provider.Validatecontinues its loop after each fetch error, so once the deadline expires every remaining key can launch orphaned work for the full retry budget. Return immediately whenctx.Err()is already set; only fetches that began while the request was live should be detached to finish.
This issue also appears on line 128 of the same file.
func (c *CachingBundleFetcher) BundleFromName(ctx context.Context, ref name.Reference, ro []remote.Option) ([]*bundle.Bundle, *v1.Hash, error) {
pkg/fetcher/cache.go:190
- This also caches a successful “no attestations” lookup for digest inputs:
DoBundleFromNamereturns(nil, nil, nil)whenremote.Referrersis empty (bundle.go:256-260), and this branch stores that result. The subject digest is immutable, but its OCI referrer set is not; if validation races attestation publication, subsequent validations keep returningimage_unsignedfor the full TTL after the attestation appears. Avoid negative-caching this mutable lookup (or give it a separately documented, shorter TTL).
case isDigest:
pkg/fetcher/cache.go:130
- A cache hit returns the same type as an upstream fetch, so
provider.go:133,151records it inaaop_attestations_retrieved_timerandaaop_attestations_retrieved_total. The timer's Help and README define it as OCI-registry fetch latency/downloads; once hits dominate, its latency distribution no longer measures the registry and can mask the timeout signal this PR is meant to monitor. Propagate hit/upstream status (or observe registry metrics around the inner fetch) and update the metric contract accordingly.
if e, ok := c.load(key); ok {
metrics.BundleCacheHits.Inc()
return e.bundles, e.hash, nil
- Files reviewed: 7/7 changed files
- Comments generated: 2
- Review effort level: Balanced
…celled callers, document referrer-set staleness - cache.go: only persist positive results. A digest with no referrers resolves to (nil, nil, nil); caching it pinned "no attestations" for the whole TTL even after one was later published (the referrer set is mutable though the digest is immutable). Gate the store on len(b) > 0; still return the empty result to the caller so the next validation re-fetches and picks up a new attestation. - cache.go: guard already-cancelled callers. A cache hit is still served (free), but a request whose ctx is already cancelled on arrival returns the cancellation without starting a DoChan fetch, so a timed-out multi-image Provider.Validate cannot spawn orphaned registry work during a herd. Uses kindFromContext, matching the mid-flight cancel path; detached warming for fetches that began while live is unchanged. - README + comment: correct the "digest = always safe" framing. Digest-keying guarantees a moved tag cannot substitute a different image, but the referrer set is mutable, so a cached positive result can be up to one TTL stale wrt attestations added/removed (removal-as-revocation delayed to TTL); re-verification checks crypto against the trust root, not the referrer set; no-attestation results are not cached. - Tests: an empty digest result is not cached (re-fetch reflects a later attestation); an already-cancelled caller with a cold cache does not call the inner fetcher and returns a cancellation, while a warm cache still serves the hit. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Round-2 review dispositionThanks for the second pass. Summary of how each finding was handled (three had no inline thread to reply to):
Validation on |
There was a problem hiding this comment.
Review details
Suppressed comments (1)
Previously missed (1) — in code that hasn't changed since the last review.
pkg/fetcher/cache.go:183
- The second-chance lookup can return a cache entry after this invocation already incremented
BundleCacheMissesat line 143, so that request is reported as a miss even though no upstream fetch occurs. This race is exactly why this lookup exists (another completed flight can populate the entry between the first lookup andDoChan). Carry whether the singleflight result came from this lookup and record hit versus miss only after the source is known.
// Another caller may have populated the cache while this call queued.
if timeCacheable {
if e, ok := c.load(key); ok {
return e, nil
}
- Files reviewed: 7/7 changed files
- Comments generated: 1
- Review effort level: Balanced
sigstore-go memoizes verification state on the *bundle.Bundle receiver (Bundle.TlogEntries sets b.hasInclusionPromise / b.hasInclusionProof), and PGIVerifier's verify flow calls TlogEntries. The cache previously stored and returned the same *bundle.Bundle to every cache hit and every singleflight recipient, so concurrent validations of the same digest -- the exact herd this cache serves -- performed unsynchronized writes to a shared struct: a real data race in the verification path. Cache the immutable serialized form instead and hand each caller its own freshly-unmarshalled bundles: - cacheEntry now holds serialized [][]byte (marshalled bundles) instead of []*bundle.Bundle; hash and expires are unchanged. - serializeBundles/deserializeBundles use Bundle.MarshalJSON/UnmarshalJSON (protojson under the hood). The round-trip is lossless for verification -- all material is in the proto and the memoized flags are recomputed per verification. - Every consumer (cache-hit path, singleflight leader, and joiners) unmarshals its own bundle set, so no two verifications share a mutable *Bundle. Fix D (empty results not cached) and fix C (already-cancelled entry guard) are preserved. Also fix miss-counting: the second-chance load inside the singleflight closure could return a concurrently-populated entry after a miss was already counted at the first lookup. Defer the hit/miss decision until the result source is known (flightResult.fromCache): a second-chance cache hit now counts as a hit, a fresh fetch as a miss. Tests: a 25-goroutine concurrency test (run under -race) asserts every caller receives a distinct *bundle.Bundle instance; a round-trip test asserts serialize->deserialize preserves the bundle (proto.Equal). Existing tests now use a real minimal bundle so they survive the marshal round-trip. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Round-3 review disposition
Validation on |
The res.Err early-return skipped the hit/miss accounting below it, so an errored upstream fetch was never counted. During a registry outage that undercounts misses and inflates the apparent hit ratio, masking the signal. Increment BundleCacheMisses in the error branch, gated on timeCacheable to match the success path. Cancellations are left untouched: an already-cancelled caller and the mid-flight ctx.Done() branch are not cache outcomes and are not counted. Test: an errored digest fetch increments aaop_bundle_cache_misses_total, and a cancelled caller does not. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The janitor goroutine is an internal detail of CachingBundleFetcher; noting it (and the absent defer Stop()) at the call site was confusing, since nothing here signals a janitor exists. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 815c91eb-313b-4d6c-bbe3-904ed8ad1e35
There was a problem hiding this comment.
Review details
Suppressed comments (2)
Previously missed (1) — in code that hasn't changed since the last review.
pkg/fetcher/cache_test.go:221
- This sleep does not guarantee that all follower goroutines have entered
DoChan. On a loaded CI worker, a follower can be scheduled only afterinner.blockis closed; because tag results are not cached, that follower starts another upstream fetch and makes both the call-count and de-duplication assertions flaky. Use deterministic synchronization (for example, a test hook/barrier that confirms every follower has joined the flight) before releasing the fake fetcher.
This issue also appears on line 277 of the same file.
time.Sleep(50 * time.Millisecond)
pkg/fetcher/cache_test.go:277
- The fixed sleep cannot ensure every goroutine has joined the in-flight call before the leader is released. If a goroutine is delayed past this point,
ttl=0means it cannot use a warmed cache and it will perform a second upstream call, intermittently failing thecallCount() == 1assertion. Replace the timing assumption with a deterministic barrier/test hook that confirms all followers are waiting on the flight.
time.Sleep(50 * time.Millisecond)
- Files reviewed: 7/7 changed files
- Comments generated: 0 new
- Review effort level: Balanced
Soften the singleflight 'no stale-serve window' wording (a mid-flight tag move can hand a joiner the leader's resolution; it is concurrent-only coalescing, not a persisted cache), and note that an errored upstream fetch counts as a cache miss — matching the implementation and the PR description. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 815c91eb-313b-4d6c-bbe3-904ed8ad1e35
The provider fetches attestation bundles through go-containerregistry's DefaultTransport, whose 30s dial and 10s TLS-handshake timeouts are far longer than a single fetch attempt (-bundle-timeout) and the fail-closed admission-webhook deadline. When a request is routed (via a geo-replicated registry's global endpoint) to a degraded replica, it stalls in connection setup and is cancelled at the deadline without ever retrying against a healthy replica — so the retry loop effectively collapses to one attempt. Install a shared http.Transport (cloned from remote.DefaultTransport, so the idle-pool sizes and HTTP/2 tuning are preserved) whose connection-phase timeouts are bounded below the per-attempt budget, and wire it in via remote.WithTransport. A stalled phase is then abandoned early, leaving parent budget for retryBundle to open a fresh connection (which the global endpoint may route to a healthy replica). Rather than bake in constants tuned for one deployment, the phase timeouts are a decomposition of the per-attempt budget and are derived from it: - dial = 0.6 * bundle-timeout - TLS handshake = 0.6 * bundle-timeout - response header = 0.8 * bundle-timeout (floored at 250ms) This keeps the provider correct by default at any -bundle-timeout, which matters for a general-purpose OSS provider run against registries with very different latency profiles. Operators can still pin an individual phase via -registry-dial-timeout / -registry-tls-handshake-timeout / -registry-response-header-timeout (0 = derive); a positive override must be less than bundle-timeout, validated at startup, so a phase timeout can never be silently swallowed by the attempt's context deadline. The transport is a singleton (shared connection pool) rebuilt once at startup by ConfigureTransport after flags are parsed. The provider can only couple transport timeouts to bundle-timeout; the outer invariant (attempts*bundle-timeout + delays < Provider.timeout < webhook timeout) spans gatekeeper/k8s config and remains a deployment-time concern. At the deployed bundle-timeout of 2.5s the derived values are dial 1.5s / TLS 1.5s / response-header 2s. Defense-in-depth alongside the retry budget and the cache + singleflight de-duplication shipped in v0.2.x (#192). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: adc26119-a4fc-4118-82b4-0601ae564252
Summary
Front the OCI bundle fetch with singleflight de-duplication and a short-TTL, digest-only in-memory cache, so that repeated and concurrent validations of the same image no longer each perform a full registry round-trip. This reduces the redundant registry load that can push attestation fetches past the admission webhook timeout.
Today
Provider.Validatere-fetches bundles and re-runs verification on every request — even for an image validated moments earlier, and even when many identical requests arrive at once.Failure modes this addresses
What this PR does
fetcher.CachingBundleFetcherdecorates the OCI fetch with two mechanisms that have different correctness properties:context.WithoutCancel(ctx)so it completes and warms the cache even if the triggering caller's admission was cancelled mid-flight. A request that is already cancelled on arrival serves a cache hit if one exists but does not start a new fetch — so a timed-out multi-imageValidatecannot spawn orphaned registry work. The inner fetcher's per-attempt timeout and retry budget still bound it.Provider.Validate. The cache operates at the fetcher layer and returns{bundles, digest}, never a verdict — so cryptographic / trust-root revocation is re-checked on every hit. (Registry-side attestation removal is a referrer-set change and is only reflected once the entry expires — see the digest bullet above.)*bundle.Bundleinstance (sigstore-go memoizes verification state on the bundle receiver, which would otherwise be an unsynchronized write).Design decision: tag references & singleflight (reviewed, intentional)
Singleflight is keyed on the requested reference, tags included. This was raised in review as a potential tag-mutation bypass; it is a deliberate, bounded trade-off, documented here so it isn't re-litigated:
Configuration
-bundle-cache-ttl— result cache TTL. Default60s;0disables the time cache (singleflight de-duplication stays active).-bundle-cache-max-entries— maximum cached entries. Default4096;0= unbounded. Negative values for either flag are rejected at startup.Metrics
aaop_bundle_cache_hits_total,aaop_bundle_cache_misses_total— digest lookups only; an errored upstream fetch counts as a miss.aaop_bundle_fetch_deduped_total— joined (non-leader) callers served by an in-flight singleflight leader.aaop_bundle_cache_entries— current cache size.aaop_bundle_cache_evictions_total— capacity evictions.Scope and limitations
timeoutSeconds. Where fetch latency under load is the root cause, raising the webhook timeout addresses the cancellation directly and independently of load; this PR reduces the load that produces that latency in the first place. The two are complementary.Testing
go build ./...✅go test ./... -race✅ — digest cache hit + TTL expiry; tag references are not served from the time cache (a moved tag re-fetches); concurrent tag references are singleflighted into a single fetch; singleflight still de-duplicates when the time cache is disabled (ttl=0); a digest with no attestations is not cached (a later-published attestation is seen on the next call); a request cancelled on arrival serves a cache hit but starts no fetch; every caller receives a distinct, freshly-unmarshalled bundle set (no shared*bundle.Bundleacross concurrent verifications); serialize↔deserialize round-trips losslessly; errors are not cached; an errored upstream fetch counts as a miss; a mid-flight cancelled caller still warms the cache; max-entries eviction increments the evictions metric; negative flags are rejected; distinct references; idempotentStop.golangci-lint run ./...✅ 0 issues.Open questions for reviewers
-bundle-cache-ttl(60s) and-bundle-cache-max-entries(4096) are starting points; open to tuning.Follow-ups
aaop_attestations_retrieved_*) uncontaminated by cache hits — tracked in Bundle cache follow-ups: expose cache flags via Helm chart; keep registry-fetch metrics uncontaminated by cache hits #202.