Skip to main content

gen_cache_attic/
lib.rs

1//! `gen-cache-attic` — typed cache-probe layer for the gen engine.
2//!
3//! The engine consults a [`CacheClient`] for every derivation before
4//! triggering a build. A hit short-circuits the rebuild; a miss flows
5//! into the local builder. The trait is the swap-in seam: today's
6//! prime impl is [`NoCacheClient`] (always reports miss); the
7//! production impl is [`AtticClient`] (HEAD-probes the substituter
8//! URL). Future impls (cachix, R2, S3) drop in without touching the
9//! engine.
10
11use std::time::Duration;
12
13use indexmap::IndexMap;
14use serde::{Deserialize, Serialize};
15use thiserror::Error;
16
17use gen_config::CacheConfig;
18use gen_types::ContentHash;
19
20/// Typed cache key. Engines compute the input-addressed hash of the
21/// derivation (typed `Manifest` + adapter + target + features) into a
22/// [`ContentHash`] and consult the cache with that key. The key is
23/// what makes the cache content-addressed: same inputs → same key →
24/// same answer fleet-wide.
25#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
26pub struct CacheKey {
27    pub hash: ContentHash,
28    /// Stable identifier (`<adapter>/<package>/<version>`) for
29    /// diagnostics; not part of the hash, never load-bearing.
30    pub label: String,
31}
32
33impl CacheKey {
34    #[must_use]
35    pub fn new(hash: ContentHash, label: impl Into<String>) -> Self {
36        Self {
37            hash,
38            label: label.into(),
39        }
40    }
41
42    /// Stable store-path-like serialization: `/<hash-hex>-<label>`.
43    #[must_use]
44    pub fn store_path(&self) -> String {
45        format!("/{}-{}", self.hash.hex(), self.label)
46    }
47}
48
49/// Outcome of a cache probe. Either a hit (substituter has the
50/// derivation) or a miss (engine triggers the local builder).
51#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
52pub enum CacheOutcome {
53    Hit {
54        substituter_url: String,
55        /// Optional content size from a HEAD response — useful for
56        /// "should we even download this?" decisions on bandwidth-
57        /// constrained agents.
58        size_bytes: Option<u64>,
59    },
60    Miss {
61        /// Stable miss reason for telemetry. Distinguishes "every
62        /// substituter probed, none had it" from "no substituters
63        /// configured" from "probe failed transport-level".
64        reason: MissReason,
65    },
66}
67
68#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
69#[serde(tag = "kind", rename_all = "kebab-case")]
70pub enum MissReason {
71    /// At least one substituter was probed; none reported the key.
72    AllSubstitutersMissing,
73    /// Cache lookup was disabled in config (always-build mode).
74    AlwaysBuildOverride,
75    /// No substituters configured.
76    NoSubstituters,
77    /// Probe failed with a transport-level error (DNS, TCP, TLS, …).
78    /// Engine treats this as a miss + triggers a local build.
79    TransportError { substituter_url: String, detail: String },
80}
81
82/// Aggregate report — one probe per supplied key.
83#[derive(Clone, Debug, Default, Serialize, Deserialize)]
84pub struct CacheReport {
85    pub outcomes: IndexMap<String, CacheOutcome>,
86}
87
88impl CacheReport {
89    #[must_use]
90    pub fn hit_count(&self) -> usize {
91        self.outcomes
92            .values()
93            .filter(|o| matches!(o, CacheOutcome::Hit { .. }))
94            .count()
95    }
96
97    #[must_use]
98    pub fn miss_count(&self) -> usize {
99        self.outcomes
100            .values()
101            .filter(|o| matches!(o, CacheOutcome::Miss { .. }))
102            .count()
103    }
104
105    #[must_use]
106    pub fn hit_rate(&self) -> f64 {
107        let total = self.outcomes.len();
108        if total == 0 {
109            return 0.0;
110        }
111        self.hit_count() as f64 / total as f64
112    }
113}
114
115/// Typed cache client. New backends (cachix, R2, S3, IPFS, …)
116/// implement this trait + plug into the engine via [`gen_config`].
117pub trait CacheClient {
118    /// Probe a single cache key. Implementations MUST NOT panic;
119    /// transport-level errors land as `Miss { TransportError }`.
120    fn probe(&self, key: &CacheKey) -> CacheOutcome;
121
122    /// Probe a batch of keys. Default impl loops `probe` — override
123    /// for parallel/HTTP-pipelined implementations.
124    fn probe_batch(&self, keys: &[CacheKey]) -> CacheReport {
125        let mut report = CacheReport::default();
126        for k in keys {
127            let outcome = self.probe(k);
128            report.outcomes.insert(k.label.clone(), outcome);
129        }
130        report
131    }
132}
133
134/// No-cache default. Always reports miss. Used when no substituter is
135/// configured OR when `cache.always_build = true`.
136#[derive(Clone, Copy, Debug, Default)]
137pub struct NoCacheClient;
138
139impl CacheClient for NoCacheClient {
140    fn probe(&self, _key: &CacheKey) -> CacheOutcome {
141        CacheOutcome::Miss {
142            reason: MissReason::NoSubstituters,
143        }
144    }
145}
146
147/// Production attic client. Builds an [`CacheClient::probe`] by issuing
148/// a HEAD against `<substituter>/<hash>.narinfo` for each configured
149/// substituter; first 200-OK wins.
150///
151/// Today this is a typed stub — the HTTP runtime lives in
152/// [`AtticClient::probe`] as a `MissReason::TransportError` when no
153/// transport feature is enabled. The real reqwest-backed impl plugs
154/// in behind the `http` feature; the engine never knows.
155#[derive(Clone, Debug)]
156pub struct AtticClient {
157    pub substituters: Vec<String>,
158    pub trusted_public_keys: Vec<String>,
159    pub timeout: Duration,
160}
161
162impl AtticClient {
163    #[must_use]
164    pub fn from_config(cfg: &CacheConfig) -> Self {
165        Self {
166            substituters: cfg.substituters.clone(),
167            trusted_public_keys: cfg.trusted_public_keys.clone(),
168            timeout: Duration::from_secs(5),
169        }
170    }
171
172    fn probe_url(substituter: &str, key: &CacheKey) -> String {
173        // Standard nix substituter shape: <base>/<hash>.narinfo
174        format!(
175            "{}/{}.narinfo",
176            substituter.trim_end_matches('/'),
177            key.hash.hex()
178        )
179    }
180}
181
182impl CacheClient for AtticClient {
183    fn probe(&self, key: &CacheKey) -> CacheOutcome {
184        if self.substituters.is_empty() {
185            return CacheOutcome::Miss {
186                reason: MissReason::NoSubstituters,
187            };
188        }
189        // No HTTP runtime in this base build — every probe reports
190        // transport-error miss against the first configured
191        // substituter. Real HTTP lives behind the `http` feature
192        // (M0.5+ wiring).
193        let url = Self::probe_url(&self.substituters[0], key);
194        CacheOutcome::Miss {
195            reason: MissReason::TransportError {
196                substituter_url: url,
197                detail: "no HTTP runtime in base build; enable the `http` feature".to_string(),
198            },
199        }
200    }
201}
202
203/// Convenience constructor — selects [`NoCacheClient`] or
204/// [`AtticClient`] based on a typed [`CacheConfig`]. Engines call this
205/// once at startup + cache the boxed result.
206#[must_use]
207pub fn client_from_config(cfg: &CacheConfig) -> Box<dyn CacheClient + Send + Sync> {
208    if cfg.always_build || cfg.substituters.is_empty() {
209        Box::new(NoCacheClient)
210    } else {
211        Box::new(AtticClient::from_config(cfg))
212    }
213}
214
215#[derive(Debug, Error)]
216pub enum CacheError {
217    #[error("invalid substituter URL `{url}`: {detail}")]
218    InvalidSubstituter { url: String, detail: String },
219}
220
221#[cfg(test)]
222mod tests {
223    use super::*;
224    use gen_types::ContentHash;
225
226    fn k(label: &str) -> CacheKey {
227        CacheKey::new(ContentHash::of(label.as_bytes()), label)
228    }
229
230    #[test]
231    fn no_cache_client_always_reports_miss() {
232        let c = NoCacheClient;
233        let outcome = c.probe(&k("foo"));
234        assert!(matches!(
235            outcome,
236            CacheOutcome::Miss {
237                reason: MissReason::NoSubstituters
238            }
239        ));
240    }
241
242    #[test]
243    fn attic_client_with_no_substituters_misses() {
244        let c = AtticClient {
245            substituters: vec![],
246            trusted_public_keys: vec![],
247            timeout: Duration::from_secs(1),
248        };
249        assert!(matches!(
250            c.probe(&k("foo")),
251            CacheOutcome::Miss {
252                reason: MissReason::NoSubstituters
253            }
254        ));
255    }
256
257    #[test]
258    fn attic_client_without_http_reports_transport_error() {
259        let c = AtticClient {
260            substituters: vec!["https://cache.example.org".to_string()],
261            trusted_public_keys: vec![],
262            timeout: Duration::from_secs(1),
263        };
264        let outcome = c.probe(&k("foo"));
265        match outcome {
266            CacheOutcome::Miss {
267                reason: MissReason::TransportError { substituter_url, .. },
268            } => {
269                assert!(substituter_url.contains("cache.example.org"));
270                assert!(substituter_url.ends_with(".narinfo"));
271            }
272            other => panic!("expected TransportError miss, got {other:?}"),
273        }
274    }
275
276    #[test]
277    fn probe_url_is_substituter_plus_hash_narinfo() {
278        let key = k("demo");
279        let url = AtticClient::probe_url("https://cache.example.org/foo", &key);
280        assert_eq!(url, format!("https://cache.example.org/foo/{}.narinfo", key.hash.hex()));
281    }
282
283    #[test]
284    fn probe_url_strips_trailing_slash() {
285        let key = k("demo");
286        let url = AtticClient::probe_url("https://cache.example.org/", &key);
287        let after_scheme = url.strip_prefix("https://").unwrap();
288        assert!(!after_scheme.contains("//"), "host+path should not contain `//`: {after_scheme}");
289    }
290
291    #[test]
292    fn batch_probe_returns_one_outcome_per_key() {
293        let c = NoCacheClient;
294        let keys = vec![k("a"), k("b"), k("c")];
295        let report = c.probe_batch(&keys);
296        assert_eq!(report.outcomes.len(), 3);
297        assert_eq!(report.miss_count(), 3);
298        assert_eq!(report.hit_count(), 0);
299        assert_eq!(report.hit_rate(), 0.0);
300    }
301
302    #[test]
303    fn cache_key_store_path_is_stable() {
304        let key = CacheKey::new(ContentHash::of(b"x"), "demo");
305        let p = key.store_path();
306        assert!(p.starts_with("/"));
307        assert!(p.ends_with("-demo"));
308    }
309
310    #[test]
311    fn client_from_config_selects_no_cache_when_substituters_empty() {
312        let cfg = CacheConfig {
313            substituters: vec![],
314            trusted_public_keys: vec![],
315            always_build: false,
316        };
317        let c = client_from_config(&cfg);
318        assert!(matches!(c.probe(&k("foo")), CacheOutcome::Miss { .. }));
319    }
320
321    #[test]
322    fn client_from_config_respects_always_build_override() {
323        let cfg = CacheConfig {
324            substituters: vec!["https://x".to_string()],
325            trusted_public_keys: vec![],
326            always_build: true,
327        };
328        let c = client_from_config(&cfg);
329        // NoCacheClient → NoSubstituters reason.
330        let r = c.probe(&k("foo"));
331        match r {
332            CacheOutcome::Miss {
333                reason: MissReason::NoSubstituters,
334            } => (),
335            other => panic!("expected NoSubstituters miss, got {other:?}"),
336        }
337    }
338
339    #[test]
340    fn hit_rate_computes_for_mixed_outcomes() {
341        let mut report = CacheReport::default();
342        report.outcomes.insert(
343            "a".to_string(),
344            CacheOutcome::Hit {
345                substituter_url: "x".into(),
346                size_bytes: None,
347            },
348        );
349        report.outcomes.insert(
350            "b".to_string(),
351            CacheOutcome::Miss {
352                reason: MissReason::NoSubstituters,
353            },
354        );
355        assert!((report.hit_rate() - 0.5).abs() < 1e-9);
356    }
357}