The pull-through proxy cache — design
Class:
design· Function:proxy(aregistrybackend thatusesstoragefor write-back + an upstream). A full-variant capability — it links egress crates, so it is absent from the airgap/static profile. Whole-system view:design.md.
What it is
ProxyBackend (server/lib/src/proxy.rs) is a repository backend that, on a request, tries its primary backend first, then each configured upstream in order, and write-through-caches a hit back into the writable primary. So a local miss transparently fills from upstream and subsequent requests hit the local copy. Because it is just another Arc<dyn RepositoryBackendTrait>, it dispatches through FastRoutes like every other repo — the serve path doesn't know it's a proxy.
Pluggable upstreams — RemoteUpstream
The upstream is a seam, not a hardcoded crates.io shape. RemoteUpstream (traits/src/lib.rs) is a read-oriented backend carrying an upstream base URL + the shared UpstreamAuth enum. Three concrete impls:
| impl | targets | source |
RustRegistryUpstream |
crates.io-shaped sparse registries | server/lib/src/upstream/ |
NexusUpstream |
Sonatype Nexus | server/lib/src/upstream/nexus.rs |
ArtifactoryUpstream |
JFrog Artifactory | server/lib/src/upstream/artifactory.rs |
So holger can target holger / Nexus / Artifactory interchangeably as a cache backend.
Wiring
Wired through the nexus-remote / artifactory-remote repository types in backend_from_config() (server/lib/src/repository.rs), configured with ron_base_url (the upstream URL) + ron_upstream_repo:
Repository(
ron_name: "crates-cache",
ron_repo_type: "nexus-remote",
ron_base_url: "https://nexus.corp",
ron_upstream_repo: "cargo-proxy",
ron_out: Some(RepositoryIO( ron_storage_endpoint: "znippy", ron_exposed_endpoint: "default" )),
)
UpstreamAuth (shared with the connectors' spirit) carries the per-upstream credential; the SSRF guards on the agent connectors apply to the upstream fetch discipline too.
Concurrency note
The write-through fill calls the writable-znippy put, which holds a per-backend write Mutex across the check-then-mutate section — so a cache-miss fill racing a direct put can't corrupt the single .znippy (see storage-design).
Negative cache — remembering a miss (parity §2)
The positive path caches hits; the negative cache caches misses. When a coordinate (or a GET/HEAD path) misses the primary and every upstream, ProxyBackend remembers it as absent for a TTL, so a repeated request for a not-yet-published artifact short-circuits the (potentially slow / network) upstream walk instead of re-paying it every time — the Nexus/Artifactory "negative cache".
Config. Opt-in per repo via ron_negative_cache_secs (seconds). Omitted or 0 ⇒ disabled, and every miss re-queries the upstreams exactly as before (additive, no regression — the constructor default is Duration::ZERO; a builder with_negative_cache_secs turns it on).
Repository(
ron_name: "crates-cache",
ron_repo_type: "rust",
ron_writable_archive: Some("{d}/cache.znippy"),
ron_upstreams: ["crates-io"],
ron_negative_cache_secs: Some(30), // remember a miss for 30s
ron_out: Some(RepositoryIO( ron_storage_endpoint: "znippy", ron_exposed_endpoint: "default" )),
)
Invariants.
- Primary is always checked first, before the negative cache is even consulted — so a later local publish (a
putinto the primary) is served immediately and is never shadowed by a stale cached miss. The only bounded staleness is a publish that lands upstream during a live TTL window; that resolves on the next re-walk after expiry. - Reads only. Only
GET/HEAD404s are memoized on the HTTP path (a write's 404 is not a stable "absent" fact);fetchmisses are keyed by full coordinate. Fetch keys and HTTP keys live in disjoint namespaces (F\0…/H\0…) so they can never alias. - Bounded memory. Entries carry an expiry
Instant; expired ones are evicted lazily on read and pruned on insert, and the map is capped atNEG_CAP(4096) live entries — a coordinate-scanning probe degrades the cache to plain re-querying rather than leaking memory. - An erroring upstream is skipped, never fatal, and never memoized. "Tries each upstream in order, returning the first hit" is the whole contract, so an upstream that returns
Err(connection refused, DNS failure, TLS error, a non-404 status) is logged atwarn, counted asresult="upstream_error"on/metrics, and the walk continues to the next member. One unreachable upstream must not blind the server to the reachable ones — a repo withron_upstreams: ["crates-io", "local-mirror"]keeps serving everythinglocal-mirrorholds while crates.io is down. If no upstream hits and at least one errored, the coordinate is unknown rather than known-absent: the error is surfaced and the miss is not written to the negative cache, because memoizing a transient network fault would keep returning 404 for the whole TTL after the upstream recovered. Only a walk in which every upstream cleanly missed is remembered.
Tier note. Purely in-process (std only, no new deps, no egress); it lives in the always-compiled proxy.rs but is dormant unless a repo declares upstreams — so it changes nothing about the airgap/mini/embedded tiers' zero-egress guarantee.
Observability (L4). A negative-cache short-circuit emits a #[cfg(feature="testmatrix")] functional_status("holger-proxy/negcache", …) cell on both the fetch and HTTP paths. Five unit tests, incl. two RED-when-broken counters (a repeated miss must NOT re-walk a call-counting upstream) + the never-shadow-a-later-primary-hit guard
- deterministic expiry (a planted past-expiry entry reads as a miss and re-walks).