Skip to main content

rch_common/
artifact_cost.rs

1//! File-count-aware artifact retrieval cost model and manifest advice
2//! (bd-session-history-remediation-ocv9i.9.2).
3//!
4//! Artifact retrieval that expands a broad recursive glob (`target/**/*`) can
5//! match *tens of thousands* of files — most of them intermediate build cruft —
6//! turning a quick build into a multi-minute rsync. This module measures the
7//! retrieval ([`ArtifactCostReport`]: file count, bytes, files/sec, bytes/sec,
8//! and per-phase wall-clock timings) and, before the transfer, advises whether
9//! a glob is too broad ([`assess_glob_expansion`]) — recommending a manifest or
10//! explicit artifact list when one would suffice. All pure + deterministic.
11
12use serde::{Deserialize, Serialize};
13
14/// File count at/above which a broad recursive glob earns a warning.
15pub const GLOB_WARN_THRESHOLD: u64 = 10_000;
16/// File count at/above which a broad recursive glob is refused in favor of a
17/// manifest / explicit list.
18pub const GLOB_REFUSE_THRESHOLD: u64 = 50_000;
19
20/// Per-phase wall-clock timings for an artifact retrieval, in milliseconds.
21#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
22pub struct ArtifactPhaseTimings {
23    pub collection_ms: u64,
24    pub compression_ms: u64,
25    pub transfer_ms: u64,
26    pub extract_ms: u64,
27}
28
29impl ArtifactPhaseTimings {
30    /// Total wall-clock across all phases.
31    #[must_use]
32    pub const fn total_ms(&self) -> u64 {
33        self.collection_ms
34            .saturating_add(self.compression_ms)
35            .saturating_add(self.transfer_ms)
36            .saturating_add(self.extract_ms)
37    }
38}
39
40/// How retrieval should be performed, given the cost.
41#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
42#[serde(rename_all = "snake_case")]
43pub enum RetrievalMode {
44    /// A glob is fine at this scale.
45    Glob,
46    /// Prefer a manifest of exact files.
47    Manifest,
48}
49
50/// Advice on whether a glob expansion is acceptable.
51#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
52#[serde(rename_all = "snake_case", tag = "advice")]
53pub enum GlobAdvice {
54    /// Proceed with the glob.
55    Proceed,
56    /// Proceed, but the expansion is large — a manifest would be cheaper.
57    Warn {
58        file_count: u64,
59        threshold: u64,
60        suggestion: String,
61    },
62    /// Refuse the glob — far too broad; require a manifest / explicit list.
63    Refuse {
64        file_count: u64,
65        hard_cap: u64,
66        suggestion: String,
67    },
68}
69
70impl GlobAdvice {
71    /// Whether retrieval should not proceed as a glob.
72    #[must_use]
73    pub const fn is_refused(&self) -> bool {
74        matches!(self, Self::Refuse { .. })
75    }
76
77    /// The recommended retrieval mode.
78    #[must_use]
79    pub const fn recommended_mode(&self) -> RetrievalMode {
80        match self {
81            Self::Proceed => RetrievalMode::Glob,
82            Self::Warn { .. } | Self::Refuse { .. } => RetrievalMode::Manifest,
83        }
84    }
85}
86
87/// Heuristic: is this pattern a broad recursive glob (`**` anywhere, or a bare
88/// directory wildcard like `target/*`)? Such patterns are the ones that blow up
89/// to tens of thousands of files.
90#[must_use]
91pub fn is_broad_recursive_glob(pattern: &str) -> bool {
92    pattern.contains("**") || pattern.ends_with("/*") || pattern.ends_with('/')
93}
94
95/// Assess whether a glob's expansion (`file_count`) is acceptable. Only *broad
96/// recursive* globs are warned/refused — an explicit pattern matching many
97/// files is the operator's deliberate choice.
98#[must_use]
99pub fn assess_glob_expansion(pattern: &str, file_count: u64) -> GlobAdvice {
100    if !is_broad_recursive_glob(pattern) {
101        return GlobAdvice::Proceed;
102    }
103    if file_count >= GLOB_REFUSE_THRESHOLD {
104        return GlobAdvice::Refuse {
105            file_count,
106            hard_cap: GLOB_REFUSE_THRESHOLD,
107            suggestion:
108                "use a manifest or explicit artifact list instead of a broad recursive glob"
109                    .to_string(),
110        };
111    }
112    if file_count >= GLOB_WARN_THRESHOLD {
113        return GlobAdvice::Warn {
114            file_count,
115            threshold: GLOB_WARN_THRESHOLD,
116            suggestion: "a manifest of exact artifacts would transfer far fewer files".to_string(),
117        };
118    }
119    GlobAdvice::Proceed
120}
121
122/// A measured artifact-retrieval cost report.
123#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
124pub struct ArtifactCostReport {
125    pub file_count: u64,
126    pub total_bytes: u64,
127    pub files_per_sec: f64,
128    pub bytes_per_sec: f64,
129    pub timings: ArtifactPhaseTimings,
130    pub wall_clock_ms: u64,
131}
132
133impl ArtifactCostReport {
134    /// Human-readable cost summary.
135    #[must_use]
136    pub fn render(&self) -> String {
137        format!(
138            "artifact retrieval cost: {} files, {} bytes in {} ms \
139             ({:.0} files/s, {:.0} bytes/s) \
140             [collect {}ms, compress {}ms, transfer {}ms, extract {}ms]",
141            self.file_count,
142            self.total_bytes,
143            self.wall_clock_ms,
144            self.files_per_sec,
145            self.bytes_per_sec,
146            self.timings.collection_ms,
147            self.timings.compression_ms,
148            self.timings.transfer_ms,
149            self.timings.extract_ms,
150        )
151    }
152}
153
154/// Compute the cost report from a measured retrieval. Rates are 0.0 when no
155/// time elapsed (avoids a divide-by-zero / infinity).
156#[must_use]
157#[allow(clippy::cast_precision_loss)]
158pub fn compute_artifact_cost(
159    file_count: u64,
160    total_bytes: u64,
161    timings: ArtifactPhaseTimings,
162) -> ArtifactCostReport {
163    let wall_clock_ms = timings.total_ms();
164    let secs = wall_clock_ms as f64 / 1000.0;
165    let (files_per_sec, bytes_per_sec) = if secs > 0.0 {
166        (file_count as f64 / secs, total_bytes as f64 / secs)
167    } else {
168        (0.0, 0.0)
169    };
170    ArtifactCostReport {
171        file_count,
172        total_bytes,
173        files_per_sec,
174        bytes_per_sec,
175        timings,
176        wall_clock_ms,
177    }
178}
179
180#[cfg(test)]
181mod tests {
182    use super::*;
183
184    #[test]
185    fn timings_total_is_sum_of_phases() {
186        let t = ArtifactPhaseTimings {
187            collection_ms: 100,
188            compression_ms: 200,
189            transfer_ms: 300,
190            extract_ms: 50,
191        };
192        assert_eq!(t.total_ms(), 650);
193    }
194
195    #[test]
196    fn cost_rates_computed_from_timings() {
197        let t = ArtifactPhaseTimings {
198            collection_ms: 0,
199            compression_ms: 0,
200            transfer_ms: 2000,
201            extract_ms: 0,
202        };
203        // 1000 files, 2_000_000 bytes in 2 s -> 500 files/s, 1_000_000 bytes/s.
204        let r = compute_artifact_cost(1000, 2_000_000, t);
205        assert_eq!(r.wall_clock_ms, 2000);
206        assert!((r.files_per_sec - 500.0).abs() < 1e-6);
207        assert!((r.bytes_per_sec - 1_000_000.0).abs() < 1e-6);
208    }
209
210    #[test]
211    fn zero_time_yields_zero_rates_not_infinity() {
212        let r = compute_artifact_cost(5, 100, ArtifactPhaseTimings::default());
213        assert_eq!(r.wall_clock_ms, 0);
214        assert_eq!(r.files_per_sec, 0.0);
215        assert_eq!(r.bytes_per_sec, 0.0);
216        assert!(r.files_per_sec.is_finite());
217    }
218
219    #[test]
220    fn broad_recursive_glob_detection() {
221        assert!(is_broad_recursive_glob("target/**/*.rlib"));
222        assert!(is_broad_recursive_glob("target/debug/*"));
223        assert!(is_broad_recursive_glob("target/debug/"));
224        assert!(!is_broad_recursive_glob("target/debug/app"));
225        assert!(!is_broad_recursive_glob(".rch-target/debug/libfoo.rlib"));
226    }
227
228    #[test]
229    fn many_small_files_under_broad_glob_warns() {
230        // The classic case: a target dir with ~15k intermediate files.
231        let advice = assess_glob_expansion("target/**/*", 15_000);
232        match advice {
233            GlobAdvice::Warn {
234                file_count,
235                threshold,
236                ..
237            } => {
238                assert_eq!(file_count, 15_000);
239                assert_eq!(threshold, GLOB_WARN_THRESHOLD);
240            }
241            other => panic!("expected warn, got {other:?}"),
242        }
243        assert_eq!(advice.recommended_mode(), RetrievalMode::Manifest);
244        assert!(!advice.is_refused());
245    }
246
247    #[test]
248    fn enormous_broad_glob_is_refused() {
249        let advice = assess_glob_expansion("target/**/*", 80_000);
250        assert!(advice.is_refused());
251        assert_eq!(advice.recommended_mode(), RetrievalMode::Manifest);
252        if let GlobAdvice::Refuse { hard_cap, .. } = advice {
253            assert_eq!(hard_cap, GLOB_REFUSE_THRESHOLD);
254        } else {
255            panic!("expected refuse");
256        }
257    }
258
259    #[test]
260    fn explicit_pattern_never_warns_even_with_many_files() {
261        // A non-recursive, explicit pattern is the operator's deliberate choice.
262        let advice = assess_glob_expansion("target/debug/app", 99_999);
263        assert_eq!(advice, GlobAdvice::Proceed);
264        assert_eq!(advice.recommended_mode(), RetrievalMode::Glob);
265    }
266
267    #[test]
268    fn small_broad_glob_proceeds() {
269        assert_eq!(
270            assess_glob_expansion("target/**/*.rlib", 200),
271            GlobAdvice::Proceed
272        );
273    }
274
275    #[test]
276    fn report_render_and_serde_are_stable() {
277        let r = compute_artifact_cost(
278            1000,
279            2_000_000,
280            ArtifactPhaseTimings {
281                collection_ms: 10,
282                compression_ms: 20,
283                transfer_ms: 1970,
284                extract_ms: 0,
285            },
286        );
287        let text = r.render();
288        assert!(text.contains("1000 files"));
289        assert!(text.contains("transfer 1970ms"));
290        // Stable JSON diagnostics (field-name freeze).
291        let v = serde_json::to_value(&r).unwrap();
292        let mut keys: Vec<&str> = v.as_object().unwrap().keys().map(String::as_str).collect();
293        keys.sort_unstable();
294        assert_eq!(
295            keys,
296            vec![
297                "bytes_per_sec",
298                "file_count",
299                "files_per_sec",
300                "timings",
301                "total_bytes",
302                "wall_clock_ms",
303            ]
304        );
305        let back: ArtifactCostReport = serde_json::from_value(v).unwrap();
306        assert_eq!(r, back);
307    }
308
309    #[test]
310    fn glob_advice_serializes_with_tagged_variant() {
311        let v = serde_json::to_value(assess_glob_expansion("target/**/*", 15_000)).unwrap();
312        assert_eq!(v["advice"], "warn");
313        assert_eq!(v["file_count"], 15_000);
314    }
315}