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        let mut check = ci_config::Check::new("build", vec!["true".to_string()]);
69        check.timeout_secs = 1;
70        check.supersede = false;
71        CacheKey::derive(
72            &std::collections::BTreeMap::new(),
73            &ExecutionContext {
74                repo: "test/repo".to_string(),
75                state: StateRef {
76                    content_hash: "state".to_string(),
77                    change_id: "change".to_string(),
78                    logical_change_id: None,
79                },
80                basis: Basis {
81                    kind: BasisKind::Branch,
82                    evaluated_tree_digest: "tree".to_string(),
83                },
84                definition_digest: "definition".to_string(),
85                toolchain: None,
86                pick_id: None,
87                attempt: 1,
88                runner: None,
89                image_digest: None,
90            },
91            &check,
92        )
93    }
94
95    #[test]
96    fn fraction_is_deterministic_for_a_key() {
97        let key = key();
98        let policy = SpotCheck::Fraction {
99            numerator: 1,
100            denominator: 2,
101        };
102        let first = policy.should_sample(&key, "build");
103        assert_eq!(first, policy.should_sample(&key, "build"));
104        assert!(SpotCheck::Always.should_sample(&key, "build"));
105        assert!(!SpotCheck::Never.should_sample(&key, "build"));
106    }
107}