Skip to main content

qk_cache/
lib.rs

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