Skip to main content

kache_core/
lib.rs

1#[cfg(feature = "planning")]
2use std::collections::{HashMap, HashSet};
3
4#[cfg(feature = "planning")]
5use anyhow::Result;
6#[cfg(feature = "planning")]
7use async_trait::async_trait;
8use serde::{Deserialize, Serialize};
9
10#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
11pub struct BuildIntent {
12    #[serde(default)]
13    pub crate_names: Vec<String>,
14    #[serde(default)]
15    pub namespace: Option<String>,
16    #[serde(default)]
17    pub cargo_lock_deps: Vec<(String, String)>,
18}
19
20#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
21pub struct PrefetchCandidate {
22    pub cache_key: String,
23    pub crate_name: String,
24}
25
26#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
27#[serde(rename_all = "snake_case")]
28pub enum PrefetchDisposition {
29    Execute,
30    UseFallback,
31    DoNothing,
32}
33
34#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
35pub struct PrefetchPlan {
36    #[serde(default)]
37    pub plan_id: Option<String>,
38    #[serde(default)]
39    pub planner: Option<String>,
40    pub disposition: PrefetchDisposition,
41    #[serde(default)]
42    pub candidates: Vec<PrefetchCandidate>,
43}
44
45#[cfg(feature = "planning")]
46#[async_trait]
47pub trait PlannerDataSource {
48    async fn shard_candidates(
49        &self,
50        namespace: &str,
51        deps: &[(String, String)],
52    ) -> Result<Vec<PrefetchCandidate>>;
53
54    async fn history_candidates(&self, crate_names: &[String]) -> Result<Vec<PrefetchCandidate>>;
55
56    async fn key_cache_keys_for_crate(&self, crate_name: &str) -> Result<Vec<String>>;
57}
58
59#[cfg(feature = "planning")]
60pub async fn build_prefetch_plan<T>(
61    source: &T,
62    intent: &BuildIntent,
63    planner_name: &str,
64) -> Result<PrefetchPlan>
65where
66    T: PlannerDataSource + Sync + ?Sized,
67{
68    let crate_order = crate_query_order(intent);
69    let mut seen = HashSet::new();
70    let mut resolved_crates = HashSet::new();
71    let mut candidates = Vec::new();
72
73    // Sources are merged in descending order of confidence, each one filling
74    // only the crates the ones before it left unresolved. Shard lookups used
75    // to RETURN as soon as they produced anything (kunobi-ninja/kache#614),
76    // but a shard hit is exact per bucket: one dependency bump invalidates one
77    // of `NUM_SHARDS` buckets while the rest still match, so a single matching
78    // shard was enough to short-circuit history and key-cache recovery for
79    // every crate in every bucket that missed.
80    if let Some(namespace) = intent.namespace.as_deref()
81        && !intent.cargo_lock_deps.is_empty()
82    {
83        // A failed shard lookup is not fatal: the sources below still run.
84        if let Ok(shard_candidates) = source
85            .shard_candidates(namespace, &intent.cargo_lock_deps)
86            .await
87        {
88            for candidate in order_candidates_by_crate_order(shard_candidates, intent) {
89                resolved_crates.insert(candidate.crate_name.clone());
90                if seen.insert(candidate.cache_key.clone()) {
91                    candidates.push(candidate);
92                }
93            }
94        }
95    }
96
97    let unresolved = |resolved: &HashSet<String>| -> Vec<String> {
98        crate_order
99            .iter()
100            .filter(|name| !resolved.contains(*name))
101            .cloned()
102            .collect()
103    };
104
105    let history_query = unresolved(&resolved_crates);
106    if !history_query.is_empty() {
107        for candidate in order_candidates_by_crate_order(
108            source.history_candidates(&history_query).await?,
109            intent,
110        ) {
111            resolved_crates.insert(candidate.crate_name.clone());
112            if seen.insert(candidate.cache_key.clone()) {
113                candidates.push(candidate);
114            }
115        }
116    }
117
118    for crate_name in unresolved(&resolved_crates) {
119        for cache_key in source.key_cache_keys_for_crate(&crate_name).await? {
120            if seen.insert(cache_key.clone()) {
121                candidates.push(PrefetchCandidate {
122                    cache_key,
123                    crate_name: crate_name.clone(),
124                });
125            }
126        }
127    }
128
129    Ok(execute_plan(planner_name, candidates))
130}
131
132#[cfg(feature = "planning")]
133fn execute_plan(planner_name: &str, candidates: Vec<PrefetchCandidate>) -> PrefetchPlan {
134    let planner = planner_name.trim();
135    PrefetchPlan {
136        plan_id: None,
137        planner: Some(if planner.is_empty() {
138            "planner".to_string()
139        } else {
140            planner.to_string()
141        }),
142        disposition: PrefetchDisposition::Execute,
143        candidates,
144    }
145}
146
147#[cfg(feature = "planning")]
148fn crate_query_order(intent: &BuildIntent) -> Vec<String> {
149    let mut seen = HashSet::new();
150    intent
151        .crate_names
152        .iter()
153        .filter(|crate_name| seen.insert((*crate_name).clone()))
154        .cloned()
155        .collect()
156}
157
158#[cfg(feature = "planning")]
159fn order_candidates_by_crate_order(
160    mut candidates: Vec<PrefetchCandidate>,
161    intent: &BuildIntent,
162) -> Vec<PrefetchCandidate> {
163    if intent.crate_names.is_empty() {
164        return candidates;
165    }
166
167    let mut priority = HashMap::new();
168    for (index, crate_name) in crate_query_order(intent).iter().enumerate() {
169        priority.entry(crate_name.clone()).or_insert(index);
170    }
171
172    let mut indexed_candidates = candidates.drain(..).enumerate().collect::<Vec<_>>();
173    indexed_candidates.sort_by_key(|(index, candidate)| {
174        (
175            priority
176                .get(&candidate.crate_name)
177                .copied()
178                .unwrap_or(usize::MAX),
179            *index,
180        )
181    });
182    indexed_candidates
183        .into_iter()
184        .map(|(_, candidate)| candidate)
185        .collect()
186}
187
188#[cfg(test)]
189mod tests {
190    use super::*;
191
192    #[cfg(feature = "planning")]
193    use std::collections::HashMap;
194
195    #[cfg(feature = "planning")]
196    use anyhow::anyhow;
197
198    #[cfg(feature = "planning")]
199    #[derive(Default)]
200    struct FakePlannerDataSource {
201        shard_candidates: Vec<PrefetchCandidate>,
202        shard_error: bool,
203        history_candidates: Vec<PrefetchCandidate>,
204        history_by_crate: HashMap<String, String>,
205        key_cache: HashMap<String, Vec<String>>,
206    }
207
208    #[cfg(feature = "planning")]
209    #[async_trait]
210    impl PlannerDataSource for FakePlannerDataSource {
211        async fn shard_candidates(
212            &self,
213            _namespace: &str,
214            _deps: &[(String, String)],
215        ) -> Result<Vec<PrefetchCandidate>> {
216            if self.shard_error {
217                Err(anyhow!("shard lookup failed"))
218            } else {
219                Ok(self.shard_candidates.clone())
220            }
221        }
222
223        async fn history_candidates(
224            &self,
225            crate_names: &[String],
226        ) -> Result<Vec<PrefetchCandidate>> {
227            if !self.history_candidates.is_empty() {
228                return Ok(self.history_candidates.clone());
229            }
230
231            Ok(crate_names
232                .iter()
233                .filter_map(|crate_name| {
234                    self.history_by_crate
235                        .get(crate_name)
236                        .map(|cache_key| PrefetchCandidate {
237                            cache_key: cache_key.clone(),
238                            crate_name: crate_name.clone(),
239                        })
240                })
241                .collect())
242        }
243
244        async fn key_cache_keys_for_crate(&self, crate_name: &str) -> Result<Vec<String>> {
245            Ok(self.key_cache.get(crate_name).cloned().unwrap_or_default())
246        }
247    }
248
249    #[test]
250    fn test_build_intent_serde_roundtrip() {
251        let intent = BuildIntent {
252            crate_names: vec!["serde".into(), "tokio".into()],
253            namespace: Some("x86_64/hash/release".into()),
254            cargo_lock_deps: vec![("serde".into(), "1.0.0".into())],
255        };
256
257        let json = serde_json::to_string(&intent).unwrap();
258        let parsed: BuildIntent = serde_json::from_str(&json).unwrap();
259        assert_eq!(parsed, intent);
260    }
261
262    #[test]
263    fn test_build_intent_defaults_missing_fields() {
264        let parsed: BuildIntent = serde_json::from_str(r#"{"crate_names":["serde"]}"#).unwrap();
265        assert_eq!(parsed.crate_names, vec!["serde"]);
266        assert!(parsed.namespace.is_none());
267        assert!(parsed.cargo_lock_deps.is_empty());
268    }
269
270    #[test]
271    fn test_prefetch_plan_serde_roundtrip() {
272        let plan = PrefetchPlan {
273            plan_id: Some("plan-1".into()),
274            planner: Some("local".into()),
275            disposition: PrefetchDisposition::Execute,
276            candidates: vec![PrefetchCandidate {
277                cache_key: "abc".into(),
278                crate_name: "serde".into(),
279            }],
280        };
281
282        let json = serde_json::to_string(&plan).unwrap();
283        let parsed: PrefetchPlan = serde_json::from_str(&json).unwrap();
284        assert_eq!(parsed, plan);
285    }
286
287    #[test]
288    fn test_prefetch_plan_missing_disposition_is_rejected() {
289        let err = serde_json::from_str::<PrefetchPlan>(
290            r#"{"planner":"legacy","candidates":[{"cache_key":"abc","crate_name":"serde"}]}"#,
291        )
292        .unwrap_err();
293        assert!(err.to_string().contains("missing field"));
294    }
295
296    #[test]
297    fn test_prefetch_plan_do_nothing_roundtrip() {
298        let plan = PrefetchPlan {
299            plan_id: Some("plan-2".into()),
300            planner: Some("remote".into()),
301            disposition: PrefetchDisposition::DoNothing,
302            candidates: vec![],
303        };
304
305        let json = serde_json::to_string(&plan).unwrap();
306        let parsed: PrefetchPlan = serde_json::from_str(&json).unwrap();
307        assert_eq!(parsed, plan);
308    }
309
310    #[cfg(feature = "planning")]
311    #[tokio::test]
312    async fn test_build_prefetch_plan_prefers_shard_candidates() {
313        let source = FakePlannerDataSource {
314            shard_candidates: vec![PrefetchCandidate {
315                cache_key: "from-shard".into(),
316                crate_name: "serde".into(),
317            }],
318            ..Default::default()
319        };
320        let intent = BuildIntent {
321            crate_names: vec!["serde".into()],
322            namespace: Some("linux/hash/release".into()),
323            cargo_lock_deps: vec![("serde".into(), "1.0.0".into())],
324        };
325
326        let plan = build_prefetch_plan(&source, &intent, "fallback")
327            .await
328            .unwrap();
329
330        assert_eq!(plan.disposition, PrefetchDisposition::Execute);
331        assert_eq!(plan.planner.as_deref(), Some("fallback"));
332        assert_eq!(plan.candidates.len(), 1);
333        assert_eq!(plan.candidates[0].cache_key, "from-shard");
334    }
335
336    #[cfg(feature = "planning")]
337    #[tokio::test]
338    async fn test_build_prefetch_plan_falls_back_to_history_and_key_cache() {
339        let mut source = FakePlannerDataSource {
340            shard_error: true,
341            history_candidates: vec![PrefetchCandidate {
342                cache_key: "history-key".into(),
343                crate_name: "serde".into(),
344            }],
345            ..Default::default()
346        };
347        source.key_cache.insert(
348            "tokio".into(),
349            vec!["tokio-key".into(), "history-key".into()],
350        );
351
352        let intent = BuildIntent {
353            crate_names: vec!["serde".into(), "tokio".into()],
354            namespace: Some("linux/hash/debug".into()),
355            cargo_lock_deps: vec![("serde".into(), "1.0.0".into())],
356        };
357
358        let plan = build_prefetch_plan(&source, &intent, "fallback")
359            .await
360            .unwrap();
361
362        assert_eq!(plan.disposition, PrefetchDisposition::Execute);
363        assert_eq!(plan.candidates.len(), 2);
364        assert_eq!(plan.candidates[0].cache_key, "history-key");
365        assert_eq!(plan.candidates[1].cache_key, "tokio-key");
366    }
367
368    #[cfg(feature = "planning")]
369    #[tokio::test]
370    async fn test_build_prefetch_plan_orders_shard_candidates_by_crate_order() {
371        let source = FakePlannerDataSource {
372            shard_candidates: vec![
373                PrefetchCandidate {
374                    cache_key: "app-key".into(),
375                    crate_name: "app".into(),
376                },
377                PrefetchCandidate {
378                    cache_key: "dep-key".into(),
379                    crate_name: "dep".into(),
380                },
381                PrefetchCandidate {
382                    cache_key: "middle-key".into(),
383                    crate_name: "middle".into(),
384                },
385            ],
386            ..Default::default()
387        };
388        let intent = BuildIntent {
389            crate_names: vec!["dep".into(), "middle".into(), "app".into()],
390            namespace: Some("linux/hash/debug".into()),
391            cargo_lock_deps: vec![("dep".into(), "1.0.0".into())],
392        };
393
394        let plan = build_prefetch_plan(&source, &intent, "fallback")
395            .await
396            .unwrap();
397
398        let keys = plan
399            .candidates
400            .iter()
401            .map(|candidate| candidate.cache_key.as_str())
402            .collect::<Vec<_>>();
403        assert_eq!(keys, vec!["dep-key", "middle-key", "app-key"]);
404    }
405
406    /// A PARTIAL shard hit must not suppress the lower-confidence sources
407    /// (kunobi-ninja/kache#614).
408    ///
409    /// Shard matching is exact per bucket, so a dependency bump invalidates
410    /// one bucket while the rest still match. The planner used to return as
411    /// soon as shards produced anything, so the crates in the missed buckets
412    /// were dropped from the plan even though history and the key cache could
413    /// resolve them.
414    #[cfg(feature = "planning")]
415    #[tokio::test]
416    async fn test_build_prefetch_plan_fills_crates_a_partial_shard_hit_missed() {
417        let mut source = FakePlannerDataSource {
418            // Only `dep` is in a bucket that still matches.
419            shard_candidates: vec![PrefetchCandidate {
420                cache_key: "dep-shard-key".into(),
421                crate_name: "dep".into(),
422            }],
423            history_by_crate: HashMap::from([("middle".into(), "middle-history-key".into())]),
424            ..Default::default()
425        };
426        source
427            .key_cache
428            .insert("app".into(), vec!["app-key-cache-key".into()]);
429
430        let intent = BuildIntent {
431            crate_names: vec!["dep".into(), "middle".into(), "app".into()],
432            namespace: Some("linux/hash/debug".into()),
433            cargo_lock_deps: vec![("dep".into(), "1.0.0".into())],
434        };
435
436        let plan = build_prefetch_plan(&source, &intent, "fallback")
437            .await
438            .unwrap();
439
440        let keys = plan
441            .candidates
442            .iter()
443            .map(|candidate| candidate.cache_key.as_str())
444            .collect::<Vec<_>>();
445        assert_eq!(
446            keys,
447            vec!["dep-shard-key", "middle-history-key", "app-key-cache-key"],
448            "each source should fill the crates the higher-confidence ones left unresolved"
449        );
450    }
451
452    /// A crate the shards already resolved is not re-queried from the
453    /// lower-confidence sources (#614): shard keys are exact, history and the
454    /// key cache are not, so they only fill gaps.
455    #[cfg(feature = "planning")]
456    #[tokio::test]
457    async fn test_build_prefetch_plan_does_not_requery_shard_resolved_crates() {
458        let mut source = FakePlannerDataSource {
459            shard_candidates: vec![PrefetchCandidate {
460                cache_key: "serde-shard-key".into(),
461                crate_name: "serde".into(),
462            }],
463            history_by_crate: HashMap::from([("serde".into(), "serde-stale-history-key".into())]),
464            ..Default::default()
465        };
466        source
467            .key_cache
468            .insert("serde".into(), vec!["serde-stale-key-cache-key".into()]);
469
470        let intent = BuildIntent {
471            crate_names: vec!["serde".into()],
472            namespace: Some("linux/hash/debug".into()),
473            cargo_lock_deps: vec![("serde".into(), "1.0.0".into())],
474        };
475
476        let plan = build_prefetch_plan(&source, &intent, "fallback")
477            .await
478            .unwrap();
479
480        let keys = plan
481            .candidates
482            .iter()
483            .map(|candidate| candidate.cache_key.as_str())
484            .collect::<Vec<_>>();
485        assert_eq!(keys, vec!["serde-shard-key"]);
486    }
487
488    #[cfg(feature = "planning")]
489    #[tokio::test]
490    async fn test_build_prefetch_plan_queries_history_by_crate_order() {
491        let source = FakePlannerDataSource {
492            history_by_crate: HashMap::from([
493                ("app".into(), "app-key".into()),
494                ("dep".into(), "dep-key".into()),
495                ("middle".into(), "middle-key".into()),
496            ]),
497            ..Default::default()
498        };
499        let intent = BuildIntent {
500            crate_names: vec!["dep".into(), "middle".into(), "app".into()],
501            namespace: None,
502            cargo_lock_deps: vec![],
503        };
504
505        let plan = build_prefetch_plan(&source, &intent, "fallback")
506            .await
507            .unwrap();
508
509        let keys = plan
510            .candidates
511            .iter()
512            .map(|candidate| candidate.cache_key.as_str())
513            .collect::<Vec<_>>();
514        assert_eq!(keys, vec!["dep-key", "middle-key", "app-key"]);
515    }
516}