Skip to main content

ci_engine/result_cache/
spot_check.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Deterministic sampling of cache hits for fail-closed re-execution.
3
4use super::key::{CacheKey, entry_id_bytes};
5
6/// Policy for re-running a sampled fraction of cache hits.
7///
8/// A disagreement between the cached entry and the fresh run is a hard
9/// error ([`super::ResultCacheError::SpotCheckDivergence`]) — never a
10/// silent fallback to either result.
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub enum SpotCheck {
13    /// Never re-run a cache hit. Tests use this to prove reuse.
14    Never,
15    /// Re-run every cache hit. Tests use this to prove fail-closed.
16    Always,
17    /// Re-run `numerator / denominator` of hits, chosen from the entry id.
18    Fraction {
19        /// Hits sampled per `denominator` keys.
20        numerator: u32,
21        /// Sampling modulus. Zero is treated as never.
22        denominator: u32,
23    },
24}
25
26impl Default for SpotCheck {
27    fn default() -> Self {
28        Self::Fraction {
29            numerator: 1,
30            denominator: 32,
31        }
32    }
33}
34
35impl SpotCheck {
36    /// Whether this policy samples `key` / `check_name`.
37    #[must_use]
38    pub fn should_sample(&self, key: &CacheKey, check_name: &str) -> bool {
39        match *self {
40            Self::Never => false,
41            Self::Always => true,
42            Self::Fraction {
43                numerator,
44                denominator,
45            } => {
46                if denominator == 0 || numerator == 0 {
47                    return false;
48                }
49                if numerator >= denominator {
50                    return true;
51                }
52                let bytes = entry_id_bytes(key, check_name);
53                let bucket = u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]);
54                bucket % denominator < numerator
55            }
56        }
57    }
58}
59
60#[cfg(test)]
61mod tests {
62    use crypto::{Basis, BasisKind, StateRef};
63
64    use super::*;
65    use crate::model::ExecutionContext;
66
67    fn key() -> CacheKey {
68        CacheKey::derive(
69            &std::collections::BTreeMap::new(),
70            &ExecutionContext {
71                repo: "test/repo".to_string(),
72                state: StateRef {
73                    content_hash: "state".to_string(),
74                    change_id: "change".to_string(),
75                    logical_change_id: None,
76                },
77                basis: Basis {
78                    kind: BasisKind::Branch,
79                    evaluated_tree_digest: "tree".to_string(),
80                },
81                definition_digest: "definition".to_string(),
82                toolchain: None,
83                pick_id: None,
84                attempt: 1,
85                runner: None,
86                image_digest: None,
87            },
88            &ci_config::Check {
89                name: "build".to_string(),
90                class: ci_config::CheckClass::Required,
91                command: vec!["true".to_string()],
92                timeout_secs: 1,
93                env: std::collections::BTreeMap::new(),
94                services: Vec::new(),
95                cache_paths: Vec::new(),
96                retry: ci_config::Retry::default(),
97                triggers: Vec::new(),
98                supersede: false,
99                isolation: None,
100            },
101        )
102    }
103
104    #[test]
105    fn fraction_is_deterministic_for_a_key() {
106        let key = key();
107        let policy = SpotCheck::Fraction {
108            numerator: 1,
109            denominator: 2,
110        };
111        let first = policy.should_sample(&key, "build");
112        assert_eq!(first, policy.should_sample(&key, "build"));
113        assert!(SpotCheck::Always.should_sample(&key, "build"));
114        assert!(!SpotCheck::Never.should_sample(&key, "build"));
115    }
116}