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        let inputs = &keyed.as_ref().expect("a cacheable task is keyed").1;
459        let mut recordings = settings.and_then(|settings| {
460            let weak = hash::weak_key(
461                snapshot,
462                workspace,
463                graph,
464                task,
465                prepared,
466                inputs,
467                &settings.ignore_reads,
468                cancelled,
469            );
470            match weak {
471                Ok(weak) => Some((recorded::load(&self.root, &weak), weak, settings)),
472                Err(error) => {
473                    qk_executor::status!(
474                        "qk: {}: not keyed by its recordings ({error:#})",
475                        task.id
476                    );
477                    None
478                }
479            }
480        });
481        let mut shadow = None;
482        if let Some((sets, weak, settings)) = &mut recordings {
483            let recorded_profile = profile::span(&task.id, "recorded_restore");
484            let keyed_by = settings.mode == ultracache::Mode::On;
485            let found = self.by_recordings(
486                workspace, task, prepared, snapshot, &outputs, &own, sets, weak, &key, keyed_by,
487                cancelled,
488            );
489            drop(recorded_profile);
490            match (keyed_by, found) {
491                (true, found @ (Found::Local(_) | Found::Remote(_))) => {
492                    let (strong, remote) = match found {
493                        Found::Local(strong) => {
494                            if let Some(remote) = &self.remote {
495                                remote.held(&strong);
496                            }
497                            (strong, None)
498                        }
499                        Found::Remote(strong) => (strong, Some(())),
500                        Found::Nothing => unreachable!(),
501                    };
502                    recorded::touch(&self.root, weak);
503                    qk_executor::report::event(qk_executor::report::Event::Cache {
504                        id: task.id.clone(),
505                        cached: if remote.is_some() {
506                            qk_executor::report::Cached::RemoteHit
507                        } else {
508                            qk_executor::report::Cached::Hit
509                        },
510                    });
511                    return Ok(TaskResult {
512                        outcome: Outcome::Success,
513                        cache: if remote.is_some() {
514                            CacheStatus::RemoteHit
515                        } else {
516                            CacheStatus::LocalHit
517                        },
518                        key: keyed,
519                        warm: None,
520                        execution: None,
521                        recorded: Some(Recorded::Hit { key: strong }),
522                        shadow: None,
523                    });
524                }
525                (true, Found::Nothing) => {}
526                (false, Found::Local(strong) | Found::Remote(strong)) => {
527                    shadow = Some(Shadow::WouldHit { key: strong });
528                }
529                (false, Found::Nothing) if sets.is_empty() => shadow = Some(Shadow::Unrecorded),
530                (false, Found::Nothing) => shadow = Some(Shadow::WouldMiss),
531            }
532        }
533        // A task with path sets is keyed by them alone: its declared inputs
534        // are what a recording found wanting. In mode "record" it is keyed by
535        // its declared inputs all the same.
536        let declared = recordings.as_ref().is_none_or(|(sets, _, settings)| {
537            sets.is_empty() || settings.mode == ultracache::Mode::Record
538        });
539        // How its recordings keyed it, unless it runs and records.
540        let unrecorded = recordings
541            .as_ref()
542            .filter(|(_, _, settings)| settings.mode == ultracache::Mode::On)
543            .map(|_| Recorded::Unrecorded);
544        // An entry fetched ahead of this task is local, but it came from the
545        // remote store, and is reported as such.
546        let prefetched = declared
547            && self.remote.as_ref().is_some_and(|remote| {
548                remote.resolved(&key);
549                remote.prefetched(&key)
550            });
551        let local_profile = profile::span(&task.id, "local_restore");
552        let restored = if declared {
553            self.restore(
554                &workspace.root,
555                &task.id,
556                &task.dependencies,
557                &key,
558                &outputs,
559                &prepared.display,
560                if prefetched {
561                    qk_executor::Shown::RemoteCache
562                } else {
563                    qk_executor::Shown::LocalCache
564                },
565            )
566        } else {
567            Ok(false)
568        };
569        match restored {
570            Ok(true) => {
571                let claimed = match &self.remote {
572                    Some(remote) if prefetched && remote.claim(&key) => true,
573                    Some(remote) => {
574                        remote.held(&key);
575                        false
576                    }
577                    None => false,
578                };
579                qk_executor::report::event(qk_executor::report::Event::Cache {
580                    id: task.id.clone(),
581                    cached: if claimed {
582                        qk_executor::report::Cached::RemoteHit
583                    } else {
584                        qk_executor::report::Cached::Hit
585                    },
586                });
587                return Ok(TaskResult {
588                    outcome: Outcome::Success,
589                    cache: if claimed {
590                        CacheStatus::RemoteHit
591                    } else {
592                        CacheStatus::LocalHit
593                    },
594                    key: keyed,
595                    warm: None,
596                    execution: None,
597                    recorded: unrecorded,
598                    shadow,
599                });
600            }
601            Ok(false) => {}
602            Err(error) => {
603                qk_executor::status!("qk: {}: ignoring unusable cache entry ({error})", task.id)
604            }
605        }
606        drop(local_profile);
607        if let Some(remote) = self.remote.as_ref().filter(|_| declared) {
608            let fetch_profile = profile::span(&task.id, "remote_fetch");
609            let fetched = remote.fetch(&self.root, &key, &task.id, cancelled);
610            drop(fetch_profile);
611            let fetched = fetched.and_then(|found| {
612                if !found {
613                    return Ok(false);
614                }
615                self.restore(
616                    &workspace.root,
617                    &task.id,
618                    &task.dependencies,
619                    &key,
620                    &outputs,
621                    &prepared.display,
622                    qk_executor::Shown::RemoteCache,
623                )
624            });
625            match fetched {
626                Ok(true) => {
627                    qk_executor::report::event(qk_executor::report::Event::Cache {
628                        id: task.id.clone(),
629                        cached: qk_executor::report::Cached::RemoteHit,
630                    });
631                    return Ok(TaskResult {
632                        outcome: Outcome::Success,
633                        cache: CacheStatus::RemoteHit,
634                        key: keyed,
635                        warm: None,
636                        execution: None,
637                        recorded: unrecorded,
638                        shadow,
639                    });
640                }
641                Ok(false) => {}
642                Err(error) => {
643                    qk_executor::status!("qk: {}: remote cache unavailable ({error:#})", task.id)
644                }
645            }
646        }
647        qk_executor::report::event(qk_executor::report::Event::Cache {
648            id: task.id.clone(),
649            cached: qk_executor::report::Cached::Miss,
650        });
651        let log = match tempfile::NamedTempFile::new_in(self.root.join("tmp")) {
652            Ok(log) => log,
653            Err(error) => return bypass(format!("cache log unavailable: {error}")),
654        };
655        let capture =
656            Capture::new(Some(log.as_file().try_clone()?), prepared.display.clone()).retain_log();
657        let restored = before(running);
658        let started = recorded::now(&self.root.join("tmp"));
659        let executing = profile::span(&task.id, "execute");
660        let outcome = execute_captured(running, cancelled, Some(&capture))?;
661        drop(executing);
662        let warm_report = after(outcome, restored, running);
663        let inputs_unchanged =
664            self.inputs_match(snapshot, workspace, graph, task, prepared, &key, cancelled);
665        let execution = capture.take_log().map(|log| Execution {
666            log,
667            inputs_unchanged,
668        });
669        let mut recorded = unrecorded.map(|unrecorded| match &recordings {
670            Some((sets, _, _)) if !sets.is_empty() => Recorded::Missed,
671            _ => unrecorded,
672        });
673        let path_set = match (&recordings, &prepared.recording) {
674            (Some((_, _, settings)), Some(recording)) => Some(
675                self.path_set(workspace, recording, prepared, settings, &own)
676                    .and_then(|set| {
677                        // Keyed by nothing, a recording would match any workspace.
678                        if set.is_empty() {
679                            return Err("its trace named no path in the workspace".to_owned());
680                        }
681                        match set.changed_since(&workspace.root, started, &own) {
682                            Some(path) => Err(format!("{path} changed while it ran")),
683                            None => Ok(set),
684                        }
685                    }),
686            ),
687            _ => None,
688        };
689        if let Some(Err(reason)) = &path_set {
690            qk_executor::status!("qk: {}: not recorded ({reason})", task.id);
691            recorded = Some(Recorded::Failed {
692                reason: reason.clone(),
693            });
694        }
695        if outcome == Outcome::Success && inputs_unchanged && capture.healthy() {
696            let publishing = profile::span(&task.id, "publish");
697            let published = self.publish(&workspace.root, &key, &outputs, log.path());
698            drop(publishing);
699            match published {
700                Ok(()) => {
701                    store::record_outputs(&workspace.root, &task.id, &key, &outputs);
702                    if let Some(remote) = &self.remote {
703                        remote.upload(&self.root, &key);
704                    }
705                }
706                Err(error) => {
707                    qk_executor::status!("qk: {}: could not save cache entry ({error})", task.id);
708                }
709            }
710            // Its result is kept under the key of what it read, too.
711            if let (Some((_, weak, _)), Some(Ok(set))) = (&recordings, path_set) {
712                let saved = recorded::strong_key(weak, &set, snapshot, &workspace.root, &own)
713                    .and_then(|strong| {
714                        self.publish(&workspace.root, &strong, &outputs, log.path())?;
715                        recorded::save(&self.root, weak, set)?;
716                        // Shared, the sets are merged with those the store
717                        // held when this task looked it up.
718                        if let Some(remote) = &self.remote {
719                            remote.upload(&self.root, &strong);
720                            if let Some(record) = recorded::record(&self.root, weak) {
721                                remote.upload_path_sets(&self.root, weak, record);
722                            }
723                        }
724                        Ok(strong)
725                    });
726                recorded = Some(match saved {
727                    Ok(key) => Recorded::Saved { key },
728                    Err(error) => {
729                        qk_executor::status!("qk: {}: recording not saved ({error:#})", task.id);
730                        Recorded::Failed {
731                            reason: format!("{error:#}"),
732                        }
733                    }
734                });
735            }
736        }
737        Ok(TaskResult {
738            outcome,
739            cache: CacheStatus::Miss,
740            key: keyed,
741            warm: warm_report,
742            execution,
743            recorded,
744            shadow,
745        })
746    }
747
748    /// Looks a task up by its recordings: by each path set's key in the local
749    /// cache, then, with none there, among the path sets and results the
750    /// remote store holds, adding its path sets to `sets`. With `restore`, a
751    /// result found is restored; without, the lookup only says whether there
752    /// is one.
753    #[allow(clippy::too_many_arguments)]
754    fn by_recordings(
755        &self,
756        workspace: &Workspace,
757        task: &Task,
758        prepared: &PreparedTask,
759        snapshot: &hash::Snapshot,
760        outputs: &paths::Outputs,
761        own: &[&paths::Outputs],
762        sets: &mut Vec<recorded::PathSet>,
763        weak: &str,
764        declared: &str,
765        restore: bool,
766        cancelled: &AtomicBool,
767    ) -> Found {
768        let strong = |set: &recorded::PathSet| {
769            recorded::strong_key(weak, set, snapshot, &workspace.root, own)
770                .inspect_err(|error| {
771                    qk_executor::status!("qk: {}: ignoring a path set ({error:#})", task.id);
772                })
773                .ok()
774        };
775        let restored = |strong: &str, shown| {
776            self.restore(
777                &workspace.root,
778                &task.id,
779                &task.dependencies,
780                strong,
781                outputs,
782                &prepared.display,
783                shown,
784            )
785            .unwrap_or_else(|error| {
786                qk_executor::status!("qk: {}: ignoring unusable cache entry ({error})", task.id);
787                false
788            })
789        };
790        let local = |strong: &str| {
791            if restore {
792                restored(strong, qk_executor::Shown::LocalCache)
793            } else {
794                self.root
795                    .join("entries")
796                    .join(format!("{strong}.json"))
797                    .is_file()
798            }
799        };
800        let mut keys = Vec::new();
801        for set in sets.iter() {
802            if let Some(strong) = strong(set) {
803                if local(&strong) {
804                    return Found::Local(strong);
805                }
806                keys.push(strong);
807            }
808        }
809        let Some(remote) = &self.remote else {
810            return Found::Nothing;
811        };
812        // Without path sets here, a result here under the declared key is
813        // the task's, and asking the store for path sets would only delay
814        // it, run after run, and have them shadow it. Looking up without
815        // restoring, for mode "record", the lookup is all there is to do.
816        if restore
817            && sets.is_empty()
818            && self
819                .root
820                .join("entries")
821                .join(format!("{declared}.json"))
822                .is_file()
823        {
824            return Found::Nothing;
825        }
826        // Path sets other machines recorded, new here.
827        let fetched = remote
828            .fetch_path_sets(&self.root, weak, &task.id)
829            .and_then(|record| match record {
830                Some(record) => recorded::merge(&self.root, weak, &record),
831                None => Ok(Vec::new()),
832            });
833        match fetched {
834            Ok(added) => {
835                for set in added {
836                    if let Some(strong) = strong(&set) {
837                        if local(&strong) {
838                            sets.push(set);
839                            return Found::Local(strong);
840                        }
841                        keys.push(strong);
842                    }
843                    sets.push(set);
844                }
845            }
846            Err(error) => {
847                qk_executor::status!("qk: {}: remote path sets unavailable ({error:#})", task.id)
848            }
849        }
850        for strong in keys {
851            let found = if restore {
852                remote
853                    .fetch(&self.root, &strong, &task.id, cancelled)
854                    .unwrap_or_else(|error| {
855                        qk_executor::status!(
856                            "qk: {}: remote cache unavailable ({error:#})",
857                            task.id
858                        );
859                        false
860                    })
861                    && restored(&strong, qk_executor::Shown::RemoteCache)
862            } else {
863                remote.holds(&strong).unwrap_or(false)
864            };
865            if found {
866                return Found::Remote(strong);
867            }
868        }
869        Found::Nothing
870    }
871
872    /// The path set of a task's recording, or why there is none.
873    fn path_set(
874        &self,
875        workspace: &Workspace,
876        recording: &qk_executor::Recording,
877        prepared: &PreparedTask,
878        settings: &ultracache::Settings,
879        own: &[&paths::Outputs],
880    ) -> std::result::Result<recorded::PathSet, String> {
881        let failed = |error: anyhow::Error| format!("{error:#}");
882        let canonical = workspace
883            .root
884            .canonicalize()
885            .map_err(|e| failed(e.into()))?;
886        // Only what a path set can hold is kept and counted: a build reads
887        // far more in node_modules and outside the workspace than in it.
888        let keep = |path: &std::path::Path| {
889            [&workspace.root, &canonical].iter().any(|root| {
890                path.strip_prefix(root).is_ok_and(|relative| {
891                    !relative.components().any(|component| {
892                        matches!(
893                            component.as_os_str().to_str(),
894                            Some("node_modules" | ".git")
895                        )
896                    })
897                })
898            })
899        };
900        let limits = qk_input_analysis::strace::Limits {
901            records: 1_000_000,
902            bytes: 256 * 1024 * 1024,
903            accesses: 500_000,
904            keep: Some(&keep),
905        };
906        let (accesses, diagnostics) =
907            qk_input_analysis::strace::collect_within(&recording.directory, &prepared.cwd, &limits)
908                .map_err(failed)?;
909        // A partial trace would key the task by part of what it read.
910        if let Some(diagnostic) = diagnostics.first() {
911            return Err(diagnostic.clone());
912        }
913        let ignore = settings
914            .ignore_reads
915            .iter()
916            .map(|glob| Pattern::new(glob, false))
917            .collect::<Result<Vec<_>>>()
918            .map_err(failed)?;
919        // qk's own directories, where they lie inside the workspace.
920        let state = [self.root.clone(), worktree_state(&workspace.root)]
921            .iter()
922            .filter_map(|path| {
923                [&workspace.root, &canonical]
924                    .iter()
925                    .find_map(|root| path.strip_prefix(root).ok())
926                    .and_then(|relative| relative.to_str())
927                    .map(|relative| relative.replace('\\', "/"))
928            })
929            .collect();
930        let exclusions = recorded::Exclusions {
931            roots: [&workspace.root, &canonical],
932            outputs: own,
933            ignore,
934            state,
935        };
936        Ok(recorded::PathSet::from_accesses(
937            &accesses,
938            &exclusions,
939            &canonical,
940        ))
941    }
942
943    /// Rechecks declared inputs for successful and failed executions alike.
944    #[allow(clippy::too_many_arguments)]
945    fn inputs_match(
946        &self,
947        snapshot: &hash::Snapshot,
948        workspace: &Workspace,
949        graph: &TaskGraph,
950        task: &Task,
951        prepared: &PreparedTask,
952        before: &str,
953        cancelled: &AtomicBool,
954    ) -> bool {
955        if cancelled.load(Ordering::SeqCst) {
956            return false;
957        }
958        let _profile = profile::span(&task.id, "inputs_recheck");
959        let after = hash::recheck_dependency_keys(snapshot, workspace, graph, task, cancelled)
960            .and_then(|dependencies| {
961                hash::fingerprint(
962                    snapshot,
963                    workspace,
964                    task,
965                    prepared,
966                    &dependencies,
967                    cancelled,
968                )
969            });
970        if after.as_deref().ok() != Some(before) {
971            if task.definition.cache == Some(true) {
972                if self.reuse {
973                    qk_executor::status!(
974                        "qk: {}: not caching because inputs changed during execution",
975                        task.id
976                    );
977                } else {
978                    qk_executor::status!(
979                        "qk: {}: excluding execution from flaky results because inputs changed during execution",
980                        task.id
981                    );
982                }
983            }
984            return false;
985        }
986        true
987    }
988}
989
990/// A task's key, and the inputs it is computed from, or why it has none.
991pub type Keyed = std::result::Result<(String, Value), String>;
992
993/// The key each task in `graph` would be cached under if it ran now, and what
994/// it is computed from, without running a task. Runtime inputs are executed,
995/// and the dependency outputs a task reads are taken as they are on disk.
996pub fn keys(
997    workspace: &Workspace,
998    graph: &TaskGraph,
999    prepared: &BTreeMap<String, PreparedTask>,
1000    cancelled: &AtomicBool,
1001) -> Result<BTreeMap<String, Keyed>> {
1002    let snapshot = hash::Snapshot::new(workspace, graph, &paths::cache_location(workspace))?;
1003    Ok(graph
1004        .tasks
1005        .values()
1006        .map(|task| {
1007            let keyed = key_now(&snapshot, workspace, graph, task, prepared, cancelled);
1008            (task.id.clone(), keyed)
1009        })
1010        .collect())
1011}
1012
1013fn key_now(
1014    snapshot: &hash::Snapshot,
1015    workspace: &Workspace,
1016    graph: &TaskGraph,
1017    task: &Task,
1018    prepared: &BTreeMap<String, PreparedTask>,
1019    cancelled: &AtomicBool,
1020) -> Keyed {
1021    let failed = |error: anyhow::Error| format!("{}: {error:#}", task.id);
1022    let dependencies =
1023        hash::dependency_keys(snapshot, workspace, graph, task, cancelled).map_err(failed)?;
1024    let inputs = hash::inputs(
1025        snapshot,
1026        workspace,
1027        task,
1028        &prepared[&task.id],
1029        &dependencies,
1030        cancelled,
1031    )
1032    .map_err(failed)?;
1033    Ok((hash::key(&inputs).map_err(failed)?, inputs))
1034}
1035
1036/// Runs the private detached-upload protocol used by the qk executable.
1037#[doc(hidden)]
1038pub fn run_upload_worker() -> anyhow::Result<()> {
1039    remote::background::run()
1040}
1041
1042#[cfg(test)]
1043mod tests {
1044    use super::*;
1045
1046    #[test]
1047    fn concurrent_blob_writes_keep_results_ordered_and_repair_corruption() {
1048        let workspace = tempfile::tempdir().unwrap();
1049        let root = workspace.path();
1050        let cache = Cache::new(root.join("cache"));
1051        cache.initialize().unwrap();
1052        let mut sources = Vec::new();
1053        for index in 0..130 {
1054            let source = root.join(format!("source-{index}"));
1055            std::fs::write(&source, format!("content-{}", index % 65)).unwrap();
1056            sources.push(source);
1057        }
1058        let digest = cache.put_blob(&sources[0]).unwrap();
1059        std::fs::write(cache.root.join("blobs").join(&digest), "corrupt").unwrap();
1060        std::fs::remove_file(&sources[31]).unwrap();
1061        let results = cache.put_blobs(&sources);
1062        assert_eq!(results.len(), sources.len());
1063        for (index, result) in results.into_iter().enumerate() {
1064            if index == 31 {
1065                assert_eq!(
1066                    result
1067                        .unwrap_err()
1068                        .downcast_ref::<std::io::Error>()
1069                        .unwrap()
1070                        .kind(),
1071                    std::io::ErrorKind::NotFound
1072                );
1073                continue;
1074            }
1075            let digest = result.unwrap();
1076            let held = cache.root.join("blobs").join(&digest);
1077            assert_eq!(
1078                std::fs::read(&held).unwrap(),
1079                format!("content-{}", index % 65).as_bytes()
1080            );
1081            assert_eq!(hash::digest_file(&held).unwrap(), digest);
1082        }
1083    }
1084
1085    /// Content the store holds already is reused, not copied over again.
1086    #[cfg(unix)]
1087    #[test]
1088    fn a_blob_the_store_holds_is_not_written_again() {
1089        use std::os::unix::fs::MetadataExt;
1090        let workspace = tempfile::tempdir().unwrap();
1091        let root = workspace.path();
1092        let cache = Cache::new(root.join(".qk/cache"));
1093        cache.initialize().unwrap();
1094        std::fs::write(root.join("first"), "same").unwrap();
1095        std::fs::write(root.join("second"), "same").unwrap();
1096        std::fs::write(root.join("other"), "different").unwrap();
1097        let first = cache.put_blob(&root.join("first")).unwrap();
1098        let blob = cache.root.join("blobs").join(&first);
1099        let stored = std::fs::metadata(&blob).unwrap().ino();
1100        assert_eq!(cache.put_blob(&root.join("second")).unwrap(), first);
1101        assert_eq!(std::fs::metadata(&blob).unwrap().ino(), stored);
1102        // A stored blob whose content no longer matches is stored anew.
1103        std::fs::write(&blob, "corrupt").unwrap();
1104        assert_eq!(cache.put_blob(&root.join("second")).unwrap(), first);
1105        assert_eq!(std::fs::read(&blob).unwrap(), b"same");
1106        // So is one that cannot be read.
1107        {
1108            use std::os::unix::fs::PermissionsExt;
1109            std::fs::set_permissions(&blob, std::fs::Permissions::from_mode(0o000)).unwrap();
1110        }
1111        assert_eq!(cache.put_blob(&root.join("second")).unwrap(), first);
1112        assert_eq!(std::fs::read(&blob).unwrap(), b"same");
1113        let other = cache.put_blob(&root.join("other")).unwrap();
1114        assert_ne!(other, first);
1115        assert_eq!(
1116            std::fs::read(cache.root.join("blobs").join(other)).unwrap(),
1117            b"different"
1118        );
1119    }
1120
1121    /// Complete-directory restore preserves contents, links, modes and
1122    /// timestamps, and the subsequent hit keeps restored files in place.
1123    #[test]
1124    fn complete_directory_restores_preserve_outputs() {
1125        let workspace = tempfile::tempdir().unwrap();
1126        let root = workspace.path();
1127        std::fs::create_dir_all(root.join("dist/nested/empty")).unwrap();
1128        std::fs::write(root.join("dist/nested/a.txt"), "a").unwrap();
1129        std::fs::write(root.join("dist/b.bin"), [0, 1, 2]).unwrap();
1130        std::fs::create_dir_all(root.join("bundle/empty")).unwrap();
1131        std::fs::write(root.join("bundle/readonly"), "readonly").unwrap();
1132        #[cfg(unix)]
1133        {
1134            use std::os::unix::fs::PermissionsExt;
1135            let script = root.join("dist/run.sh");
1136            std::fs::write(&script, "#!/bin/sh\n").unwrap();
1137            std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
1138            std::os::unix::fs::symlink("nested/a.txt", root.join("dist/link")).unwrap();
1139            std::fs::set_permissions(
1140                root.join("dist/nested"),
1141                std::fs::Permissions::from_mode(0o500),
1142            )
1143            .unwrap();
1144            std::fs::set_permissions(
1145                root.join("bundle/readonly"),
1146                std::fs::Permissions::from_mode(0o444),
1147            )
1148            .unwrap();
1149        }
1150        let log = root.join("log");
1151        std::fs::write(&log, "").unwrap();
1152
1153        let cache = Cache::new(root.join(".qk/cache"));
1154        cache.initialize().unwrap();
1155        let outputs = paths::Outputs::from_patterns(&["dist", "bundle"]);
1156        let key = "0".repeat(64);
1157        cache.publish(root, &key, &outputs, &log).unwrap();
1158
1159        #[cfg(unix)]
1160        {
1161            use std::os::unix::fs::PermissionsExt;
1162            // The manifest retains the restrictive mode; cleanup needs write
1163            // permission on the old directory to remove its files.
1164            std::fs::set_permissions(
1165                root.join("dist/nested"),
1166                std::fs::Permissions::from_mode(0o755),
1167            )
1168            .unwrap();
1169        }
1170        std::fs::remove_dir_all(root.join("dist")).unwrap();
1171        std::fs::remove_dir_all(root.join("bundle")).unwrap();
1172        let before = std::time::SystemTime::now();
1173        let restored = cache
1174            .restore(
1175                root,
1176                "app:build",
1177                &Default::default(),
1178                &key,
1179                &outputs,
1180                &qk_executor::Display::Stream,
1181                qk_executor::Shown::LocalCache,
1182            )
1183            .unwrap();
1184        assert!(restored);
1185        assert!(root.join("dist/nested/empty").is_dir());
1186        assert!(root.join("bundle/empty").is_dir());
1187        assert_eq!(
1188            std::fs::read_to_string(root.join("bundle/readonly")).unwrap(),
1189            "readonly"
1190        );
1191        #[cfg(unix)]
1192        {
1193            assert_eq!(
1194                std::fs::read_link(root.join("dist/link")).unwrap(),
1195                std::path::Path::new("nested/a.txt")
1196            );
1197            assert_eq!(
1198                store::mode(&std::fs::metadata(root.join("dist/run.sh")).unwrap()),
1199                0o755
1200            );
1201            assert_eq!(
1202                store::mode(&std::fs::metadata(root.join("dist/nested")).unwrap()),
1203                0o500
1204            );
1205            assert_eq!(
1206                store::mode(&std::fs::metadata(root.join("bundle/readonly")).unwrap()),
1207                0o444
1208            );
1209        }
1210        let path = root.join("dist/nested/a.txt");
1211        let modified = std::fs::metadata(&path).unwrap().modified().unwrap();
1212        assert!(modified >= before);
1213        let kept = cache
1214            .restore(
1215                root,
1216                "app:build",
1217                &Default::default(),
1218                &key,
1219                &outputs,
1220                &qk_executor::Display::Hidden,
1221                qk_executor::Shown::LocalCache,
1222            )
1223            .unwrap();
1224        assert!(kept);
1225        assert_eq!(
1226            std::fs::metadata(&path).unwrap().modified().unwrap(),
1227            modified
1228        );
1229        #[cfg(unix)]
1230        {
1231            use std::os::unix::fs::PermissionsExt;
1232            std::fs::set_permissions(
1233                root.join("dist/nested"),
1234                std::fs::Permissions::from_mode(0o755),
1235            )
1236            .unwrap();
1237        }
1238    }
1239}