Skip to content

perf(cache-proxy): bound, hedge, and adapt peer fetches - #1047

Open
EDsCODE wants to merge 2 commits into
mainfrom
eric/cache-proxy-peer-path
Open

perf(cache-proxy): bound, hedge, and adapt peer fetches#1047
EDsCODE wants to merge 2 commits into
mainfrom
eric/cache-proxy-peer-path

Conversation

@EDsCODE

@EDsCODE EDsCODE commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Problem

Peer lookup is useful only while it beats origin. The previous implementation had three failure modes under contention: peer fills were unbounded across requests, a fixed wait delayed origin even when the fleet was degraded, and losing transfers could continue for up to 30 seconds after the response path had already chosen the other source.

Changes

  • Bound peer work process-wide. Keep the existing 8-fill per-request fairness limit and add shared count and byte ceilings. CACHE_PEER_FETCH_MAX_CONCURRENCY defaults to 32; CACHE_PEER_FETCH_MAX_BYTES defaults to concurrency × block size. Permit queueing consumes the hedge head start, so overloaded work sheds to the coalesced origin path instead of starting late.
  • Use a true adaptive hedge. Origin starts while peer I/O is still running after a head start equal to the rolling p50 of the last 64 successful peer block fetches, clamped to 25–150 ms. Origin work continues through the existing miss-run coalescing and shared span flight rather than becoming one request per block.
  • Cancel the losing side. Each validated origin block commit cancels the matching peer transfer for every waiter on the shared span. A peer win releases and cancels the origin span once no other request still needs it. Partial losing-side bytes are recorded as duplicate work.
  • Add a latency circuit breaker. The proxy compares peer latency with time through the first atomically committed origin block. Eight sustained observations above 1.5× origin open the breaker; open proxies go directly to origin and run one bounded, non-blocking recovery probe every 5 seconds. Three healthy samples close it again. Abrupt slowdowns and origin-before-first-byte stalls have bounded diagnostic paths.
  • Expand observability. Add hedge/winner, cancellation, duplicate-byte, late-success, breaker-transition, shed, queue/fetch latency, EWMA, and process-wide in-flight count/byte metrics. Served. lines now include the client address without its ephemeral port. Existing phase-timing fields and blocks_hedged remain intact.
  • Keep the proven mechanics. Peer has probes retain their 150 ms timeout, block fills remain parallel, and all peer/body operations now honor caller cancellation.

Runtime defaults and a peer-path recovery runbook are documented in cmd/cache-proxy/README.md; the new metric families are documented in docs/metrics.md.

Default-cap sweep

An isolated 16/32/64 contention sweep selected 32. A cap of 64 removed synthetic origin fallback but did not show a stable latency improvement and doubled the maximum byte reservation. A cap of 32 materially reduced fallback versus 16 while preserving the safer resource ceiling.

Testing

  • just test-cache-proxy
  • Focused new-path tests under go test -race, 3 repetitions
  • just lint

Coverage includes global count/byte admission, queued-deadline shedding, adaptive head starts, shared-flight and per-block cancellation, partial duplicate-byte accounting, mixed peer/origin spans, abrupt breaker opening, bounded recovery before and after the first origin block, pure-peer reset behavior, and client attribution.

…imeout

Phase-timing trails from production cold scans showed 100% of block-request
serving time inside FetchFromPeers (median 714ms cold / 237ms steady-state
per request, vs 8ms for local hits), with 16% of cold requests burning the
full 1s has-probe timeout, and 2-block requests costing exactly twice the
single-block peer round.

Three changes, each aimed at one measured cost:

- peerHasTimeout 1s -> 150ms: healthy peers answer probes in single-digit
  ms while the origin path costs ~200ms, so waiting longer for the fleet's
  slowest "no" only delays a faster fallback.
- Peer fills for a request's missing blocks now run concurrently (bounded
  at 8) instead of one FetchFromPeers round per block in sequence; phase 1
  consumes results in order, so miss-run coalescing and single-flight keys
  are unchanged.
- A wait budget (400ms, shared absolute deadline across the request's
  fills) hedges stalled fills to the coalesced origin fetch instead of
  pinning the request on the 30s body-transfer timeout; the fill keeps
  running in the background and still populates the cache. New counter
  cache_proxy_peer_fill_hedged_total and Served. field blocks_hedged track
  how often that happens.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Aug 9, 2026

Copy link
Copy Markdown

Test Impact Plan

Deterministic summary of how this PR changes tests, CI runners, and coverage-risk signals.

Summary

Area Added Changed Deleted
Test files 2 3 0
E2E/journey files 0 0 0
Workflow files 0 0 0

Signals

  • Test cases: +46 / -0
  • Assertions: +171 / -7
  • Skips or known failures added: 0
  • Workflow continue-on-error added: 0
  • Workflow path filters added: 0
  • Test commands removed from justfile: 0
  • E2E/journey retry lines added: 0

Coverage risk: neutral or increased

No coverage-reduction warnings detected.

@bill-ph bill-ph left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed the peer-path changes, including the cache-proxy suite and targeted race tests. The tests pass, but I found one high-priority background-work/resource issue and two hedge correctness/observability issues that are not covered by the new tests.

Comment thread cmd/cache-proxy/block_serve.go Outdated
}
f := &peerFill{done: make(chan struct{})}
fills[idx] = f
go func() {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] This launches one goroutine per missing block; the semaphore only parks excess goroutines and is scoped per request. Because blockCount is bounded only by cache capacity (not maxSpanBlocks), a wide accepted range can retain a very large number of goroutines. Queued jobs also acquire slots after the 400 ms deadline and still call FetchFromPeers for up to 30 seconds even after the origin hedge has populated those keys. Please use a bounded/global worker queue or acquire before spawning, cancel expired jobs, and recheck store.Has(key) after acquiring. A >8-block test should assert no peer GETs start after the deadline.

Comment thread cmd/cache-proxy/block_serve.go Outdated
_, _, ok := p.peers.FetchFromPeers(key, func(rd io.Reader) (int64, error) {
return p.store.PutStream(key, rd)
})
filled, hedged := waitFill(f, fillDeadline)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] After this times out, the peer fill keeps running but its completion is no longer considered by the request. If the peer lands the block at 450 ms and the origin hedge fails at 500 ms, flushRun returns the origin error even though the requested block is valid locally (pre-PR, the peer would have served it). Before propagating an origin error, recheck the run's block keys/fill handles so either successful side of the hedge can satisfy the request.

Comment thread cmd/cache-proxy/block_serve.go Outdated
case <-timer.C:
}
}
peerFillHedgedTotal.Inc()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] This increments cache_proxy_peer_fill_hedged_total as soon as the deadline wins, but the caller subsequently checks store.Has and may avoid origin entirely if the peer or another request landed the block. That makes this counter and blocks_hedged overstate the documented “blocks fetched from origin” signal. Increment when the block is actually added to an origin miss run, or rename/document the metric as peer-budget expiration.

@EDsCODE EDsCODE changed the title perf(cache-proxy): parallel peer fills, origin hedge, tighter probe timeout perf(cache-proxy): bound, hedge, and adapt peer fetches Aug 10, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants