Skip to main content

qk_cache/
lib.rs

1//! Local content-addressed cache shared by linked Git worktrees.
2
3mod ahead;
4mod evict;
5pub mod expectations;
6mod glob;
7mod hash;
8mod ignored;
9mod inputs;
10mod paths;
11pub mod profile;
12mod record;
13pub mod recorded;
14mod remote;
15pub mod scratch;
16mod store;
17pub mod ultracache;
18pub mod warm;
19
20use std::collections::{BTreeMap, BTreeSet};
21use std::path::PathBuf;
22use std::sync::OnceLock;
23use std::sync::atomic::{AtomicBool, Ordering};
24
25use anyhow::Result;
26use qk_config::Workspace;
27use qk_executor::{Capture, Outcome, PreparedTask, execute, execute_captured};
28use qk_taskgraph::{Task, TaskGraph};
29use serde_json::Value;
30
31pub use evict::{Pruned, max_size, parse_size, prune, prune_automatic};
32pub use glob::Pattern;
33pub use hash::{
34    DependencyGlob, Selects, SourceIgnore, dependency_globs, keyed_definition, source_files,
35    task_configuration,
36};
37pub use inputs::{
38    InputChange, InputChanges, JsonFields, ROOT_TSCONFIG_FILES, Resolution, Resolved, TsConfig,
39    manifest_for_dependents, resolve_each, resolve_tasks, selectively_hash_tsconfig,
40    without_resolution,
41};
42/// Expands `{projectRoot}` and `{workspaceRoot}` in a project's path or glob.
43pub use paths::expand as expand_project_path;
44pub use paths::git as workspace_git;
45pub use paths::{
46    Outputs, cache_directory, cache_location, clear_worktree_state, resolved_outputs,
47    worktree_state,
48};
49
50/// The nx.json settings of the workspace's remote store: its `s3`, as Nx's
51/// cache plugin reads it, and its `qk:storeServer`, which Nx never reads, so a
52/// workspace can name a store server for qk while Nx's plugin keeps `s3`.
53fn remote_settings(workspace: &Workspace) -> (Option<&Value>, Option<&Value>) {
54    let extra = &workspace.config.extra;
55    (extra.get("s3"), extra.get("qk:storeServer"))
56}
57
58/// Runs a task the cache does not keep. A sandboxed one's log is kept, which
59/// says whether it failed applying a sandbox of its own.
60fn execute_uncached(
61    task: &PreparedTask,
62    cancelled: &AtomicBool,
63) -> Result<(Outcome, Option<Execution>)> {
64    if task.sandbox.is_none() {
65        return Ok((execute(task, cancelled)?, None));
66    }
67    let capture = Capture::new(None, task.display.clone()).retain_log();
68    let outcome = execute_captured(task, cancelled, Some(&capture))?;
69    let execution = capture.take_log().map(|log| Execution {
70        log,
71        inputs_unchanged: false,
72    });
73    Ok((outcome, execution))
74}
75
76/// A task's scratch directories, and all beneath them, as workspace globs:
77/// emptied before each run, nothing in them keys a recording.
78fn scratch_patterns<'a>(
79    root: &'a std::path::Path,
80    prepared: &'a PreparedTask,
81) -> impl Iterator<Item = Pattern> + 'a {
82    prepared.scratch.iter().filter_map(move |scratch| {
83        let relative = scratch.directory.strip_prefix(root).ok()?.to_str()?;
84        // Named exactly, though its name may hold what a glob reads.
85        let named = glob::escape(&relative.replace('\\', "/"));
86        Pattern::fileset(&named, false).ok()
87    })
88}
89
90impl Cache {
91    /// What a task's recordings leave out: its `ignoreReads`, and its scratch
92    /// directories. The project graph is only built for an entry naming
93    /// dependencies.
94    fn ignored_reads(
95        &self,
96        workspace: &Workspace,
97        task: &Task,
98        prepared: &PreparedTask,
99        written: &[String],
100    ) -> Result<ultracache::IgnoredReads> {
101        let projects = written
102            .iter()
103            .any(|glob| glob.starts_with('^'))
104            .then(|| {
105                self.projects
106                    .get_or_init(|| qk_graph::ProjectGraph::build(workspace).ok())
107                    .as_ref()
108            })
109            .flatten();
110        Ok(
111            ultracache::IgnoredReads::new(written, workspace, projects, &task.project)?
112                .with_patterns(scratch_patterns(&workspace.root, prepared)),
113        )
114    }
115}
116
117/// The cache for one run; its workspace snapshot is taken on first use.
118pub struct Cache {
119    pub root: PathBuf,
120    snapshot: OnceLock<std::result::Result<hash::Snapshot, String>>,
121    remote: Option<std::sync::Arc<remote::Remote>>,
122    /// Whether results and warm state may be restored or published.
123    reuse: bool,
124    /// Whether each task runs from scratch: no result is restored, its warm
125    /// paths are emptied rather than restored, and what it gives is saved as
126    /// ever, replacing what was saved before.
127    cold: bool,
128    /// The warm groups `cold` emptied this run, which tasks sharing one fill
129    /// together.
130    cleared: std::sync::Mutex<std::collections::BTreeSet<String>>,
131    /// Remote warm saves fetched ahead, by warm identity.
132    prefetches: OnceLock<warm::Prefetches>,
133    /// The tasks whose outputs this run left exactly as they were.
134    kept: std::sync::Mutex<std::collections::BTreeSet<String>>,
135    /// Warm state being saved in the background.
136    saves: std::sync::Mutex<Vec<std::thread::JoinHandle<()>>>,
137    cleanup: std::sync::Mutex<Option<store::Cleanup>>,
138    /// Every target's declared outputs, which recordings' listings leave out.
139    workspace_outputs: OnceLock<recorded::WorkspaceOutputs>,
140    /// The project graph, for `ignoreReads` entries that name dependencies;
141    /// built on first use, `None` where it cannot be.
142    projects: OnceLock<Option<qk_graph::ProjectGraph>>,
143}
144
145/// Where a task's result came from.
146#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
147#[serde(rename_all = "kebab-case")]
148pub enum CacheStatus {
149    LocalHit,
150    RemoteHit,
151    Miss,
152    /// Ran without consulting the cache: not cacheable, bypassed or disabled.
153    Uncached,
154}
155
156/// One actual keyed execution, independent of reusable cache entries.
157pub struct Execution {
158    pub log: qk_executor::RecordedLog,
159    pub inputs_unchanged: bool,
160}
161
162pub struct TaskResult {
163    pub outcome: Outcome,
164    pub cache: CacheStatus,
165    /// The task's key and what it was computed from, when it has one.
166    pub key: Option<(String, Value)>,
167    /// What warm state did, for targets that keep it.
168    pub warm: Option<warm::WarmReport>,
169    pub execution: Option<Execution>,
170    /// How a task keyed by its recordings was keyed: see [`recorded`].
171    pub recorded: Option<Recorded>,
172    /// For a task with `qk:ultracache` mode `"record"`, what its recordings
173    /// would have done.
174    pub shadow: Option<Shadow>,
175}
176
177/// What the recordings of a task keyed by its declared inputs would have done.
178#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
179#[serde(rename_all = "camelCase", tag = "kind")]
180pub enum Shadow {
181    /// One of its path sets' keys had a result, here or in the remote store.
182    WouldHit {
183        key: String,
184        /// For a task that ran all the same, whether it gave that result.
185        #[serde(default, skip_serializing_if = "Option::is_none")]
186        check: Option<Check>,
187    },
188    /// None of its path sets' keys had a result.
189    WouldMiss {
190        /// For a task its declared inputs took from the cache, the paths of
191        /// its newest path set that are not as they were when it was
192        /// recorded, as `<path> (<how>)`, `<how>` ending in `, declared`
193        /// where its declared inputs cover the path. A change they do not
194        /// cover either matters, so they missed it, or does not, and its
195        /// recording keys what it need not. At most [`UNCOVERED`].
196        #[serde(default, skip_serializing_if = "Vec::is_empty")]
197        changed: Vec<String>,
198        /// Where the newest path set keeps no record of how its paths were,
199        /// what it holds that the declared inputs leave out, as
200        /// `<path> (<how>)`: one of them changed. At most [`UNCOVERED`].
201        #[serde(default, skip_serializing_if = "Vec::is_empty")]
202        uncovered: Vec<String>,
203    },
204    /// It has no path set yet.
205    Unrecorded,
206}
207
208/// How many uncovered paths a [`Shadow::WouldMiss`] names.
209pub const UNCOVERED: usize = 5;
210
211/// Whether a task that ran where a recording would have taken a result from
212/// the cache gave that result: it succeeded, with the same output files.
213/// Its terminal output is not compared, since timings and the like differ
214/// from run to run.
215#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
216#[serde(rename_all = "camelCase")]
217pub struct Check {
218    pub matched: bool,
219    /// How the run differed, as `failed (exit <code>)` or `<path> (<how>)`,
220    /// at most [`UNCOVERED`].
221    #[serde(default, skip_serializing_if = "Vec::is_empty")]
222    pub differences: Vec<String>,
223}
224
225/// A lookup by a task's recordings in mode `"record"`, started before its
226/// lookup by its declared key so that the two wait on the remote store
227/// together rather than one after the other.
228enum Shadowing {
229    /// Answered without the store: a recording's result is held here, or
230    /// there is no store to ask.
231    Settled(Shadow),
232    /// Waiting for the path sets the store holds, with the keys of the path
233    /// sets held here, none of which has a result here.
234    Fetching {
235        keys: Vec<String>,
236        fetch: std::thread::JoinHandle<Result<Option<Vec<u8>>>>,
237    },
238}
239
240/// The key of one of a task's path sets, or none, said, when the path set
241/// cannot key it.
242fn recording_key(
243    weak: &str,
244    set: &recorded::PathSet,
245    snapshot: &hash::Snapshot,
246    workspace: &Workspace,
247    unlisted: &recorded::Unlisted,
248    task: &Task,
249) -> Option<String> {
250    recorded::strong_key(weak, set, snapshot, &workspace.root, unlisted)
251        .inspect_err(|error| {
252            qk_executor::status!("qk: {}: ignoring a path set ({error:#})", task.id);
253        })
254        .ok()
255}
256
257/// What a lookup by a task's recordings found.
258enum Found {
259    /// A result under this key in the local cache.
260    Local(String),
261    /// A result under this key in the remote store.
262    Remote(String),
263    Nothing,
264}
265
266/// How a task with `qk:ultracache` was keyed.
267#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
268#[serde(rename_all = "camelCase", tag = "kind")]
269pub enum Recorded {
270    /// One of its path sets' keys had a result, restored under that key.
271    Hit { key: String },
272    /// This run recorded it, and stored its result under its new key.
273    Saved { key: String },
274    /// None of its path sets' keys had a result, and this run did not record.
275    Missed,
276    /// It has no path set yet, and this run did not record one: it was keyed
277    /// by its declared inputs.
278    Unrecorded,
279    /// None of its path sets' keys had a result, and its declared key, which
280    /// covers what each of them read, did: restored under that key.
281    Declared { key: String },
282    /// Recording it failed, with why.
283    Failed { reason: String },
284}
285
286impl TaskResult {
287    pub fn uncached(outcome: Outcome) -> Self {
288        Self {
289            outcome,
290            cache: CacheStatus::Uncached,
291            key: None,
292            warm: None,
293            execution: None,
294            recorded: None,
295            shadow: None,
296        }
297    }
298}
299
300impl Cache {
301    pub fn new(root: PathBuf) -> Self {
302        Self {
303            root,
304            snapshot: OnceLock::new(),
305            remote: None,
306            reuse: true,
307            cold: false,
308            cleared: Default::default(),
309            prefetches: OnceLock::new(),
310            kept: Default::default(),
311            saves: Default::default(),
312            cleanup: Default::default(),
313            workspace_outputs: OnceLock::new(),
314            projects: OnceLock::new(),
315        }
316    }
317
318    /// Records verified executions without configuring or using result stores.
319    pub fn for_observation(workspace: &Workspace) -> Self {
320        Self {
321            reuse: false,
322            ..Self::new(paths::cache_location(workspace))
323        }
324    }
325
326    /// The workspace's cache, with the remote store its nx.json `s3` or
327    /// `qk:storeServer` key configures, if any. Credentials
328    /// and modes are read from `environment`, which includes the workspace's
329    /// dotenv files. A remote store that cannot be used is reported and left
330    /// out; the local cache still works.
331    pub fn for_workspace(
332        workspace: &Workspace,
333        environment: &BTreeMap<std::ffi::OsString, std::ffi::OsString>,
334    ) -> Self {
335        let root = paths::cache_location(workspace);
336        let (s3, server) = remote_settings(workspace);
337        let remote = match remote::configure(s3, server, environment) {
338            Ok(remote) => remote.map(std::sync::Arc::new),
339            Err(error) => {
340                qk_executor::status!("qk: remote cache disabled: {error:#}");
341                None
342            }
343        };
344        if let Some(remote) = &remote {
345            remote.read_index(&root);
346        }
347        Self {
348            remote,
349            ..Self::new(root)
350        }
351    }
352
353    /// This cache with every task run from scratch, as `--cold` asks: see
354    /// [`Cache::cold`]'s field.
355    pub fn cold(self) -> Self {
356        Self { cold: true, ..self }
357    }
358
359    /// Saves what later runs reuse and waits for background saves, uploads
360    /// and retired-output cleanup, reporting failures.
361    pub fn finish(&self, workspace: &Workspace) {
362        if !self.reuse {
363            return;
364        }
365        if let Some(Ok(snapshot)) = self.snapshot.get() {
366            snapshot.save_digests(&workspace.root);
367        }
368        self.finish_prefetches();
369        // Before the uploads, which a save may add to.
370        for save in std::mem::take(&mut *self.saves.lock().unwrap()) {
371            let _ = save.join();
372        }
373        if let Some(remote) = &self.remote {
374            let failures = remote.finish();
375            for failure in &failures {
376                qk_executor::status!("qk: remote cache upload failed for {failure}");
377            }
378        }
379        let _profile = profile::span("cache", "restore_cleanup_wait");
380        self.cleanup.lock().unwrap().take();
381    }
382
383    fn snapshot(
384        &self,
385        workspace: &Workspace,
386        graph: &TaskGraph,
387    ) -> std::result::Result<&hash::Snapshot, String> {
388        self.snapshot
389            .get_or_init(|| {
390                hash::Snapshot::new(workspace, graph, &self.root)
391                    .map_err(|error| format!("cannot snapshot the workspace: {error:#}"))
392            })
393            .as_ref()
394            .map_err(Clone::clone)
395    }
396
397    pub fn run(
398        &self,
399        workspace: &Workspace,
400        graph: &TaskGraph,
401        task: &Task,
402        prepared: &PreparedTask,
403        cancelled: &AtomicBool,
404    ) -> Result<TaskResult> {
405        let result = self.run_task(workspace, graph, task, prepared, cancelled);
406        if let Some(Ok(snapshot)) = self.snapshot.get() {
407            snapshot.outputs_written(workspace, task);
408        }
409        result
410    }
411
412    fn run_task(
413        &self,
414        workspace: &Workspace,
415        graph: &TaskGraph,
416        task: &Task,
417        prepared: &PreparedTask,
418        cancelled: &AtomicBool,
419    ) -> Result<TaskResult> {
420        let cacheable = task.definition.cache == Some(true);
421        // Held until the task is done, whether it ran or came from the cache.
422        let _kept = self
423            .reuse
424            .then(|| warm::keep_across(workspace, graph, task));
425        let entries = if self.reuse {
426            warm::config(workspace, task).unwrap_or_else(|error| {
427                qk_executor::status!("qk: {}: warm state ignored ({error:#})", task.id);
428                Vec::new()
429            })
430        } else {
431            Vec::new()
432        };
433        // Names an entry in messages: the group it shares, if any.
434        let entry_label = |warm: &warm::Warm| match warm.group() {
435            Some(group) => format!(" (group {group})"),
436            None => String::new(),
437        };
438        // The warm variables point tools at their state; they are not inputs,
439        // so the key is computed from the task without them.
440        let mut running = prepared.clone();
441        for warm in &entries {
442            for (name, value) in &warm.env {
443                running.execution.insert(name.into(), value.into());
444            }
445        }
446        // What each entry restored, and which groups were on disk already.
447        // Cold: the groups left as they were, whose save would keep what
448        // the run was to replace.
449        let uncleared = std::sync::Mutex::new(std::collections::BTreeSet::new());
450        let before = |running: &PreparedTask| -> (Vec<warm::Restored>, Vec<String>) {
451            let (mut restored, mut present) = (Vec::new(), Vec::new());
452            for warm in &entries {
453                if self.cold {
454                    // Started from nothing, so its save replaces a bad one.
455                    if let Err(error) = self.clear_warm(workspace, task, warm) {
456                        qk_executor::status!(
457                            "qk: {}: warm state{} not cleared, so not saved ({error:#})",
458                            task.id,
459                            entry_label(warm)
460                        );
461                        uncleared.lock().unwrap().insert(warm.identity.clone());
462                    }
463                    continue;
464                }
465                match self
466                    .initialize()
467                    .and_then(|()| self.restore_warm(workspace, task, warm, running))
468                {
469                    Ok((entry, groups)) => {
470                        if !entry.groups.is_empty() {
471                            restored.push(entry);
472                        }
473                        present.extend(groups);
474                    }
475                    Err(error) => qk_executor::status!(
476                        "qk: {}: warm state{} not restored ({error:#})",
477                        task.id,
478                        entry_label(warm)
479                    ),
480                }
481            }
482            (restored, present)
483        };
484        let after = |outcome: Outcome,
485                     (restored, present): (Vec<warm::Restored>, Vec<String>),
486                     running: &PreparedTask|
487         -> Option<warm::WarmReport> {
488            if entries.is_empty() {
489                return None;
490            }
491            let mut save_ms = None;
492            let mut background = false;
493            let uncleared = uncleared.lock().unwrap();
494            for warm in entries
495                .iter()
496                .filter(|warm| outcome == Outcome::Success && !uncleared.contains(&warm.identity))
497            {
498                let started = std::time::Instant::now();
499                let saved = if warm.background {
500                    self.save_warm_in_background(workspace, task, warm, running)
501                } else {
502                    self.save_warm(workspace, task, warm, running)
503                };
504                match saved {
505                    Ok(()) if warm.background => background = true,
506                    Ok(()) => {
507                        *save_ms.get_or_insert(0) += started.elapsed().as_millis() as u64;
508                    }
509                    Err(error) => qk_executor::status!(
510                        "qk: {}: warm state{} not saved ({error:#})",
511                        task.id,
512                        entry_label(warm)
513                    ),
514                }
515            }
516            Some(warm::WarmReport {
517                restored,
518                present,
519                save_ms,
520                background,
521            })
522        };
523        // Reports why this task runs uncached.
524        let bypass = |reason: String| {
525            if cacheable {
526                qk_executor::status!("qk: {}: cache bypassed ({reason})", task.id);
527            }
528            let _profile = profile::span(&task.id, "execute");
529            let (outcome, execution) = execute_uncached(prepared, cancelled)?;
530            Ok(TaskResult {
531                execution,
532                ..TaskResult::uncached(outcome)
533            })
534        };
535        let snapshot = match self.snapshot(workspace, graph) {
536            Ok(snapshot) => snapshot,
537            Err(reason) => return bypass(reason),
538        };
539        let dependency_profile = profile::span(&task.id, "dependency_inputs");
540        let dependencies = match hash::dependency_keys(snapshot, workspace, graph, task, cancelled)
541        {
542            Ok(dependencies) => dependencies,
543            Err(error) => return bypass(format!("{error:#}")),
544        };
545        drop(dependency_profile);
546        let input_profile = profile::span(&task.id, "inputs");
547        let (key, inputs) = match hash::inputs(
548            snapshot,
549            workspace,
550            task,
551            prepared,
552            &dependencies,
553            cancelled,
554        )
555        .and_then(|inputs| Ok((hash::key(&inputs)?, inputs)))
556        {
557            Ok(key) => key,
558            Err(error) => return bypass(format!("{error:#}")),
559        };
560        drop(input_profile);
561        let keyed = Some((key.clone(), inputs));
562        // As in Nx, the task sees its hash.
563        running
564            .execution
565            .insert("NX_TASK_HASH".into(), key.clone().into());
566        let running = &running;
567        let outputs = match paths::Outputs::new(workspace, task) {
568            Ok(outputs) => outputs,
569            Err(error) => return bypass(format!("{error:#}")),
570        };
571        if !cacheable {
572            let restored = before(running);
573            let executing = profile::span(&task.id, "execute");
574            let (outcome, execution) = execute_uncached(running, cancelled)?;
575            drop(executing);
576            let warm_report = after(outcome, restored, running);
577            return Ok(TaskResult {
578                outcome,
579                cache: CacheStatus::Uncached,
580                key: keyed,
581                warm: warm_report,
582                execution,
583                recorded: None,
584                shadow: None,
585            });
586        }
587        if !self.reuse {
588            let capture = Capture::new(None, prepared.display.clone()).retain_log();
589            let executing = profile::span(&task.id, "execute");
590            let outcome = execute_captured(running, cancelled, Some(&capture))?;
591            drop(executing);
592            let inputs_unchanged =
593                self.inputs_match(snapshot, workspace, graph, task, prepared, &key, cancelled);
594            return Ok(TaskResult {
595                outcome,
596                cache: CacheStatus::Uncached,
597                key: keyed,
598                warm: None,
599                execution: capture.take_log().map(|log| Execution {
600                    log,
601                    inputs_unchanged,
602                }),
603                recorded: None,
604                shadow: None,
605            });
606        }
607        if let Err(error) = self.initialize() {
608            return bypass(format!("cache unavailable: {error}"));
609        }
610        let (_lock, waited) = match self.lock(&key, cancelled) {
611            Ok(Some(lock)) => lock,
612            Ok(None) => {
613                return Ok(TaskResult::uncached(Outcome::Cancelled));
614            }
615            Err(error) => return bypass(format!("cache lock unavailable: {error}")),
616        };
617        // Inputs may have changed while another worktree held this key's lock.
618        if waited
619            && hash::recheck_dependency_keys(snapshot, workspace, graph, task, cancelled)
620                .and_then(|dependencies| {
621                    hash::fingerprint(
622                        snapshot,
623                        workspace,
624                        task,
625                        prepared,
626                        &dependencies,
627                        cancelled,
628                    )
629                })
630                .ok()
631                .as_ref()
632                != Some(&key)
633        {
634            return bypass("inputs changed while waiting for another run".into());
635        }
636        // Keyed by its recordings, a task's path sets stand in for its
637        // declared file inputs: see `recorded`.
638        let settings = ultracache::Settings::of(task).unwrap_or_else(|error| {
639            qk_executor::status!("qk: {}: qk:ultracache ignored ({error:#})", task.id);
640            None
641        });
642        let warm_outputs = paths::Outputs::from_paths(&warm::kept_paths(&entries)).ok();
643        let own: Vec<&paths::Outputs> = std::iter::once(&outputs)
644            .chain(warm_outputs.as_ref())
645            .collect();
646        // What its recordings' listings leave out, by the workspace's
647        // configuration rather than what this run schedules, so that the
648        // same recording keys the same in any run.
649        let others = settings.as_ref().map(|_| recorded::Others {
650            outputs: self
651                .workspace_outputs
652                .get_or_init(|| workspace_outputs(workspace)),
653            upstream: upstream(graph, task),
654        });
655        let ignore = settings
656            .as_ref()
657            .and_then(|settings| {
658                self.ignored_reads(workspace, task, prepared, &settings.ignore_reads)
659                    .ok()
660            })
661            .unwrap_or_default();
662        let unlisted = recorded::Unlisted {
663            own: own.clone(),
664            others,
665            ignore: &ignore,
666        };
667        let inputs = &keyed.as_ref().expect("a cacheable task is keyed").1;
668        let mut recordings = settings.and_then(|settings| {
669            let weak = hash::weak_key(
670                snapshot,
671                workspace,
672                graph,
673                task,
674                prepared,
675                inputs,
676                &settings.ignore_reads,
677                &ignore.scope(),
678                cancelled,
679            );
680            match weak {
681                Ok(weak) => Some((recorded::load(&self.root, &weak), weak, settings)),
682                Err(error) => {
683                    qk_executor::status!(
684                        "qk: {}: not keyed by its recordings ({error:#})",
685                        task.id
686                    );
687                    None
688                }
689            }
690        });
691        let mut shadowing = None;
692        // Cold: the keys this task's path sets give, whose entries a later
693        // run looks up first, so its result replaces theirs too.
694        let mut replaced = Vec::new();
695        if let Some((sets, weak, settings)) = &mut recordings {
696            if self.cold {
697                // Nothing is restored, so there is nothing to look up.
698                replaced = sets
699                    .iter()
700                    .filter_map(|set| {
701                        recording_key(weak, set, snapshot, workspace, &unlisted, task)
702                    })
703                    .collect();
704            } else if settings.mode == ultracache::Mode::Record {
705                let _lookup = profile::span(&task.id, "recorded_lookup");
706                shadowing =
707                    Some(self.start_shadow(workspace, task, snapshot, &unlisted, sets, weak));
708            } else {
709                let recorded_profile = profile::span(&task.id, "recorded_restore");
710                let found = self.by_recordings(
711                    workspace, task, prepared, snapshot, &outputs, &unlisted, sets, weak, &key,
712                    cancelled,
713                );
714                drop(recorded_profile);
715                if let found @ (Found::Local(_) | Found::Remote(_)) = found {
716                    let (strong, remote) = match found {
717                        Found::Local(strong) => {
718                            if let Some(remote) = &self.remote {
719                                remote.held(&strong);
720                            }
721                            (strong, None)
722                        }
723                        Found::Remote(strong) => (strong, Some(())),
724                        Found::Nothing => unreachable!(),
725                    };
726                    recorded::touch(&self.root, weak);
727                    qk_executor::report::event(qk_executor::report::Event::Cache {
728                        id: task.id.clone(),
729                        cached: if remote.is_some() {
730                            qk_executor::report::Cached::RemoteHit
731                        } else {
732                            qk_executor::report::Cached::Hit
733                        },
734                    });
735                    return Ok(TaskResult {
736                        outcome: Outcome::Success,
737                        cache: if remote.is_some() {
738                            CacheStatus::RemoteHit
739                        } else {
740                            CacheStatus::LocalHit
741                        },
742                        key: keyed,
743                        warm: None,
744                        execution: None,
745                        recorded: Some(Recorded::Hit { key: strong }),
746                        shadow: None,
747                    });
748                }
749            }
750        }
751        // What its recordings would have done, once the store has said.
752        let mut settle = |recordings: &mut Option<(Vec<recorded::PathSet>, String, _)>| {
753            let shadowing = shadowing.take()?;
754            let (sets, weak, _) = recordings.as_mut()?;
755            let _wait = profile::span(&task.id, "recorded_wait");
756            Some(self.settle_shadow(shadowing, workspace, task, snapshot, &unlisted, sets, weak))
757        };
758        // A task with path sets is keyed by them alone, unless its declared
759        // inputs, as they are now, cover what each of them read: otherwise
760        // its declared inputs are what a recording found wanting. In mode
761        // "record" it is keyed by its declared inputs all the same.
762        let declared = recordings.as_ref().is_none_or(|(sets, _, settings)| {
763            settings.mode == ultracache::Mode::Record
764                || sets.is_empty()
765                || hash::coverage(snapshot, workspace, graph, task, prepared, cancelled).is_ok_and(
766                    |coverage| {
767                        let outputs = self
768                            .workspace_outputs
769                            .get_or_init(|| workspace_outputs(workspace));
770                        let output = |path: &str| outputs.matches(path);
771                        sets.iter()
772                            .all(|set| coverage.covers(snapshot, &workspace.root, set, &output))
773                    },
774                )
775        });
776        // How its recordings keyed it, unless it runs and records.
777        let unrecorded = recordings
778            .as_ref()
779            .filter(|(_, _, settings)| settings.mode == ultracache::Mode::On)
780            .map(|(sets, _, _)| {
781                if sets.is_empty() {
782                    Recorded::Unrecorded
783                } else {
784                    Recorded::Declared { key: key.clone() }
785                }
786            });
787        // An entry fetched ahead of this task is local, but it came from the
788        // remote store, and is reported as such.
789        let prefetched = declared
790            && self.remote.as_ref().is_some_and(|remote| {
791                remote.resolved(&key);
792                remote.prefetched(&key)
793            });
794        let local_profile = profile::span(&task.id, "local_restore");
795        let restored = if declared && !self.cold {
796            self.restore(
797                &workspace.root,
798                &task.id,
799                &task.dependencies,
800                &key,
801                &outputs,
802                &prepared.display,
803                if prefetched {
804                    qk_executor::Shown::RemoteCache
805                } else {
806                    qk_executor::Shown::LocalCache
807                },
808            )
809        } else {
810            Ok(false)
811        };
812        match restored {
813            Ok(true) => {
814                let claimed = match &self.remote {
815                    Some(remote) if prefetched && remote.claim(&key) => true,
816                    Some(remote) => {
817                        remote.held(&key);
818                        false
819                    }
820                    None => false,
821                };
822                qk_executor::report::event(qk_executor::report::Event::Cache {
823                    id: task.id.clone(),
824                    cached: if claimed {
825                        qk_executor::report::Cached::RemoteHit
826                    } else {
827                        qk_executor::report::Cached::Hit
828                    },
829                });
830                let shadow = settle(&mut recordings);
831                let shadow = self.with_changes(
832                    shadow,
833                    recordings.as_ref(),
834                    (snapshot, workspace, graph, task, prepared, &unlisted),
835                    cancelled,
836                );
837                return Ok(TaskResult {
838                    outcome: Outcome::Success,
839                    cache: if claimed {
840                        CacheStatus::RemoteHit
841                    } else {
842                        CacheStatus::LocalHit
843                    },
844                    key: keyed,
845                    warm: None,
846                    execution: None,
847                    recorded: unrecorded,
848                    shadow,
849                });
850            }
851            Ok(false) => {}
852            Err(error) => {
853                qk_executor::status!("qk: {}: ignoring unusable cache entry ({error})", task.id)
854            }
855        }
856        drop(local_profile);
857        if let Some(remote) = self.remote.as_ref().filter(|_| declared && !self.cold) {
858            let fetch_profile = profile::span(&task.id, "remote_fetch");
859            let fetched = remote.fetch(&self.root, &key, &task.id, cancelled);
860            drop(fetch_profile);
861            let fetched = fetched.and_then(|found| {
862                if !found {
863                    return Ok(false);
864                }
865                self.restore(
866                    &workspace.root,
867                    &task.id,
868                    &task.dependencies,
869                    &key,
870                    &outputs,
871                    &prepared.display,
872                    qk_executor::Shown::RemoteCache,
873                )
874            });
875            match fetched {
876                Ok(true) => {
877                    qk_executor::report::event(qk_executor::report::Event::Cache {
878                        id: task.id.clone(),
879                        cached: qk_executor::report::Cached::RemoteHit,
880                    });
881                    let shadow = settle(&mut recordings);
882                    let shadow = self.with_changes(
883                        shadow,
884                        recordings.as_ref(),
885                        (snapshot, workspace, graph, task, prepared, &unlisted),
886                        cancelled,
887                    );
888                    return Ok(TaskResult {
889                        outcome: Outcome::Success,
890                        cache: CacheStatus::RemoteHit,
891                        key: keyed,
892                        warm: None,
893                        execution: None,
894                        recorded: unrecorded,
895                        shadow,
896                    });
897                }
898                Ok(false) => {}
899                Err(error) => {
900                    qk_executor::status!("qk: {}: remote cache unavailable ({error:#})", task.id)
901                }
902            }
903        }
904        // Settled before it runs: recording it merges the path sets fetched.
905        let shadow = settle(&mut recordings);
906        qk_executor::report::event(qk_executor::report::Event::Cache {
907            id: task.id.clone(),
908            cached: qk_executor::report::Cached::Miss,
909        });
910        let log = match tempfile::NamedTempFile::new_in(self.root.join("tmp")) {
911            Ok(log) => log,
912            Err(error) => return bypass(format!("cache log unavailable: {error}")),
913        };
914        let capture =
915            Capture::new(Some(log.as_file().try_clone()?), prepared.display.clone()).retain_log();
916        let restored = before(running);
917        // Only a recording `--record` made keys it: one made to audit the task
918        // is no decision to key results by what it read.
919        let keying = prepared
920            .recording
921            .as_ref()
922            .filter(|_| prepared.recording_keys);
923        let listed = match (&recordings, keying) {
924            (Some(_), Some(_)) => {
925                let project_root = workspace
926                    .projects
927                    .get(&task.project)
928                    .map_or("", |project| project.root.as_str());
929                recorded::Before::take(
930                    &workspace.root,
931                    unlisted.directories(project_root),
932                    &unlisted,
933                )
934            }
935            _ => recorded::Before::default(),
936        };
937        let started = recorded::now(&self.root.join("tmp"));
938        let executing = profile::span(&task.id, "execute");
939        let outcome = execute_captured(running, cancelled, Some(&capture))?;
940        drop(executing);
941        let warm_report = after(outcome, restored, running);
942        let inputs_unchanged =
943            self.inputs_match(snapshot, workspace, graph, task, prepared, &key, cancelled);
944        let execution = capture.take_log().map(|log| Execution {
945            log,
946            inputs_unchanged,
947        });
948        let mut recorded = unrecorded.map(|unrecorded| match &recordings {
949            Some((sets, _, _)) if !sets.is_empty() => Recorded::Missed,
950            _ => unrecorded,
951        });
952        let path_set = match (&recordings, keying) {
953            (Some((_, _, settings)), Some(recording)) => Some(
954                self.path_set(workspace, task, recording, prepared, settings, &own)
955                    .and_then(|set| {
956                        // Keyed by nothing, a recording would match any workspace.
957                        if set.is_empty() {
958                            return Err("its trace named no path in the workspace".to_owned());
959                        }
960                        match set
961                            .changed_since(&workspace.root, started, &unlisted, &listed)
962                            .map(|changed| changed.to_string())
963                        {
964                            Some(reason) => Err(reason),
965                            None => Ok(set),
966                        }
967                    }),
968            ),
969            _ => None,
970        };
971        if let Some(Err(reason)) = &path_set {
972            qk_executor::status!("qk: {}: not recorded ({reason})", task.id);
973            recorded = Some(Recorded::Failed {
974                reason: reason.clone(),
975            });
976        }
977        let mut published = false;
978        let kept = outcome == Outcome::Success && inputs_unchanged && capture.healthy();
979        if kept {
980            let publishing = profile::span(&task.id, "publish");
981            let publish = self.publish(&workspace.root, &key, &outputs, log.path());
982            drop(publishing);
983            match publish {
984                Ok(()) => {
985                    published = true;
986                    store::record_outputs(&workspace.root, &task.id, &key, &outputs);
987                    if let Some(remote) = &self.remote {
988                        remote.upload(&self.root, &key);
989                    }
990                    for strong in &replaced {
991                        if let Err(error) =
992                            self.publish(&workspace.root, strong, &outputs, log.path())
993                        {
994                            qk_executor::status!(
995                                "qk: {}: could not replace a recorded cache entry ({error})",
996                                task.id
997                            );
998                        }
999                    }
1000                }
1001                Err(error) => {
1002                    qk_executor::status!("qk: {}: could not save cache entry ({error})", task.id);
1003                }
1004            }
1005        }
1006        // Ran where a recording would have taken a result from the cache:
1007        // whether it gave that result, read before this run's own recording
1008        // can replace it.
1009        let check = match &shadow {
1010            Some(Shadow::WouldHit { key: recorded, .. }) => match outcome {
1011                Outcome::Failed(code) => Some(Check {
1012                    matched: false,
1013                    differences: vec![format!("failed (exit {code})")],
1014                }),
1015                Outcome::Success if published => {
1016                    let _checking = profile::span(&task.id, "recorded_check");
1017                    self.check(&task.id, &key, recorded)
1018                }
1019                _ => None,
1020            },
1021            _ => None,
1022        };
1023        if kept {
1024            // Its result is kept under the key of what it read, too.
1025            if let (Some((_, weak, _)), Some(Ok(set))) = (&recordings, path_set) {
1026                let saved = recorded::with_values(weak, set, snapshot, &workspace.root, &unlisted)
1027                    .and_then(|(strong, set)| {
1028                        self.publish(&workspace.root, &strong, &outputs, log.path())?;
1029                        recorded::save(&self.root, weak, set)?;
1030                        // Shared, the sets are merged with those the store
1031                        // held when this task looked it up.
1032                        if let Some(remote) = &self.remote {
1033                            remote.upload(&self.root, &strong);
1034                            if let Some(record) = recorded::record(&self.root, weak) {
1035                                remote.upload_path_sets(&self.root, weak, record);
1036                            }
1037                        }
1038                        Ok(strong)
1039                    });
1040                recorded = Some(match saved {
1041                    Ok(key) => Recorded::Saved { key },
1042                    Err(error) => {
1043                        qk_executor::status!("qk: {}: recording not saved ({error:#})", task.id);
1044                        Recorded::Failed {
1045                            reason: format!("{error:#}"),
1046                        }
1047                    }
1048                });
1049            }
1050        }
1051        let shadow = match shadow {
1052            Some(Shadow::WouldHit { key: recorded, .. }) => Some(Shadow::WouldHit {
1053                key: recorded,
1054                check,
1055            }),
1056            shadow => shadow,
1057        };
1058        Ok(TaskResult {
1059            outcome,
1060            cache: CacheStatus::Miss,
1061            key: keyed,
1062            warm: warm_report,
1063            execution,
1064            recorded,
1065            shadow,
1066        })
1067    }
1068
1069    /// Whether the result just published under `ran` is the one under
1070    /// `recorded`, here or in the remote store: `None` when that is not to
1071    /// be had.
1072    fn check(&self, task: &str, ran: &str, recorded: &str) -> Option<Check> {
1073        let compared = self
1074            .artifacts(ran, task)
1075            .and_then(|ran| Ok(ran.zip(self.artifacts(recorded, task)?)));
1076        match compared {
1077            Ok(Some((ran, recorded))) => {
1078                let differences = store::differences(&ran, &recorded, UNCOVERED);
1079                Some(Check {
1080                    matched: differences.is_empty(),
1081                    differences,
1082                })
1083            }
1084            Ok(None) => None,
1085            Err(error) => {
1086                qk_executor::status!(
1087                    "qk: {task}: not compared with its recorded result ({error:#})"
1088                );
1089                None
1090            }
1091        }
1092    }
1093
1094    /// `shadow`, naming what changed in the newest path set since it was
1095    /// recorded, or what it holds that the declared inputs leave out, when no
1096    /// recording would have hit.
1097    fn with_changes(
1098        &self,
1099        shadow: Option<Shadow>,
1100        recordings: Option<&(Vec<recorded::PathSet>, String, ultracache::Settings)>,
1101        (snapshot, workspace, graph, task, prepared, unlisted): (
1102            &hash::Snapshot,
1103            &Workspace,
1104            &TaskGraph,
1105            &Task,
1106            &PreparedTask,
1107            &recorded::Unlisted,
1108        ),
1109        cancelled: &AtomicBool,
1110    ) -> Option<Shadow> {
1111        let (Some(Shadow::WouldMiss { .. }), Some((sets, _, _))) = (&shadow, recordings) else {
1112            return shadow;
1113        };
1114        let Some(newest) = sets.first() else {
1115            return shadow;
1116        };
1117        let _explaining = profile::span(&task.id, "recorded_changes");
1118        let Ok(coverage) = hash::coverage(snapshot, workspace, graph, task, prepared, cancelled)
1119        else {
1120            return shadow;
1121        };
1122        let outputs = self
1123            .workspace_outputs
1124            .get_or_init(|| workspace_outputs(workspace));
1125        let output = |path: &str| outputs.matches(path);
1126        let uncovered: BTreeMap<&str, &str> = coverage
1127            .uncovered(snapshot, &workspace.root, newest, &output)
1128            .collect();
1129        let named = |paths: Vec<String>| {
1130            let more = paths.len().saturating_sub(UNCOVERED - 1);
1131            let mut paths = paths;
1132            if more > 1 {
1133                paths.truncate(UNCOVERED - 1);
1134                paths.push(format!("{more} more"));
1135            }
1136            paths
1137        };
1138        if newest.recorded.is_some() {
1139            let changed = newest
1140                .changed(snapshot, &workspace.root, unlisted)
1141                .unwrap_or_default()
1142                .into_iter()
1143                .map(|(path, how)| match uncovered.contains_key(path) {
1144                    true => format!("{path} ({how})"),
1145                    false => format!("{path} ({how}, declared)"),
1146                })
1147                .collect();
1148            return Some(Shadow::WouldMiss {
1149                changed: named(changed),
1150                uncovered: vec![],
1151            });
1152        }
1153        let uncovered = uncovered
1154            .into_iter()
1155            .map(|(path, how)| format!("{path} ({how})"))
1156            .collect();
1157        Some(Shadow::WouldMiss {
1158            changed: vec![],
1159            uncovered: named(uncovered),
1160        })
1161    }
1162
1163    /// Looks a task up by its recordings and restores what it finds: by each
1164    /// path set's key in the local cache, then, with none there, among the
1165    /// path sets and results the remote store holds, adding its path sets to
1166    /// `sets`.
1167    #[allow(clippy::too_many_arguments)]
1168    fn by_recordings(
1169        &self,
1170        workspace: &Workspace,
1171        task: &Task,
1172        prepared: &PreparedTask,
1173        snapshot: &hash::Snapshot,
1174        outputs: &paths::Outputs,
1175        unlisted: &recorded::Unlisted,
1176        sets: &mut Vec<recorded::PathSet>,
1177        weak: &str,
1178        declared: &str,
1179        cancelled: &AtomicBool,
1180    ) -> Found {
1181        let strong =
1182            |set: &recorded::PathSet| recording_key(weak, set, snapshot, workspace, unlisted, task);
1183        let restored = |strong: &str, shown| {
1184            self.restore(
1185                &workspace.root,
1186                &task.id,
1187                &task.dependencies,
1188                strong,
1189                outputs,
1190                &prepared.display,
1191                shown,
1192            )
1193            .unwrap_or_else(|error| {
1194                qk_executor::status!("qk: {}: ignoring unusable cache entry ({error})", task.id);
1195                false
1196            })
1197        };
1198        let local = |strong: &str| restored(strong, qk_executor::Shown::LocalCache);
1199        let mut keys = Vec::new();
1200        for set in sets.iter() {
1201            if let Some(strong) = strong(set) {
1202                if local(&strong) {
1203                    return Found::Local(strong);
1204                }
1205                keys.push(strong);
1206            }
1207        }
1208        let Some(remote) = &self.remote else {
1209            return Found::Nothing;
1210        };
1211        // Without path sets here, a result here under the declared key is
1212        // the task's, and asking the store for path sets would only delay
1213        // it, run after run, and have them shadow it.
1214        if sets.is_empty()
1215            && self
1216                .root
1217                .join("entries")
1218                .join(format!("{declared}.json"))
1219                .is_file()
1220        {
1221            return Found::Nothing;
1222        }
1223        // Path sets other machines recorded, new here.
1224        let fetched = remote
1225            .fetch_path_sets(&self.root, weak, &task.id)
1226            .and_then(|record| match record {
1227                Some(record) => recorded::merge(&self.root, weak, &record),
1228                None => Ok(Vec::new()),
1229            });
1230        match fetched {
1231            Ok(added) => {
1232                for set in added {
1233                    if let Some(strong) = strong(&set) {
1234                        if local(&strong) {
1235                            sets.push(set);
1236                            return Found::Local(strong);
1237                        }
1238                        keys.push(strong);
1239                    }
1240                    sets.push(set);
1241                }
1242            }
1243            Err(error) => {
1244                qk_executor::status!("qk: {}: remote path sets unavailable ({error:#})", task.id)
1245            }
1246        }
1247        for strong in keys {
1248            let found = remote
1249                .fetch(&self.root, &strong, &task.id, cancelled)
1250                .unwrap_or_else(|error| {
1251                    qk_executor::status!("qk: {}: remote cache unavailable ({error:#})", task.id);
1252                    false
1253                })
1254                && restored(&strong, qk_executor::Shown::RemoteCache);
1255            if found {
1256                return Found::Remote(strong);
1257            }
1258        }
1259        Found::Nothing
1260    }
1261
1262    /// Starts looking a task in mode `"record"` up by its recordings, without
1263    /// restoring anything: by each path set's key in the local cache, and with
1264    /// none there, by fetching the path sets the remote store holds on a
1265    /// thread of its own, for [`Self::settle_shadow`] to finish.
1266    fn start_shadow(
1267        &self,
1268        workspace: &Workspace,
1269        task: &Task,
1270        snapshot: &hash::Snapshot,
1271        unlisted: &recorded::Unlisted,
1272        sets: &[recorded::PathSet],
1273        weak: &str,
1274    ) -> Shadowing {
1275        let mut keys = Vec::new();
1276        for set in sets {
1277            if let Some(strong) = recording_key(weak, set, snapshot, workspace, unlisted, task) {
1278                if self.holds_entry(&strong) {
1279                    return Shadowing::Settled(Shadow::WouldHit {
1280                        key: strong,
1281                        check: None,
1282                    });
1283                }
1284                keys.push(strong);
1285            }
1286        }
1287        let Some(remote) = &self.remote else {
1288            return Shadowing::Settled(if sets.is_empty() {
1289                Shadow::Unrecorded
1290            } else {
1291                Shadow::WouldMiss {
1292                    changed: vec![],
1293                    uncovered: vec![],
1294                }
1295            });
1296        };
1297        let (remote, root, weak, id) = (
1298            std::sync::Arc::clone(remote),
1299            self.root.clone(),
1300            weak.to_owned(),
1301            task.id.clone(),
1302        );
1303        Shadowing::Fetching {
1304            keys,
1305            fetch: std::thread::spawn(move || remote.fetch_path_sets(&root, &weak, &id)),
1306        }
1307    }
1308
1309    /// What a task's recordings would have done: [`Self::start_shadow`]'s
1310    /// lookup, finished with the path sets the store held, which are added
1311    /// to `sets`, and the results it holds.
1312    #[allow(clippy::too_many_arguments)]
1313    fn settle_shadow(
1314        &self,
1315        shadowing: Shadowing,
1316        workspace: &Workspace,
1317        task: &Task,
1318        snapshot: &hash::Snapshot,
1319        unlisted: &recorded::Unlisted,
1320        sets: &mut Vec<recorded::PathSet>,
1321        weak: &str,
1322    ) -> Shadow {
1323        let (mut keys, fetch) = match shadowing {
1324            Shadowing::Settled(shadow) => return shadow,
1325            Shadowing::Fetching { keys, fetch } => (keys, fetch),
1326        };
1327        let fetched = fetch
1328            .join()
1329            .unwrap_or_else(|_| Err(anyhow::anyhow!("fetching them panicked")))
1330            .and_then(|record| match record {
1331                Some(record) => recorded::merge(&self.root, weak, &record),
1332                None => Ok(Vec::new()),
1333            });
1334        match fetched {
1335            Ok(added) => {
1336                for set in added {
1337                    if let Some(strong) =
1338                        recording_key(weak, &set, snapshot, workspace, unlisted, task)
1339                    {
1340                        if self.holds_entry(&strong) {
1341                            sets.push(set);
1342                            return Shadow::WouldHit {
1343                                key: strong,
1344                                check: None,
1345                            };
1346                        }
1347                        keys.push(strong);
1348                    }
1349                    sets.push(set);
1350                }
1351            }
1352            Err(error) => {
1353                qk_executor::status!("qk: {}: remote path sets unavailable ({error:#})", task.id)
1354            }
1355        }
1356        let remote = self
1357            .remote
1358            .as_ref()
1359            .expect("only a remote store is fetched from");
1360        keys.into_iter()
1361            .find(|strong| remote.holds(strong).unwrap_or(false))
1362            .map_or(
1363                if sets.is_empty() {
1364                    Shadow::Unrecorded
1365                } else {
1366                    Shadow::WouldMiss {
1367                        changed: vec![],
1368                        uncovered: vec![],
1369                    }
1370                },
1371                |key| Shadow::WouldHit { key, check: None },
1372            )
1373    }
1374
1375    /// Whether the local cache holds a result under `key`.
1376    fn holds_entry(&self, key: &str) -> bool {
1377        self.root
1378            .join("entries")
1379            .join(format!("{key}.json"))
1380            .is_file()
1381    }
1382
1383    /// The path set of a task's recording, or why there is none.
1384    fn path_set(
1385        &self,
1386        workspace: &Workspace,
1387        task: &Task,
1388        recording: &qk_executor::Recording,
1389        prepared: &PreparedTask,
1390        settings: &ultracache::Settings,
1391        own: &[&paths::Outputs],
1392    ) -> std::result::Result<recorded::PathSet, String> {
1393        let failed = |error: anyhow::Error| format!("{error:#}");
1394        let canonical = workspace
1395            .root
1396            .canonicalize()
1397            .map_err(|e| failed(e.into()))?;
1398        // Only what a path set can hold is kept and counted: a build reads
1399        // far more in node_modules and outside the workspace than in it.
1400        let keep = |path: &std::path::Path| {
1401            [&workspace.root, &canonical].iter().any(|root| {
1402                path.strip_prefix(root).is_ok_and(|relative| {
1403                    !relative.components().any(|component| {
1404                        matches!(
1405                            component.as_os_str().to_str(),
1406                            Some("node_modules" | ".git")
1407                        )
1408                    })
1409                })
1410            })
1411        };
1412        let limits = qk_input_analysis::strace::Limits {
1413            records: 1_000_000,
1414            bytes: 256 * 1024 * 1024,
1415            accesses: 500_000,
1416            keep: Some(&keep),
1417            // A path set names what a task read by the paths it named.
1418            resolved: false,
1419            // And names each once, whichever processes read it.
1420            distinct: true,
1421        };
1422        recording.settle().map_err(failed)?;
1423        let (accesses, diagnostics) =
1424            qk_input_analysis::strace::collect_within(&recording.directory, &prepared.cwd, &limits)
1425                .map_err(failed)?;
1426        // A partial trace would key the task by part of what it read.
1427        if let Some(diagnostic) = diagnostics.first() {
1428            return Err(diagnostic.clone());
1429        }
1430        let ignore = self
1431            .ignored_reads(workspace, task, prepared, &settings.ignore_reads)
1432            .map_err(failed)?;
1433        // qk's own directories, where they lie inside the workspace.
1434        let state = [self.root.clone(), worktree_state(&workspace.root)]
1435            .iter()
1436            .filter_map(|path| {
1437                [&workspace.root, &canonical]
1438                    .iter()
1439                    .find_map(|root| path.strip_prefix(root).ok())
1440                    .and_then(|relative| relative.to_str())
1441                    .map(|relative| relative.replace('\\', "/"))
1442            })
1443            .collect();
1444        let exclusions = recorded::Exclusions {
1445            roots: [&workspace.root, &canonical],
1446            outputs: own,
1447            ignore,
1448            state,
1449        };
1450        Ok(recorded::PathSet::from_accesses(
1451            &accesses,
1452            &exclusions,
1453            &canonical,
1454        ))
1455    }
1456
1457    /// Rechecks declared inputs for successful and failed executions alike.
1458    #[allow(clippy::too_many_arguments)]
1459    fn inputs_match(
1460        &self,
1461        snapshot: &hash::Snapshot,
1462        workspace: &Workspace,
1463        graph: &TaskGraph,
1464        task: &Task,
1465        prepared: &PreparedTask,
1466        before: &str,
1467        cancelled: &AtomicBool,
1468    ) -> bool {
1469        if cancelled.load(Ordering::SeqCst) {
1470            return false;
1471        }
1472        let _profile = profile::span(&task.id, "inputs_recheck");
1473        let after = hash::recheck_dependency_keys(snapshot, workspace, graph, task, cancelled)
1474            .and_then(|dependencies| {
1475                hash::fingerprint(
1476                    snapshot,
1477                    workspace,
1478                    task,
1479                    prepared,
1480                    &dependencies,
1481                    cancelled,
1482                )
1483            });
1484        if after.as_deref().ok() != Some(before) {
1485            if task.definition.cache == Some(true) {
1486                if self.reuse {
1487                    qk_executor::status!(
1488                        "qk: {}: not caching because inputs changed during execution",
1489                        task.id
1490                    );
1491                } else {
1492                    qk_executor::status!(
1493                        "qk: {}: excluding execution from flaky results because inputs changed during execution",
1494                        task.id
1495                    );
1496                }
1497            }
1498            return false;
1499        }
1500        true
1501    }
1502}
1503
1504/// A task's key, and the inputs it is computed from, or why it has none.
1505pub type Keyed = std::result::Result<(String, Value), String>;
1506
1507/// The key each task in `graph` would be cached under if it ran now, and what
1508/// it is computed from, without running a task. Runtime inputs are executed,
1509/// and the dependency outputs a task reads are taken as they are on disk.
1510pub fn keys(
1511    workspace: &Workspace,
1512    graph: &TaskGraph,
1513    prepared: &BTreeMap<String, PreparedTask>,
1514    cancelled: &AtomicBool,
1515) -> Result<BTreeMap<String, Keyed>> {
1516    let snapshot = hash::Snapshot::new(workspace, graph, &paths::cache_location(workspace))?;
1517    Ok(graph
1518        .tasks
1519        .values()
1520        .map(|task| {
1521            let keyed = key_now(&snapshot, workspace, graph, task, prepared, cancelled);
1522            (task.id.clone(), keyed)
1523        })
1524        .collect())
1525}
1526
1527fn key_now(
1528    snapshot: &hash::Snapshot,
1529    workspace: &Workspace,
1530    graph: &TaskGraph,
1531    task: &Task,
1532    prepared: &BTreeMap<String, PreparedTask>,
1533    cancelled: &AtomicBool,
1534) -> Keyed {
1535    let failed = |error: anyhow::Error| format!("{}: {error:#}", task.id);
1536    let dependencies =
1537        hash::dependency_keys(snapshot, workspace, graph, task, cancelled).map_err(failed)?;
1538    let inputs = hash::inputs(
1539        snapshot,
1540        workspace,
1541        task,
1542        &prepared[&task.id],
1543        &dependencies,
1544        cancelled,
1545    )
1546    .map_err(failed)?;
1547    Ok((hash::key(&inputs).map_err(failed)?, inputs))
1548}
1549
1550/// Runs the private detached-upload protocol used by the qk executable.
1551#[doc(hidden)]
1552pub fn run_upload_worker() -> anyhow::Result<()> {
1553    remote::background::run()
1554}
1555
1556/// The declared outputs of every target in the workspace, as their targets
1557/// declare them and as each of their configurations does with its options,
1558/// which can move an output; one whose outputs cannot be read is left out.
1559fn workspace_outputs(workspace: &Workspace) -> recorded::WorkspaceOutputs {
1560    let mut outputs = Vec::new();
1561    for (name, project) in &workspace.projects {
1562        for (target, definition) in &project.targets {
1563            let configured = definition
1564                .configurations
1565                .iter()
1566                .map(|(configuration, options)| {
1567                    let mut definition = definition.clone();
1568                    definition.options.extend(options.clone());
1569                    (Some(configuration.clone()), definition)
1570                });
1571            for (configuration, definition) in
1572                std::iter::once((None, definition.clone())).chain(configured)
1573            {
1574                let task = Task {
1575                    id: format!("{name}:{target}"),
1576                    project: name.clone(),
1577                    target: target.clone(),
1578                    configuration: configuration.clone(),
1579                    args: Vec::new(),
1580                    definition,
1581                    dependencies: Default::default(),
1582                    edges: Default::default(),
1583                };
1584                if let Some(declared) = paths::Outputs::new(workspace, &task)
1585                    .ok()
1586                    .filter(paths::Outputs::declared)
1587                {
1588                    outputs.push(((name.clone(), target.clone(), configuration), declared));
1589                }
1590            }
1591        }
1592    }
1593    let roots = workspace
1594        .projects
1595        .iter()
1596        .map(|(name, project)| (name.clone(), project.root.clone()))
1597        .collect();
1598    recorded::WorkspaceOutputs::new(outputs, &roots)
1599}
1600
1601/// The targets of every task `task` depends on, each with the configuration
1602/// it runs with. Its own target is not among them: its outputs are left out
1603/// of its listings as other targets' are.
1604fn upstream(graph: &TaskGraph, task: &Task) -> BTreeSet<recorded::TargetOf> {
1605    let of = |task: &Task| {
1606        (
1607            task.project.clone(),
1608            task.target.clone(),
1609            task.configuration.clone(),
1610        )
1611    };
1612    let mut targets = BTreeSet::new();
1613    let mut seen = BTreeSet::new();
1614    let mut pending: Vec<&String> = task.dependencies.iter().collect();
1615    while let Some(id) = pending.pop() {
1616        if !seen.insert(id) {
1617            continue;
1618        }
1619        if let Some(dependency) = graph.tasks.get(id) {
1620            targets.insert(of(dependency));
1621            pending.extend(&dependency.dependencies);
1622        }
1623    }
1624    targets
1625}
1626
1627#[cfg(test)]
1628mod tests {
1629    use super::*;
1630
1631    #[test]
1632    fn concurrent_blob_writes_keep_results_ordered_and_repair_corruption() {
1633        let workspace = tempfile::tempdir().unwrap();
1634        let root = workspace.path();
1635        let cache = Cache::new(root.join("cache"));
1636        cache.initialize().unwrap();
1637        let mut sources = Vec::new();
1638        for index in 0..130 {
1639            let source = root.join(format!("source-{index}"));
1640            std::fs::write(&source, format!("content-{}", index % 65)).unwrap();
1641            sources.push(source);
1642        }
1643        let digest = cache.put_blob(&sources[0]).unwrap();
1644        std::fs::write(cache.root.join("blobs").join(&digest), "corrupt").unwrap();
1645        std::fs::remove_file(&sources[31]).unwrap();
1646        let results = cache.put_blobs(&sources);
1647        assert_eq!(results.len(), sources.len());
1648        for (index, result) in results.into_iter().enumerate() {
1649            if index == 31 {
1650                assert_eq!(
1651                    result
1652                        .unwrap_err()
1653                        .downcast_ref::<std::io::Error>()
1654                        .unwrap()
1655                        .kind(),
1656                    std::io::ErrorKind::NotFound
1657                );
1658                continue;
1659            }
1660            let digest = result.unwrap();
1661            let held = cache.root.join("blobs").join(&digest);
1662            assert_eq!(
1663                std::fs::read(&held).unwrap(),
1664                format!("content-{}", index % 65).as_bytes()
1665            );
1666            assert_eq!(hash::digest_file(&held).unwrap(), digest);
1667        }
1668    }
1669
1670    /// Content the store holds already is reused, not copied over again.
1671    #[cfg(unix)]
1672    #[test]
1673    fn a_blob_the_store_holds_is_not_written_again() {
1674        use std::os::unix::fs::MetadataExt;
1675        let workspace = tempfile::tempdir().unwrap();
1676        let root = workspace.path();
1677        let cache = Cache::new(root.join(".qk/cache"));
1678        cache.initialize().unwrap();
1679        std::fs::write(root.join("first"), "same").unwrap();
1680        std::fs::write(root.join("second"), "same").unwrap();
1681        std::fs::write(root.join("other"), "different").unwrap();
1682        let first = cache.put_blob(&root.join("first")).unwrap();
1683        let blob = cache.root.join("blobs").join(&first);
1684        let stored = std::fs::metadata(&blob).unwrap().ino();
1685        assert_eq!(cache.put_blob(&root.join("second")).unwrap(), first);
1686        assert_eq!(std::fs::metadata(&blob).unwrap().ino(), stored);
1687        // A stored blob whose content no longer matches is stored anew.
1688        std::fs::write(&blob, "corrupt").unwrap();
1689        assert_eq!(cache.put_blob(&root.join("second")).unwrap(), first);
1690        assert_eq!(std::fs::read(&blob).unwrap(), b"same");
1691        // So is one that cannot be read.
1692        {
1693            use std::os::unix::fs::PermissionsExt;
1694            std::fs::set_permissions(&blob, std::fs::Permissions::from_mode(0o000)).unwrap();
1695        }
1696        assert_eq!(cache.put_blob(&root.join("second")).unwrap(), first);
1697        assert_eq!(std::fs::read(&blob).unwrap(), b"same");
1698        let other = cache.put_blob(&root.join("other")).unwrap();
1699        assert_ne!(other, first);
1700        assert_eq!(
1701            std::fs::read(cache.root.join("blobs").join(other)).unwrap(),
1702            b"different"
1703        );
1704    }
1705
1706    /// Complete-directory restore preserves contents, links, modes and
1707    /// timestamps, and the subsequent hit keeps restored files in place.
1708    #[test]
1709    fn complete_directory_restores_preserve_outputs() {
1710        let workspace = tempfile::tempdir().unwrap();
1711        let root = workspace.path();
1712        std::fs::create_dir_all(root.join("dist/nested/empty")).unwrap();
1713        std::fs::write(root.join("dist/nested/a.txt"), "a").unwrap();
1714        std::fs::write(root.join("dist/b.bin"), [0, 1, 2]).unwrap();
1715        std::fs::create_dir_all(root.join("bundle/empty")).unwrap();
1716        std::fs::write(root.join("bundle/readonly"), "readonly").unwrap();
1717        #[cfg(unix)]
1718        {
1719            use std::os::unix::fs::PermissionsExt;
1720            let script = root.join("dist/run.sh");
1721            std::fs::write(&script, "#!/bin/sh\n").unwrap();
1722            std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
1723            std::os::unix::fs::symlink("nested/a.txt", root.join("dist/link")).unwrap();
1724            std::fs::set_permissions(
1725                root.join("dist/nested"),
1726                std::fs::Permissions::from_mode(0o500),
1727            )
1728            .unwrap();
1729            std::fs::set_permissions(
1730                root.join("bundle/readonly"),
1731                std::fs::Permissions::from_mode(0o444),
1732            )
1733            .unwrap();
1734        }
1735        let log = root.join("log");
1736        std::fs::write(&log, "").unwrap();
1737
1738        let cache = Cache::new(root.join(".qk/cache"));
1739        cache.initialize().unwrap();
1740        let outputs = paths::Outputs::from_patterns(&["dist", "bundle"]);
1741        let key = "0".repeat(64);
1742        cache.publish(root, &key, &outputs, &log).unwrap();
1743
1744        #[cfg(unix)]
1745        {
1746            use std::os::unix::fs::PermissionsExt;
1747            // The manifest retains the restrictive mode; cleanup needs write
1748            // permission on the old directory to remove its files.
1749            std::fs::set_permissions(
1750                root.join("dist/nested"),
1751                std::fs::Permissions::from_mode(0o755),
1752            )
1753            .unwrap();
1754        }
1755        std::fs::remove_dir_all(root.join("dist")).unwrap();
1756        std::fs::remove_dir_all(root.join("bundle")).unwrap();
1757        let before = std::time::SystemTime::now();
1758        let restored = cache
1759            .restore(
1760                root,
1761                "app:build",
1762                &Default::default(),
1763                &key,
1764                &outputs,
1765                &qk_executor::Display::Stream,
1766                qk_executor::Shown::LocalCache,
1767            )
1768            .unwrap();
1769        assert!(restored);
1770        assert!(root.join("dist/nested/empty").is_dir());
1771        assert!(root.join("bundle/empty").is_dir());
1772        assert_eq!(
1773            std::fs::read_to_string(root.join("bundle/readonly")).unwrap(),
1774            "readonly"
1775        );
1776        #[cfg(unix)]
1777        {
1778            assert_eq!(
1779                std::fs::read_link(root.join("dist/link")).unwrap(),
1780                std::path::Path::new("nested/a.txt")
1781            );
1782            assert_eq!(
1783                store::mode(&std::fs::metadata(root.join("dist/run.sh")).unwrap()),
1784                0o755
1785            );
1786            assert_eq!(
1787                store::mode(&std::fs::metadata(root.join("dist/nested")).unwrap()),
1788                0o500
1789            );
1790            assert_eq!(
1791                store::mode(&std::fs::metadata(root.join("bundle/readonly")).unwrap()),
1792                0o444
1793            );
1794        }
1795        let path = root.join("dist/nested/a.txt");
1796        let modified = std::fs::metadata(&path).unwrap().modified().unwrap();
1797        assert!(modified >= before);
1798        let kept = cache
1799            .restore(
1800                root,
1801                "app:build",
1802                &Default::default(),
1803                &key,
1804                &outputs,
1805                &qk_executor::Display::Hidden,
1806                qk_executor::Shown::LocalCache,
1807            )
1808            .unwrap();
1809        assert!(kept);
1810        assert_eq!(
1811            std::fs::metadata(&path).unwrap().modified().unwrap(),
1812            modified
1813        );
1814        #[cfg(unix)]
1815        {
1816            use std::os::unix::fs::PermissionsExt;
1817            std::fs::set_permissions(
1818                root.join("dist/nested"),
1819                std::fs::Permissions::from_mode(0o755),
1820            )
1821            .unwrap();
1822        }
1823    }
1824
1825    #[test]
1826    fn listings_leave_out_other_targets_outputs_but_the_upstreams() {
1827        let temp = tempfile::tempdir().unwrap();
1828        let root = temp.path();
1829        std::fs::write(root.join("nx.json"), "{}").unwrap();
1830        std::fs::create_dir_all(root.join("app")).unwrap();
1831        std::fs::create_dir_all(root.join("lib")).unwrap();
1832        // A project at the workspace root, with an output in a folder of its
1833        // own and one inside another project.
1834        std::fs::write(
1835            root.join("project.json"),
1836            r#"{"name":"workspace","targets":{"cache":{"command":"true",
1837                "outputs":["{projectRoot}/.cache/build","{projectRoot}/app/extra/out"]}}}"#,
1838        )
1839        .unwrap();
1840        std::fs::write(
1841            root.join("app/project.json"),
1842            r#"{"name":"app","root":"./app","targets":{
1843                "build":{"command":"true","outputs":["{options.outputPath}"],
1844                  "options":{"outputPath":"app/dist"},
1845                  "configurations":{"production":{"outputPath":"app/dist-production"}}},
1846                "lint":{"command":"true"},
1847                "cover":{"command":"true","outputs":["{projectRoot}/.coverage/unit"]},
1848                "cover-browser":{"command":"true","outputs":["{projectRoot}/.coverage/browser"]},
1849                "wasm":{"command":"true","outputs":["{projectRoot}/target/wasm/release/*.wasm"]},
1850                "test":{"command":"true","dependsOn":[{"projects":["lib"],"target":"build"}],
1851                  "configurations":{"production":{}}}}}"#,
1852        )
1853        .unwrap();
1854        std::fs::write(
1855            root.join("lib/project.json"),
1856            r#"{"name":"lib","targets":{"build":{"command":"true","outputs":["{options.outputPath}"],
1857                "options":{"outputPath":"lib/dist"},
1858                "configurations":{"production":{"outputPath":"lib/dist-production"}}}}}"#,
1859        )
1860        .unwrap();
1861        let workspace = Workspace::load(root).unwrap();
1862        let graph = qk_taskgraph::TaskGraph::build(
1863            &workspace,
1864            &[
1865                qk_taskgraph::Request::parse("app:lint").unwrap(),
1866                qk_taskgraph::Request::parse("app:cover").unwrap(),
1867                qk_taskgraph::Request::parse("app:test").unwrap(),
1868                qk_taskgraph::Request::parse("app:test:production").unwrap(),
1869            ],
1870        )
1871        .unwrap();
1872        let outputs = workspace_outputs(&workspace);
1873        let others = |task: &str| recorded::Others {
1874            outputs: &outputs,
1875            upstream: upstream(&graph, &graph.tasks[task]),
1876        };
1877        let lint = others("app:lint");
1878        assert!(lint.matches("app/dist/main.js"));
1879        assert!(lint.matches("app/dist-production/main.js"));
1880        assert!(lint.matches("lib/dist"));
1881        assert!(!lint.matches("app/src/main.ts"));
1882        // The directories holding an output count as it, not its project's.
1883        assert!(lint.matches("app/target"));
1884        assert!(lint.matches("app/target/wasm"));
1885        assert!(!lint.matches("app"));
1886        // A root project's folders are top-level, but never another project.
1887        assert!(lint.matches(".cache"));
1888        assert!(lint.matches("app/extra"));
1889        assert!(!lint.matches("lib"));
1890        // Before the task runs, the directories those folders come and go
1891        // in are listed, another project's root among them.
1892        let unlisted = recorded::Unlisted {
1893            own: Vec::new(),
1894            others: Some(lint),
1895            ignore: &ultracache::IgnoredReads::none(),
1896        };
1897        let directories = unlisted.directories("lib");
1898        for directory in [".", ".cache", "app", "app/extra"] {
1899            assert!(directories.contains(directory), "{directory}");
1900        }
1901        // A dependency's output is the test's input, and only that of the
1902        // configuration it depends on.
1903        assert!(!others("app:test").matches("lib/dist"));
1904        assert!(others("app:test").matches("lib/dist-production"));
1905        // A folder holding the task's own output and another target's is
1906        // left out, as both are.
1907        assert!(others("app:cover").matches("app/.coverage"));
1908        let production = others("app:test:production");
1909        assert!(!production.matches("lib/dist-production"));
1910        assert!(production.matches("lib/dist"));
1911    }
1912}