Skip to main content

qk_cache/
lib.rs

1//! Local content-addressed cache shared by linked Git worktrees.
2
3mod evict;
4mod glob;
5mod hash;
6mod ignored;
7mod inputs;
8mod paths;
9pub mod profile;
10mod record;
11pub mod recorded;
12mod remote;
13mod store;
14pub mod ultracache;
15pub mod warm;
16
17use std::collections::BTreeMap;
18use std::path::PathBuf;
19use std::sync::OnceLock;
20use std::sync::atomic::{AtomicBool, Ordering};
21
22use anyhow::Result;
23use qk_config::Workspace;
24use qk_executor::{Capture, Outcome, PreparedTask, execute, execute_captured};
25use qk_taskgraph::{Task, TaskGraph};
26use serde_json::Value;
27
28pub use evict::{Pruned, max_size, parse_size, prune, prune_automatic};
29pub use glob::Pattern;
30pub use hash::{DependencyGlob, Selects, SourceIgnore, dependency_globs, source_files};
31pub use inputs::{
32    InputChange, InputChanges, JsonFields, ROOT_TSCONFIG_FILES, Resolution, Resolved, TsConfig,
33    manifest_for_dependents, resolve_each, resolve_tasks, selectively_hash_tsconfig,
34    without_resolution,
35};
36/// Expands `{projectRoot}` and `{workspaceRoot}` in a project's path or glob.
37pub use paths::expand as expand_project_path;
38pub use paths::{
39    Outputs, cache_directory, cache_location, clear_worktree_state, resolved_outputs,
40    worktree_state,
41};
42
43/// The cache for one run; its workspace snapshot is taken on first use.
44pub struct Cache {
45    pub root: PathBuf,
46    snapshot: OnceLock<std::result::Result<hash::Snapshot, String>>,
47    remote: Option<std::sync::Arc<remote::Remote>>,
48    /// Whether results and warm state may be restored or published.
49    reuse: bool,
50    /// The tasks whose outputs this run left exactly as they were.
51    kept: std::sync::Mutex<std::collections::BTreeSet<String>>,
52    /// Warm state being saved in the background.
53    saves: std::sync::Mutex<Vec<std::thread::JoinHandle<()>>>,
54    cleanup: std::sync::Mutex<Option<store::Cleanup>>,
55}
56
57/// Where a task's result came from.
58#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
59#[serde(rename_all = "kebab-case")]
60pub enum CacheStatus {
61    LocalHit,
62    RemoteHit,
63    Miss,
64    /// Ran without consulting the cache: not cacheable, bypassed or disabled.
65    Uncached,
66}
67
68/// One actual keyed execution, independent of reusable cache entries.
69pub struct Execution {
70    pub log: qk_executor::RecordedLog,
71    pub inputs_unchanged: bool,
72}
73
74pub struct TaskResult {
75    pub outcome: Outcome,
76    pub cache: CacheStatus,
77    /// The task's key and what it was computed from, when it has one.
78    pub key: Option<(String, Value)>,
79    /// What warm state did, for targets that keep it.
80    pub warm: Option<warm::WarmReport>,
81    pub execution: Option<Execution>,
82    /// How a task keyed by its recordings was keyed: see [`recorded`].
83    pub recorded: Option<Recorded>,
84    /// For a task with `qk:ultracache` mode `"record"`, what its recordings
85    /// would have done.
86    pub shadow: Option<Shadow>,
87}
88
89/// What the recordings of a task keyed by its declared inputs would have done.
90#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
91#[serde(rename_all = "camelCase", tag = "kind")]
92pub enum Shadow {
93    /// One of its path sets' keys had a result, here or in the remote store.
94    WouldHit { key: String },
95    /// None of its path sets' keys had a result.
96    WouldMiss,
97    /// It has no path set yet.
98    Unrecorded,
99}
100
101/// What a lookup by a task's recordings found.
102enum Found {
103    /// A result under this key in the local cache.
104    Local(String),
105    /// A result under this key in the remote store.
106    Remote(String),
107    Nothing,
108}
109
110/// How a task with `qk:ultracache` was keyed.
111#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
112#[serde(rename_all = "camelCase", tag = "kind")]
113pub enum Recorded {
114    /// One of its path sets' keys had a result, restored under that key.
115    Hit { key: String },
116    /// This run recorded it, and stored its result under its new key.
117    Saved { key: String },
118    /// None of its path sets' keys had a result, and this run did not record.
119    Missed,
120    /// It has no path set yet, and this run did not record one: it was keyed
121    /// by its declared inputs.
122    Unrecorded,
123    /// Recording it failed, with why.
124    Failed { reason: String },
125}
126
127impl TaskResult {
128    pub fn uncached(outcome: Outcome) -> Self {
129        Self {
130            outcome,
131            cache: CacheStatus::Uncached,
132            key: None,
133            warm: None,
134            execution: None,
135            recorded: None,
136            shadow: None,
137        }
138    }
139}
140
141impl Cache {
142    pub fn new(root: PathBuf) -> Self {
143        Self {
144            root,
145            snapshot: OnceLock::new(),
146            remote: None,
147            reuse: true,
148            kept: Default::default(),
149            saves: Default::default(),
150            cleanup: Default::default(),
151        }
152    }
153
154    /// Records verified executions without configuring or using result stores.
155    pub fn for_observation(workspace: &Workspace) -> Self {
156        Self {
157            reuse: false,
158            ..Self::new(paths::cache_location(workspace))
159        }
160    }
161
162    /// The workspace's cache, with the remote store its nx.json `s3`,
163    /// `storeServer` or `qk:storeServer` key configures, if any. Credentials
164    /// and modes are read from `environment`, which includes the workspace's
165    /// dotenv files. A remote store that cannot be used is reported and left
166    /// out; the local cache still works.
167    pub fn for_workspace(
168        workspace: &Workspace,
169        environment: &BTreeMap<std::ffi::OsString, std::ffi::OsString>,
170    ) -> Self {
171        let root = paths::cache_location(workspace);
172        let extra = &workspace.config.extra;
173        // Nx never reads `qk:storeServer`, so a workspace can name a store
174        // server for qk while Nx's own cache plugin keeps `s3`.
175        let (s3, server) = match extra.get("qk:storeServer") {
176            Some(server) => (None, Some(server)),
177            None => (extra.get("s3"), extra.get("storeServer")),
178        };
179        let remote = match remote::configure(s3, server, environment) {
180            Ok(remote) => remote.map(std::sync::Arc::new),
181            Err(error) => {
182                qk_executor::status!("qk: remote cache disabled: {error:#}");
183                None
184            }
185        };
186        if let Some(remote) = &remote {
187            remote.read_index(&root);
188        }
189        Self {
190            remote,
191            ..Self::new(root)
192        }
193    }
194
195    /// Saves what later runs reuse and waits for background saves, uploads
196    /// and retired-output cleanup, reporting failures.
197    pub fn finish(&self, workspace: &Workspace) {
198        if !self.reuse {
199            return;
200        }
201        if let Some(Ok(snapshot)) = self.snapshot.get() {
202            snapshot.save_digests(&workspace.root);
203        }
204        // Before the uploads, which a save may add to.
205        for save in std::mem::take(&mut *self.saves.lock().unwrap()) {
206            let _ = save.join();
207        }
208        if let Some(remote) = &self.remote {
209            let failures = remote.finish();
210            for failure in &failures {
211                qk_executor::status!("qk: remote cache upload failed for {failure}");
212            }
213        }
214        let _profile = profile::span("cache", "restore_cleanup_wait");
215        self.cleanup.lock().unwrap().take();
216    }
217
218    fn snapshot(
219        &self,
220        workspace: &Workspace,
221        graph: &TaskGraph,
222    ) -> std::result::Result<&hash::Snapshot, String> {
223        self.snapshot
224            .get_or_init(|| {
225                hash::Snapshot::new(workspace, graph, &self.root)
226                    .map_err(|error| format!("cannot snapshot the workspace: {error:#}"))
227            })
228            .as_ref()
229            .map_err(Clone::clone)
230    }
231
232    pub fn run(
233        &self,
234        workspace: &Workspace,
235        graph: &TaskGraph,
236        task: &Task,
237        prepared: &PreparedTask,
238        cancelled: &AtomicBool,
239    ) -> Result<TaskResult> {
240        let result = self.run_task(workspace, graph, task, prepared, cancelled);
241        if let Some(Ok(snapshot)) = self.snapshot.get() {
242            snapshot.outputs_written(workspace, task);
243        }
244        result
245    }
246
247    fn run_task(
248        &self,
249        workspace: &Workspace,
250        graph: &TaskGraph,
251        task: &Task,
252        prepared: &PreparedTask,
253        cancelled: &AtomicBool,
254    ) -> Result<TaskResult> {
255        let cacheable = task.definition.cache == Some(true);
256        // Held until the task is done, whether it ran or came from the cache.
257        let _kept = self
258            .reuse
259            .then(|| warm::keep_across(workspace, graph, task));
260        let entries = if self.reuse {
261            warm::config(workspace, task).unwrap_or_else(|error| {
262                qk_executor::status!("qk: {}: warm state ignored ({error:#})", task.id);
263                Vec::new()
264            })
265        } else {
266            Vec::new()
267        };
268        // Names an entry in messages: the group it shares, if any.
269        let entry_label = |warm: &warm::Warm| match warm.group() {
270            Some(group) => format!(" (group {group})"),
271            None => String::new(),
272        };
273        // The warm variables point tools at their state; they are not inputs,
274        // so the key is computed from the task without them.
275        let mut running = prepared.clone();
276        for warm in &entries {
277            for (name, value) in &warm.env {
278                running.execution.insert(name.into(), value.into());
279            }
280        }
281        // What each entry restored, and which groups were on disk already.
282        let before = |running: &PreparedTask| -> (Vec<warm::Restored>, Vec<String>) {
283            let (mut restored, mut present) = (Vec::new(), Vec::new());
284            for warm in &entries {
285                match self
286                    .initialize()
287                    .and_then(|()| self.restore_warm(workspace, task, warm, running))
288                {
289                    Ok((entry, groups)) => {
290                        if !entry.groups.is_empty() {
291                            restored.push(entry);
292                        }
293                        present.extend(groups);
294                    }
295                    Err(error) => qk_executor::status!(
296                        "qk: {}: warm state{} not restored ({error:#})",
297                        task.id,
298                        entry_label(warm)
299                    ),
300                }
301            }
302            (restored, present)
303        };
304        let after = |outcome: Outcome,
305                     (restored, present): (Vec<warm::Restored>, Vec<String>),
306                     running: &PreparedTask|
307         -> Option<warm::WarmReport> {
308            if entries.is_empty() {
309                return None;
310            }
311            let mut save_ms = None;
312            let mut background = false;
313            for warm in entries.iter().filter(|_| outcome == Outcome::Success) {
314                let started = std::time::Instant::now();
315                let saved = if warm.background {
316                    self.save_warm_in_background(workspace, task, warm, running)
317                } else {
318                    self.save_warm(workspace, task, warm, running)
319                };
320                match saved {
321                    Ok(()) if warm.background => background = true,
322                    Ok(()) => {
323                        *save_ms.get_or_insert(0) += started.elapsed().as_millis() as u64;
324                    }
325                    Err(error) => qk_executor::status!(
326                        "qk: {}: warm state{} not saved ({error:#})",
327                        task.id,
328                        entry_label(warm)
329                    ),
330                }
331            }
332            Some(warm::WarmReport {
333                restored,
334                present,
335                save_ms,
336                background,
337            })
338        };
339        // Reports why this task runs uncached.
340        let bypass = |reason: String| {
341            if cacheable {
342                qk_executor::status!("qk: {}: cache bypassed ({reason})", task.id);
343            }
344            let _profile = profile::span(&task.id, "execute");
345            execute(prepared, cancelled).map(TaskResult::uncached)
346        };
347        let snapshot = match self.snapshot(workspace, graph) {
348            Ok(snapshot) => snapshot,
349            Err(reason) => return bypass(reason),
350        };
351        let dependency_profile = profile::span(&task.id, "dependency_inputs");
352        let dependencies = match hash::dependency_keys(snapshot, workspace, graph, task, cancelled)
353        {
354            Ok(dependencies) => dependencies,
355            Err(error) => return bypass(format!("{error:#}")),
356        };
357        drop(dependency_profile);
358        let input_profile = profile::span(&task.id, "inputs");
359        let (key, inputs) = match hash::inputs(
360            snapshot,
361            workspace,
362            task,
363            prepared,
364            &dependencies,
365            cancelled,
366        )
367        .and_then(|inputs| Ok((hash::key(&inputs)?, inputs)))
368        {
369            Ok(key) => key,
370            Err(error) => return bypass(format!("{error:#}")),
371        };
372        drop(input_profile);
373        let keyed = Some((key.clone(), inputs));
374        // As in Nx, the task sees its hash.
375        running
376            .execution
377            .insert("NX_TASK_HASH".into(), key.clone().into());
378        let running = &running;
379        let outputs = match paths::Outputs::new(workspace, task) {
380            Ok(outputs) => outputs,
381            Err(error) => return bypass(format!("{error:#}")),
382        };
383        if !cacheable {
384            let restored = before(running);
385            let executing = profile::span(&task.id, "execute");
386            let outcome = execute(running, cancelled)?;
387            drop(executing);
388            let warm_report = after(outcome, restored, running);
389            return Ok(TaskResult {
390                outcome,
391                cache: CacheStatus::Uncached,
392                key: keyed,
393                warm: warm_report,
394                execution: None,
395                recorded: None,
396                shadow: None,
397            });
398        }
399        if !self.reuse {
400            let capture = Capture::new(None, prepared.display.clone()).retain_log();
401            let executing = profile::span(&task.id, "execute");
402            let outcome = execute_captured(running, cancelled, Some(&capture))?;
403            drop(executing);
404            let inputs_unchanged =
405                self.inputs_match(snapshot, workspace, graph, task, prepared, &key, cancelled);
406            return Ok(TaskResult {
407                outcome,
408                cache: CacheStatus::Uncached,
409                key: keyed,
410                warm: None,
411                execution: capture.take_log().map(|log| Execution {
412                    log,
413                    inputs_unchanged,
414                }),
415                recorded: None,
416                shadow: None,
417            });
418        }
419        if let Err(error) = self.initialize() {
420            return bypass(format!("cache unavailable: {error}"));
421        }
422        let (_lock, waited) = match self.lock(&key, cancelled) {
423            Ok(Some(lock)) => lock,
424            Ok(None) => {
425                return Ok(TaskResult::uncached(Outcome::Cancelled));
426            }
427            Err(error) => return bypass(format!("cache lock unavailable: {error}")),
428        };
429        // Inputs may have changed while another worktree held this key's lock.
430        if waited
431            && hash::recheck_dependency_keys(snapshot, workspace, graph, task, cancelled)
432                .and_then(|dependencies| {
433                    hash::fingerprint(
434                        snapshot,
435                        workspace,
436                        task,
437                        prepared,
438                        &dependencies,
439                        cancelled,
440                    )
441                })
442                .ok()
443                .as_ref()
444                != Some(&key)
445        {
446            return bypass("inputs changed while waiting for another run".into());
447        }
448        // Keyed by its recordings, a task's path sets stand in for its
449        // declared file inputs: see `recorded`.
450        let settings = ultracache::Settings::of(task).unwrap_or_else(|error| {
451            qk_executor::status!("qk: {}: qk:ultracache ignored ({error:#})", task.id);
452            None
453        });
454        let warm_outputs = paths::Outputs::from_paths(&warm::kept_paths(&entries)).ok();
455        let own: Vec<&paths::Outputs> = std::iter::once(&outputs)
456            .chain(warm_outputs.as_ref())
457            .collect();
458        // What its recordings' listings leave out, by the workspace's
459        // configuration rather than what this run schedules, so that the
460        // same recording keys the same in any run.
461        let siblings = settings
462            .as_ref()
463            .map(|_| project_outputs(workspace, task))
464            .unwrap_or_default();
465        let ignore: Vec<Pattern> = settings
466            .as_ref()
467            .map(|settings| {
468                settings
469                    .ignore_reads
470                    .iter()
471                    .filter_map(|glob| Pattern::new(glob, false).ok())
472                    .collect()
473            })
474            .unwrap_or_default();
475        let unlisted = recorded::Unlisted {
476            own: own.clone(),
477            siblings: &siblings,
478            ignore: &ignore,
479        };
480        let inputs = &keyed.as_ref().expect("a cacheable task is keyed").1;
481        let mut recordings = settings.and_then(|settings| {
482            let weak = hash::weak_key(
483                snapshot,
484                workspace,
485                graph,
486                task,
487                prepared,
488                inputs,
489                &settings.ignore_reads,
490                cancelled,
491            );
492            match weak {
493                Ok(weak) => Some((recorded::load(&self.root, &weak), weak, settings)),
494                Err(error) => {
495                    qk_executor::status!(
496                        "qk: {}: not keyed by its recordings ({error:#})",
497                        task.id
498                    );
499                    None
500                }
501            }
502        });
503        let mut shadow = None;
504        if let Some((sets, weak, settings)) = &mut recordings {
505            let recorded_profile = profile::span(&task.id, "recorded_restore");
506            let keyed_by = settings.mode == ultracache::Mode::On;
507            let found = self.by_recordings(
508                workspace, task, prepared, snapshot, &outputs, &unlisted, sets, weak, &key,
509                keyed_by, cancelled,
510            );
511            drop(recorded_profile);
512            match (keyed_by, found) {
513                (true, found @ (Found::Local(_) | Found::Remote(_))) => {
514                    let (strong, remote) = match found {
515                        Found::Local(strong) => {
516                            if let Some(remote) = &self.remote {
517                                remote.held(&strong);
518                            }
519                            (strong, None)
520                        }
521                        Found::Remote(strong) => (strong, Some(())),
522                        Found::Nothing => unreachable!(),
523                    };
524                    recorded::touch(&self.root, weak);
525                    qk_executor::report::event(qk_executor::report::Event::Cache {
526                        id: task.id.clone(),
527                        cached: if remote.is_some() {
528                            qk_executor::report::Cached::RemoteHit
529                        } else {
530                            qk_executor::report::Cached::Hit
531                        },
532                    });
533                    return Ok(TaskResult {
534                        outcome: Outcome::Success,
535                        cache: if remote.is_some() {
536                            CacheStatus::RemoteHit
537                        } else {
538                            CacheStatus::LocalHit
539                        },
540                        key: keyed,
541                        warm: None,
542                        execution: None,
543                        recorded: Some(Recorded::Hit { key: strong }),
544                        shadow: None,
545                    });
546                }
547                (true, Found::Nothing) => {}
548                (false, Found::Local(strong) | Found::Remote(strong)) => {
549                    shadow = Some(Shadow::WouldHit { key: strong });
550                }
551                (false, Found::Nothing) if sets.is_empty() => shadow = Some(Shadow::Unrecorded),
552                (false, Found::Nothing) => shadow = Some(Shadow::WouldMiss),
553            }
554        }
555        // A task with path sets is keyed by them alone: its declared inputs
556        // are what a recording found wanting. In mode "record" it is keyed by
557        // its declared inputs all the same.
558        let declared = recordings.as_ref().is_none_or(|(sets, _, settings)| {
559            sets.is_empty() || settings.mode == ultracache::Mode::Record
560        });
561        // How its recordings keyed it, unless it runs and records.
562        let unrecorded = recordings
563            .as_ref()
564            .filter(|(_, _, settings)| settings.mode == ultracache::Mode::On)
565            .map(|_| Recorded::Unrecorded);
566        // An entry fetched ahead of this task is local, but it came from the
567        // remote store, and is reported as such.
568        let prefetched = declared
569            && self.remote.as_ref().is_some_and(|remote| {
570                remote.resolved(&key);
571                remote.prefetched(&key)
572            });
573        let local_profile = profile::span(&task.id, "local_restore");
574        let restored = if declared {
575            self.restore(
576                &workspace.root,
577                &task.id,
578                &task.dependencies,
579                &key,
580                &outputs,
581                &prepared.display,
582                if prefetched {
583                    qk_executor::Shown::RemoteCache
584                } else {
585                    qk_executor::Shown::LocalCache
586                },
587            )
588        } else {
589            Ok(false)
590        };
591        match restored {
592            Ok(true) => {
593                let claimed = match &self.remote {
594                    Some(remote) if prefetched && remote.claim(&key) => true,
595                    Some(remote) => {
596                        remote.held(&key);
597                        false
598                    }
599                    None => false,
600                };
601                qk_executor::report::event(qk_executor::report::Event::Cache {
602                    id: task.id.clone(),
603                    cached: if claimed {
604                        qk_executor::report::Cached::RemoteHit
605                    } else {
606                        qk_executor::report::Cached::Hit
607                    },
608                });
609                return Ok(TaskResult {
610                    outcome: Outcome::Success,
611                    cache: if claimed {
612                        CacheStatus::RemoteHit
613                    } else {
614                        CacheStatus::LocalHit
615                    },
616                    key: keyed,
617                    warm: None,
618                    execution: None,
619                    recorded: unrecorded,
620                    shadow,
621                });
622            }
623            Ok(false) => {}
624            Err(error) => {
625                qk_executor::status!("qk: {}: ignoring unusable cache entry ({error})", task.id)
626            }
627        }
628        drop(local_profile);
629        if let Some(remote) = self.remote.as_ref().filter(|_| declared) {
630            let fetch_profile = profile::span(&task.id, "remote_fetch");
631            let fetched = remote.fetch(&self.root, &key, &task.id, cancelled);
632            drop(fetch_profile);
633            let fetched = fetched.and_then(|found| {
634                if !found {
635                    return Ok(false);
636                }
637                self.restore(
638                    &workspace.root,
639                    &task.id,
640                    &task.dependencies,
641                    &key,
642                    &outputs,
643                    &prepared.display,
644                    qk_executor::Shown::RemoteCache,
645                )
646            });
647            match fetched {
648                Ok(true) => {
649                    qk_executor::report::event(qk_executor::report::Event::Cache {
650                        id: task.id.clone(),
651                        cached: qk_executor::report::Cached::RemoteHit,
652                    });
653                    return Ok(TaskResult {
654                        outcome: Outcome::Success,
655                        cache: CacheStatus::RemoteHit,
656                        key: keyed,
657                        warm: None,
658                        execution: None,
659                        recorded: unrecorded,
660                        shadow,
661                    });
662                }
663                Ok(false) => {}
664                Err(error) => {
665                    qk_executor::status!("qk: {}: remote cache unavailable ({error:#})", task.id)
666                }
667            }
668        }
669        qk_executor::report::event(qk_executor::report::Event::Cache {
670            id: task.id.clone(),
671            cached: qk_executor::report::Cached::Miss,
672        });
673        let log = match tempfile::NamedTempFile::new_in(self.root.join("tmp")) {
674            Ok(log) => log,
675            Err(error) => return bypass(format!("cache log unavailable: {error}")),
676        };
677        let capture =
678            Capture::new(Some(log.as_file().try_clone()?), prepared.display.clone()).retain_log();
679        let restored = before(running);
680        let listed = match (&recordings, &prepared.recording) {
681            (Some(_), Some(_)) => {
682                let project_root = workspace
683                    .projects
684                    .get(&task.project)
685                    .map_or("", |project| project.root.as_str());
686                recorded::Before::take(
687                    &workspace.root,
688                    unlisted.directories(project_root),
689                    &unlisted,
690                )
691            }
692            _ => recorded::Before::default(),
693        };
694        let started = recorded::now(&self.root.join("tmp"));
695        let executing = profile::span(&task.id, "execute");
696        let outcome = execute_captured(running, cancelled, Some(&capture))?;
697        drop(executing);
698        let warm_report = after(outcome, restored, running);
699        let inputs_unchanged =
700            self.inputs_match(snapshot, workspace, graph, task, prepared, &key, cancelled);
701        let execution = capture.take_log().map(|log| Execution {
702            log,
703            inputs_unchanged,
704        });
705        let mut recorded = unrecorded.map(|unrecorded| match &recordings {
706            Some((sets, _, _)) if !sets.is_empty() => Recorded::Missed,
707            _ => unrecorded,
708        });
709        let path_set = match (&recordings, &prepared.recording) {
710            (Some((_, _, settings)), Some(recording)) => Some(
711                self.path_set(workspace, recording, prepared, settings, &own)
712                    .and_then(|set| {
713                        // Keyed by nothing, a recording would match any workspace.
714                        if set.is_empty() {
715                            return Err("its trace named no path in the workspace".to_owned());
716                        }
717                        match set.changed_since(&workspace.root, started, &unlisted, &listed) {
718                            Some(path) => Err(format!("{path} changed while it ran")),
719                            None => Ok(set),
720                        }
721                    }),
722            ),
723            _ => None,
724        };
725        if let Some(Err(reason)) = &path_set {
726            qk_executor::status!("qk: {}: not recorded ({reason})", task.id);
727            recorded = Some(Recorded::Failed {
728                reason: reason.clone(),
729            });
730        }
731        if outcome == Outcome::Success && inputs_unchanged && capture.healthy() {
732            let publishing = profile::span(&task.id, "publish");
733            let published = self.publish(&workspace.root, &key, &outputs, log.path());
734            drop(publishing);
735            match published {
736                Ok(()) => {
737                    store::record_outputs(&workspace.root, &task.id, &key, &outputs);
738                    if let Some(remote) = &self.remote {
739                        remote.upload(&self.root, &key);
740                    }
741                }
742                Err(error) => {
743                    qk_executor::status!("qk: {}: could not save cache entry ({error})", task.id);
744                }
745            }
746            // Its result is kept under the key of what it read, too.
747            if let (Some((_, weak, _)), Some(Ok(set))) = (&recordings, path_set) {
748                let saved = recorded::strong_key(weak, &set, snapshot, &workspace.root, &unlisted)
749                    .and_then(|strong| {
750                        self.publish(&workspace.root, &strong, &outputs, log.path())?;
751                        recorded::save(&self.root, weak, set)?;
752                        // Shared, the sets are merged with those the store
753                        // held when this task looked it up.
754                        if let Some(remote) = &self.remote {
755                            remote.upload(&self.root, &strong);
756                            if let Some(record) = recorded::record(&self.root, weak) {
757                                remote.upload_path_sets(&self.root, weak, record);
758                            }
759                        }
760                        Ok(strong)
761                    });
762                recorded = Some(match saved {
763                    Ok(key) => Recorded::Saved { key },
764                    Err(error) => {
765                        qk_executor::status!("qk: {}: recording not saved ({error:#})", task.id);
766                        Recorded::Failed {
767                            reason: format!("{error:#}"),
768                        }
769                    }
770                });
771            }
772        }
773        Ok(TaskResult {
774            outcome,
775            cache: CacheStatus::Miss,
776            key: keyed,
777            warm: warm_report,
778            execution,
779            recorded,
780            shadow,
781        })
782    }
783
784    /// Looks a task up by its recordings: by each path set's key in the local
785    /// cache, then, with none there, among the path sets and results the
786    /// remote store holds, adding its path sets to `sets`. With `restore`, a
787    /// result found is restored; without, the lookup only says whether there
788    /// is one.
789    #[allow(clippy::too_many_arguments)]
790    fn by_recordings(
791        &self,
792        workspace: &Workspace,
793        task: &Task,
794        prepared: &PreparedTask,
795        snapshot: &hash::Snapshot,
796        outputs: &paths::Outputs,
797        unlisted: &recorded::Unlisted,
798        sets: &mut Vec<recorded::PathSet>,
799        weak: &str,
800        declared: &str,
801        restore: bool,
802        cancelled: &AtomicBool,
803    ) -> Found {
804        let strong = |set: &recorded::PathSet| {
805            recorded::strong_key(weak, set, snapshot, &workspace.root, unlisted)
806                .inspect_err(|error| {
807                    qk_executor::status!("qk: {}: ignoring a path set ({error:#})", task.id);
808                })
809                .ok()
810        };
811        let restored = |strong: &str, shown| {
812            self.restore(
813                &workspace.root,
814                &task.id,
815                &task.dependencies,
816                strong,
817                outputs,
818                &prepared.display,
819                shown,
820            )
821            .unwrap_or_else(|error| {
822                qk_executor::status!("qk: {}: ignoring unusable cache entry ({error})", task.id);
823                false
824            })
825        };
826        let local = |strong: &str| {
827            if restore {
828                restored(strong, qk_executor::Shown::LocalCache)
829            } else {
830                self.root
831                    .join("entries")
832                    .join(format!("{strong}.json"))
833                    .is_file()
834            }
835        };
836        let mut keys = Vec::new();
837        for set in sets.iter() {
838            if let Some(strong) = strong(set) {
839                if local(&strong) {
840                    return Found::Local(strong);
841                }
842                keys.push(strong);
843            }
844        }
845        let Some(remote) = &self.remote else {
846            return Found::Nothing;
847        };
848        // Without path sets here, a result here under the declared key is
849        // the task's, and asking the store for path sets would only delay
850        // it, run after run, and have them shadow it. Looking up without
851        // restoring, for mode "record", the lookup is all there is to do.
852        if restore
853            && sets.is_empty()
854            && self
855                .root
856                .join("entries")
857                .join(format!("{declared}.json"))
858                .is_file()
859        {
860            return Found::Nothing;
861        }
862        // Path sets other machines recorded, new here.
863        let fetched = remote
864            .fetch_path_sets(&self.root, weak, &task.id)
865            .and_then(|record| match record {
866                Some(record) => recorded::merge(&self.root, weak, &record),
867                None => Ok(Vec::new()),
868            });
869        match fetched {
870            Ok(added) => {
871                for set in added {
872                    if let Some(strong) = strong(&set) {
873                        if local(&strong) {
874                            sets.push(set);
875                            return Found::Local(strong);
876                        }
877                        keys.push(strong);
878                    }
879                    sets.push(set);
880                }
881            }
882            Err(error) => {
883                qk_executor::status!("qk: {}: remote path sets unavailable ({error:#})", task.id)
884            }
885        }
886        for strong in keys {
887            let found = if restore {
888                remote
889                    .fetch(&self.root, &strong, &task.id, cancelled)
890                    .unwrap_or_else(|error| {
891                        qk_executor::status!(
892                            "qk: {}: remote cache unavailable ({error:#})",
893                            task.id
894                        );
895                        false
896                    })
897                    && restored(&strong, qk_executor::Shown::RemoteCache)
898            } else {
899                remote.holds(&strong).unwrap_or(false)
900            };
901            if found {
902                return Found::Remote(strong);
903            }
904        }
905        Found::Nothing
906    }
907
908    /// The path set of a task's recording, or why there is none.
909    fn path_set(
910        &self,
911        workspace: &Workspace,
912        recording: &qk_executor::Recording,
913        prepared: &PreparedTask,
914        settings: &ultracache::Settings,
915        own: &[&paths::Outputs],
916    ) -> std::result::Result<recorded::PathSet, String> {
917        let failed = |error: anyhow::Error| format!("{error:#}");
918        let canonical = workspace
919            .root
920            .canonicalize()
921            .map_err(|e| failed(e.into()))?;
922        // Only what a path set can hold is kept and counted: a build reads
923        // far more in node_modules and outside the workspace than in it.
924        let keep = |path: &std::path::Path| {
925            [&workspace.root, &canonical].iter().any(|root| {
926                path.strip_prefix(root).is_ok_and(|relative| {
927                    !relative.components().any(|component| {
928                        matches!(
929                            component.as_os_str().to_str(),
930                            Some("node_modules" | ".git")
931                        )
932                    })
933                })
934            })
935        };
936        let limits = qk_input_analysis::strace::Limits {
937            records: 1_000_000,
938            bytes: 256 * 1024 * 1024,
939            accesses: 500_000,
940            keep: Some(&keep),
941        };
942        let (accesses, diagnostics) =
943            qk_input_analysis::strace::collect_within(&recording.directory, &prepared.cwd, &limits)
944                .map_err(failed)?;
945        // A partial trace would key the task by part of what it read.
946        if let Some(diagnostic) = diagnostics.first() {
947            return Err(diagnostic.clone());
948        }
949        let ignore = settings
950            .ignore_reads
951            .iter()
952            .map(|glob| Pattern::new(glob, false))
953            .collect::<Result<Vec<_>>>()
954            .map_err(failed)?;
955        // qk's own directories, where they lie inside the workspace.
956        let state = [self.root.clone(), worktree_state(&workspace.root)]
957            .iter()
958            .filter_map(|path| {
959                [&workspace.root, &canonical]
960                    .iter()
961                    .find_map(|root| path.strip_prefix(root).ok())
962                    .and_then(|relative| relative.to_str())
963                    .map(|relative| relative.replace('\\', "/"))
964            })
965            .collect();
966        let exclusions = recorded::Exclusions {
967            roots: [&workspace.root, &canonical],
968            outputs: own,
969            ignore,
970            state,
971        };
972        Ok(recorded::PathSet::from_accesses(
973            &accesses,
974            &exclusions,
975            &canonical,
976        ))
977    }
978
979    /// Rechecks declared inputs for successful and failed executions alike.
980    #[allow(clippy::too_many_arguments)]
981    fn inputs_match(
982        &self,
983        snapshot: &hash::Snapshot,
984        workspace: &Workspace,
985        graph: &TaskGraph,
986        task: &Task,
987        prepared: &PreparedTask,
988        before: &str,
989        cancelled: &AtomicBool,
990    ) -> bool {
991        if cancelled.load(Ordering::SeqCst) {
992            return false;
993        }
994        let _profile = profile::span(&task.id, "inputs_recheck");
995        let after = hash::recheck_dependency_keys(snapshot, workspace, graph, task, cancelled)
996            .and_then(|dependencies| {
997                hash::fingerprint(
998                    snapshot,
999                    workspace,
1000                    task,
1001                    prepared,
1002                    &dependencies,
1003                    cancelled,
1004                )
1005            });
1006        if after.as_deref().ok() != Some(before) {
1007            if task.definition.cache == Some(true) {
1008                if self.reuse {
1009                    qk_executor::status!(
1010                        "qk: {}: not caching because inputs changed during execution",
1011                        task.id
1012                    );
1013                } else {
1014                    qk_executor::status!(
1015                        "qk: {}: excluding execution from flaky results because inputs changed during execution",
1016                        task.id
1017                    );
1018                }
1019            }
1020            return false;
1021        }
1022        true
1023    }
1024}
1025
1026/// A task's key, and the inputs it is computed from, or why it has none.
1027pub type Keyed = std::result::Result<(String, Value), String>;
1028
1029/// The key each task in `graph` would be cached under if it ran now, and what
1030/// it is computed from, without running a task. Runtime inputs are executed,
1031/// and the dependency outputs a task reads are taken as they are on disk.
1032pub fn keys(
1033    workspace: &Workspace,
1034    graph: &TaskGraph,
1035    prepared: &BTreeMap<String, PreparedTask>,
1036    cancelled: &AtomicBool,
1037) -> Result<BTreeMap<String, Keyed>> {
1038    let snapshot = hash::Snapshot::new(workspace, graph, &paths::cache_location(workspace))?;
1039    Ok(graph
1040        .tasks
1041        .values()
1042        .map(|task| {
1043            let keyed = key_now(&snapshot, workspace, graph, task, prepared, cancelled);
1044            (task.id.clone(), keyed)
1045        })
1046        .collect())
1047}
1048
1049fn key_now(
1050    snapshot: &hash::Snapshot,
1051    workspace: &Workspace,
1052    graph: &TaskGraph,
1053    task: &Task,
1054    prepared: &BTreeMap<String, PreparedTask>,
1055    cancelled: &AtomicBool,
1056) -> Keyed {
1057    let failed = |error: anyhow::Error| format!("{}: {error:#}", task.id);
1058    let dependencies =
1059        hash::dependency_keys(snapshot, workspace, graph, task, cancelled).map_err(failed)?;
1060    let inputs = hash::inputs(
1061        snapshot,
1062        workspace,
1063        task,
1064        &prepared[&task.id],
1065        &dependencies,
1066        cancelled,
1067    )
1068    .map_err(failed)?;
1069    Ok((hash::key(&inputs).map_err(failed)?, inputs))
1070}
1071
1072/// Runs the private detached-upload protocol used by the qk executable.
1073#[doc(hidden)]
1074pub fn run_upload_worker() -> anyhow::Result<()> {
1075    remote::background::run()
1076}
1077
1078/// The outputs of every other target of `task`'s project, as their targets
1079/// declare them, and as each of their configurations does with its options,
1080/// which can move an output; one whose outputs cannot be read is left out.
1081fn project_outputs(workspace: &Workspace, task: &Task) -> Vec<paths::Outputs> {
1082    let Some(project) = workspace.projects.get(&task.project) else {
1083        return Vec::new();
1084    };
1085    project
1086        .targets
1087        .iter()
1088        .filter(|(target, _)| **target != task.target)
1089        .flat_map(|(target, definition)| {
1090            let configured = definition.configurations.iter().map(|(name, options)| {
1091                let mut definition = definition.clone();
1092                definition.options.extend(options.clone());
1093                (Some(name.clone()), definition)
1094            });
1095            std::iter::once((None, definition.clone()))
1096                .chain(configured)
1097                .map(move |(configuration, definition)| Task {
1098                    id: format!("{}:{target}", task.project),
1099                    project: task.project.clone(),
1100                    target: target.clone(),
1101                    configuration,
1102                    args: Vec::new(),
1103                    definition,
1104                    dependencies: Default::default(),
1105                    edges: Default::default(),
1106                })
1107        })
1108        .filter_map(|sibling| {
1109            paths::Outputs::new(workspace, &sibling)
1110                .ok()
1111                .filter(paths::Outputs::declared)
1112        })
1113        .collect()
1114}
1115
1116#[cfg(test)]
1117mod tests {
1118    use super::*;
1119
1120    #[test]
1121    fn concurrent_blob_writes_keep_results_ordered_and_repair_corruption() {
1122        let workspace = tempfile::tempdir().unwrap();
1123        let root = workspace.path();
1124        let cache = Cache::new(root.join("cache"));
1125        cache.initialize().unwrap();
1126        let mut sources = Vec::new();
1127        for index in 0..130 {
1128            let source = root.join(format!("source-{index}"));
1129            std::fs::write(&source, format!("content-{}", index % 65)).unwrap();
1130            sources.push(source);
1131        }
1132        let digest = cache.put_blob(&sources[0]).unwrap();
1133        std::fs::write(cache.root.join("blobs").join(&digest), "corrupt").unwrap();
1134        std::fs::remove_file(&sources[31]).unwrap();
1135        let results = cache.put_blobs(&sources);
1136        assert_eq!(results.len(), sources.len());
1137        for (index, result) in results.into_iter().enumerate() {
1138            if index == 31 {
1139                assert_eq!(
1140                    result
1141                        .unwrap_err()
1142                        .downcast_ref::<std::io::Error>()
1143                        .unwrap()
1144                        .kind(),
1145                    std::io::ErrorKind::NotFound
1146                );
1147                continue;
1148            }
1149            let digest = result.unwrap();
1150            let held = cache.root.join("blobs").join(&digest);
1151            assert_eq!(
1152                std::fs::read(&held).unwrap(),
1153                format!("content-{}", index % 65).as_bytes()
1154            );
1155            assert_eq!(hash::digest_file(&held).unwrap(), digest);
1156        }
1157    }
1158
1159    /// Content the store holds already is reused, not copied over again.
1160    #[cfg(unix)]
1161    #[test]
1162    fn a_blob_the_store_holds_is_not_written_again() {
1163        use std::os::unix::fs::MetadataExt;
1164        let workspace = tempfile::tempdir().unwrap();
1165        let root = workspace.path();
1166        let cache = Cache::new(root.join(".qk/cache"));
1167        cache.initialize().unwrap();
1168        std::fs::write(root.join("first"), "same").unwrap();
1169        std::fs::write(root.join("second"), "same").unwrap();
1170        std::fs::write(root.join("other"), "different").unwrap();
1171        let first = cache.put_blob(&root.join("first")).unwrap();
1172        let blob = cache.root.join("blobs").join(&first);
1173        let stored = std::fs::metadata(&blob).unwrap().ino();
1174        assert_eq!(cache.put_blob(&root.join("second")).unwrap(), first);
1175        assert_eq!(std::fs::metadata(&blob).unwrap().ino(), stored);
1176        // A stored blob whose content no longer matches is stored anew.
1177        std::fs::write(&blob, "corrupt").unwrap();
1178        assert_eq!(cache.put_blob(&root.join("second")).unwrap(), first);
1179        assert_eq!(std::fs::read(&blob).unwrap(), b"same");
1180        // So is one that cannot be read.
1181        {
1182            use std::os::unix::fs::PermissionsExt;
1183            std::fs::set_permissions(&blob, std::fs::Permissions::from_mode(0o000)).unwrap();
1184        }
1185        assert_eq!(cache.put_blob(&root.join("second")).unwrap(), first);
1186        assert_eq!(std::fs::read(&blob).unwrap(), b"same");
1187        let other = cache.put_blob(&root.join("other")).unwrap();
1188        assert_ne!(other, first);
1189        assert_eq!(
1190            std::fs::read(cache.root.join("blobs").join(other)).unwrap(),
1191            b"different"
1192        );
1193    }
1194
1195    /// Complete-directory restore preserves contents, links, modes and
1196    /// timestamps, and the subsequent hit keeps restored files in place.
1197    #[test]
1198    fn complete_directory_restores_preserve_outputs() {
1199        let workspace = tempfile::tempdir().unwrap();
1200        let root = workspace.path();
1201        std::fs::create_dir_all(root.join("dist/nested/empty")).unwrap();
1202        std::fs::write(root.join("dist/nested/a.txt"), "a").unwrap();
1203        std::fs::write(root.join("dist/b.bin"), [0, 1, 2]).unwrap();
1204        std::fs::create_dir_all(root.join("bundle/empty")).unwrap();
1205        std::fs::write(root.join("bundle/readonly"), "readonly").unwrap();
1206        #[cfg(unix)]
1207        {
1208            use std::os::unix::fs::PermissionsExt;
1209            let script = root.join("dist/run.sh");
1210            std::fs::write(&script, "#!/bin/sh\n").unwrap();
1211            std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
1212            std::os::unix::fs::symlink("nested/a.txt", root.join("dist/link")).unwrap();
1213            std::fs::set_permissions(
1214                root.join("dist/nested"),
1215                std::fs::Permissions::from_mode(0o500),
1216            )
1217            .unwrap();
1218            std::fs::set_permissions(
1219                root.join("bundle/readonly"),
1220                std::fs::Permissions::from_mode(0o444),
1221            )
1222            .unwrap();
1223        }
1224        let log = root.join("log");
1225        std::fs::write(&log, "").unwrap();
1226
1227        let cache = Cache::new(root.join(".qk/cache"));
1228        cache.initialize().unwrap();
1229        let outputs = paths::Outputs::from_patterns(&["dist", "bundle"]);
1230        let key = "0".repeat(64);
1231        cache.publish(root, &key, &outputs, &log).unwrap();
1232
1233        #[cfg(unix)]
1234        {
1235            use std::os::unix::fs::PermissionsExt;
1236            // The manifest retains the restrictive mode; cleanup needs write
1237            // permission on the old directory to remove its files.
1238            std::fs::set_permissions(
1239                root.join("dist/nested"),
1240                std::fs::Permissions::from_mode(0o755),
1241            )
1242            .unwrap();
1243        }
1244        std::fs::remove_dir_all(root.join("dist")).unwrap();
1245        std::fs::remove_dir_all(root.join("bundle")).unwrap();
1246        let before = std::time::SystemTime::now();
1247        let restored = cache
1248            .restore(
1249                root,
1250                "app:build",
1251                &Default::default(),
1252                &key,
1253                &outputs,
1254                &qk_executor::Display::Stream,
1255                qk_executor::Shown::LocalCache,
1256            )
1257            .unwrap();
1258        assert!(restored);
1259        assert!(root.join("dist/nested/empty").is_dir());
1260        assert!(root.join("bundle/empty").is_dir());
1261        assert_eq!(
1262            std::fs::read_to_string(root.join("bundle/readonly")).unwrap(),
1263            "readonly"
1264        );
1265        #[cfg(unix)]
1266        {
1267            assert_eq!(
1268                std::fs::read_link(root.join("dist/link")).unwrap(),
1269                std::path::Path::new("nested/a.txt")
1270            );
1271            assert_eq!(
1272                store::mode(&std::fs::metadata(root.join("dist/run.sh")).unwrap()),
1273                0o755
1274            );
1275            assert_eq!(
1276                store::mode(&std::fs::metadata(root.join("dist/nested")).unwrap()),
1277                0o500
1278            );
1279            assert_eq!(
1280                store::mode(&std::fs::metadata(root.join("bundle/readonly")).unwrap()),
1281                0o444
1282            );
1283        }
1284        let path = root.join("dist/nested/a.txt");
1285        let modified = std::fs::metadata(&path).unwrap().modified().unwrap();
1286        assert!(modified >= before);
1287        let kept = cache
1288            .restore(
1289                root,
1290                "app:build",
1291                &Default::default(),
1292                &key,
1293                &outputs,
1294                &qk_executor::Display::Hidden,
1295                qk_executor::Shown::LocalCache,
1296            )
1297            .unwrap();
1298        assert!(kept);
1299        assert_eq!(
1300            std::fs::metadata(&path).unwrap().modified().unwrap(),
1301            modified
1302        );
1303        #[cfg(unix)]
1304        {
1305            use std::os::unix::fs::PermissionsExt;
1306            std::fs::set_permissions(
1307                root.join("dist/nested"),
1308                std::fs::Permissions::from_mode(0o755),
1309            )
1310            .unwrap();
1311        }
1312    }
1313
1314    #[test]
1315    fn project_outputs_include_each_configuration_of_other_targets() {
1316        let temp = tempfile::tempdir().unwrap();
1317        let root = temp.path();
1318        std::fs::write(root.join("nx.json"), "{}").unwrap();
1319        std::fs::create_dir(root.join("app")).unwrap();
1320        std::fs::write(
1321            root.join("app/project.json"),
1322            r#"{"name":"app","targets":{
1323                "build":{"command":"true","outputs":["{options.outputPath}"],
1324                  "options":{"outputPath":"app/dist"},
1325                  "configurations":{"production":{"outputPath":"app/dist-production"}}},
1326                "lint":{"command":"true"}}}"#,
1327        )
1328        .unwrap();
1329        let workspace = Workspace::load(root).unwrap();
1330        let graph = qk_taskgraph::TaskGraph::build(
1331            &workspace,
1332            &[qk_taskgraph::Request::parse("app:lint").unwrap()],
1333        )
1334        .unwrap();
1335        let outputs = project_outputs(&workspace, &graph.tasks["app:lint"]);
1336        let matched = |path: &str| outputs.iter().any(|outputs| outputs.matches(path));
1337        assert!(matched("app/dist/main.js"));
1338        assert!(matched("app/dist-production/main.js"));
1339        assert!(!matched("app/src/main.ts"));
1340    }
1341}