Skip to main content

qk_cache/
ultracache.rs

1//! `qk:ultracache`: key a task by what it read when it last ran, rather
2//! than by its declared file inputs.
3//!
4//! A run with `--record` traces each opted-in task it executes and keeps a
5//! path set: the files the task read, the directories it listed and the
6//! paths it looked up. A later run keys the task by how those paths are now,
7//! so a change to a file the task never read is still a hit, and a new file
8//! in a directory it listed is a miss. See [`crate::recorded`].
9//!
10//! - `mode`: `"on"`, the default once the field is set, `"record"` or
11//!   `"off"`. With `"record"`, a task is recorded and its recordings kept and
12//!   shared as with `"on"`, but it is keyed by its declared inputs, and the
13//!   run reports what its recordings would have done. Nx's `"warn"` and
14//!   `"error"` are not supported yet.
15//! - `ignoreReads`: workspace globs left out of the path set, for files whose
16//!   changes cannot change the result.
17
18use anyhow::{Context, Result, bail};
19use qk_taskgraph::Task;
20use serde_json::Value;
21
22/// The fields a `qk:ultracache` object can set. Others are ignored, as Nx
23/// ignores keys it does not know; `qk doctor` reports them.
24pub const FIELDS: &[&str] = &["mode", "ignoreReads"];
25
26#[derive(Clone, Debug, Default, PartialEq, Eq)]
27pub struct Settings {
28    pub mode: Mode,
29    pub ignore_reads: Vec<String>,
30}
31
32#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
33pub enum Mode {
34    /// Keyed by its recordings once it has any.
35    #[default]
36    On,
37    /// Recorded, but keyed by its declared inputs.
38    Record,
39}
40
41impl Settings {
42    /// The target's settings when it is recorded, `None` when it is not:
43    /// without the field, or with `mode` `"off"`.
44    pub fn of(task: &Task) -> Result<Option<Self>> {
45        let Some(value) = task.definition.extra.get("qk:ultracache") else {
46            return Ok(None);
47        };
48        let Value::Object(object) = value else {
49            bail!("qk:ultracache must be an object");
50        };
51        let mode = match object.get("mode").map(Value::as_str) {
52            None | Some(Some("on")) => Mode::On,
53            Some(Some("record")) => Mode::Record,
54            Some(Some("off")) => return Ok(None),
55            Some(Some(mode @ ("warn" | "error"))) => {
56                bail!(
57                    "qk:ultracache.mode {mode:?} is not supported yet; use \"on\", \"record\" or \"off\""
58                )
59            }
60            Some(_) => bail!("qk:ultracache.mode must be \"on\", \"record\" or \"off\""),
61        };
62        let ignore_reads = ignored_reads(task)?;
63        Ok(Some(Self { mode, ignore_reads }))
64    }
65}
66
67/// The target's `qk:ultracache.ignoreReads`, whatever its mode: the reads
68/// that change nothing the task does, which the sandbox leaves out of its
69/// findings and allows whether or not the task is recorded.
70pub fn ignored_reads(task: &Task) -> Result<Vec<String>> {
71    let Some(Value::Object(object)) = task.definition.extra.get("qk:ultracache") else {
72        return Ok(Vec::new());
73    };
74    let ignore_reads: Vec<String> = match object.get("ignoreReads") {
75        None => Vec::new(),
76        Some(value) => serde_json::from_value(value.clone())
77            .context("qk:ultracache.ignoreReads must be an array of globs")?,
78    };
79    // Checked before the task runs, rather than after it was traced.
80    for glob in &ignore_reads {
81        crate::glob::Pattern::new(glob, false)
82            .with_context(|| format!("qk:ultracache.ignoreReads has an invalid glob {glob:?}"))?;
83    }
84    Ok(ignore_reads)
85}
86
87#[cfg(test)]
88mod tests {
89    use super::*;
90
91    fn task(value: Value) -> Task {
92        let mut definition: qk_config::Target =
93            serde_json::from_value(serde_json::json!({})).unwrap();
94        definition.extra.insert("qk:ultracache".into(), value);
95        Task {
96            id: "app:build".into(),
97            project: "app".into(),
98            target: "build".into(),
99            configuration: None,
100            args: Vec::new(),
101            definition,
102            dependencies: Default::default(),
103            edges: Default::default(),
104        }
105    }
106
107    #[test]
108    fn reads_the_mode_and_ignored_reads() {
109        let on = Settings::of(&task(serde_json::json!({"ignoreReads": ["tmp/**"]})))
110            .unwrap()
111            .unwrap();
112        assert_eq!(on.ignore_reads, ["tmp/**"]);
113        assert_eq!(on.mode, Mode::On);
114        let record = Settings::of(&task(serde_json::json!({"mode": "record"})))
115            .unwrap()
116            .unwrap();
117        assert_eq!(record.mode, Mode::Record);
118        assert!(
119            Settings::of(&task(serde_json::json!({"mode": "off"})))
120                .unwrap()
121                .is_none()
122        );
123        // Unrecorded, its ignored reads still hold for the sandbox.
124        let off = task(serde_json::json!({"mode": "off", "ignoreReads": ["tmp/**"]}));
125        assert_eq!(ignored_reads(&off).unwrap(), ["tmp/**"]);
126        for invalid in [
127            serde_json::json!(true),
128            serde_json::json!({"mode": "warn"}),
129            serde_json::json!({"mode": 1}),
130            serde_json::json!({"ignoreReads": "tmp/**"}),
131            serde_json::json!({"ignoreReads": ["src/[*.log"]}),
132        ] {
133            assert!(Settings::of(&task(invalid.clone())).is_err(), "{invalid}");
134        }
135    }
136}