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 inputs;
7mod paths;
8pub mod profile;
9mod record;
10mod remote;
11mod store;
12pub mod warm;
13
14use std::collections::BTreeMap;
15use std::path::{Path, PathBuf};
16use std::sync::OnceLock;
17use std::sync::atomic::{AtomicBool, Ordering};
18
19use anyhow::{Context, Result};
20use qk_config::Workspace;
21use qk_executor::{Capture, Outcome, PreparedTask, execute, execute_captured};
22use qk_taskgraph::{Task, TaskGraph};
23use serde_json::{Value, json};
24
25pub use evict::{Pruned, max_size, parse_size, prune, prune_automatic};
26pub use glob::Pattern;
27pub use hash::{SourceIgnore, source_files};
28pub use inputs::{
29    InputChange, InputChanges, Resolution, Resolved, manifest_for_dependents, resolve_each,
30    resolve_tasks, without_resolution,
31};
32/// Expands `{projectRoot}` and `{workspaceRoot}` in a project's path or glob.
33pub use paths::expand as expand_project_path;
34pub use paths::{
35    Outputs, cache_directory, cache_location, clear_worktree_state, resolved_outputs,
36    worktree_state,
37};
38
39/// The cache for one run; its workspace snapshot is taken on first use.
40pub struct Cache {
41    pub root: PathBuf,
42    snapshot: OnceLock<std::result::Result<hash::Snapshot, String>>,
43    remote: Option<std::sync::Arc<remote::Remote>>,
44    /// Whether results and warm state may be restored or published.
45    reuse: bool,
46    /// The tasks whose outputs this run left exactly as they were.
47    kept: std::sync::Mutex<std::collections::BTreeSet<String>>,
48    /// Warm state being saved in the background.
49    saves: std::sync::Mutex<Vec<std::thread::JoinHandle<()>>>,
50    cleanup: std::sync::Mutex<Option<store::Cleanup>>,
51}
52
53/// A task's output fingerprint for its dependents' keys, or why it has none:
54/// `"<task id>: <reason>"` for the task where fingerprinting first failed.
55pub type Fingerprint = std::result::Result<String, String>;
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 fingerprint: Fingerprint,
77    pub cache: CacheStatus,
78    /// The task's key and what it was computed from, when it has one.
79    pub key: Option<(String, Value)>,
80    /// What warm state did, for targets that keep it.
81    pub warm: Option<warm::WarmReport>,
82    pub execution: Option<Execution>,
83}
84
85impl TaskResult {
86    pub fn uncached(outcome: Outcome, reason: String) -> Self {
87        Self {
88            outcome,
89            fingerprint: Err(reason),
90            cache: CacheStatus::Uncached,
91            key: None,
92            warm: None,
93            execution: None,
94        }
95    }
96}
97
98impl Cache {
99    pub fn new(root: PathBuf) -> Self {
100        Self {
101            root,
102            snapshot: OnceLock::new(),
103            remote: None,
104            reuse: true,
105            kept: Default::default(),
106            saves: Default::default(),
107            cleanup: Default::default(),
108        }
109    }
110
111    /// Records verified executions without configuring or using result stores.
112    pub fn for_observation(workspace: &Workspace) -> Self {
113        Self {
114            reuse: false,
115            ..Self::new(paths::cache_location(workspace))
116        }
117    }
118
119    /// The workspace's cache, with the remote store its nx.json `s3` key
120    /// configures, if any. Credentials and modes are read from `environment`,
121    /// which includes the workspace's dotenv files. A remote store that cannot
122    /// be used is reported and left out; the local cache still works.
123    pub fn for_workspace(
124        workspace: &Workspace,
125        environment: &BTreeMap<std::ffi::OsString, std::ffi::OsString>,
126    ) -> Self {
127        let root = paths::cache_location(workspace);
128        let remote = match remote::configure(
129            workspace.config.extra.get("s3"),
130            workspace.config.extra.get("storeServer"),
131            environment,
132        ) {
133            Ok(remote) => remote.map(std::sync::Arc::new),
134            Err(error) => {
135                qk_executor::status!("qk: remote cache disabled: {error:#}");
136                None
137            }
138        };
139        if let Some(remote) = &remote {
140            remote.read_index(&root);
141        }
142        Self {
143            remote,
144            ..Self::new(root)
145        }
146    }
147
148    /// Saves what later runs reuse and waits for background saves, uploads
149    /// and retired-output cleanup, reporting failures.
150    pub fn finish(&self, workspace: &Workspace) {
151        if !self.reuse {
152            return;
153        }
154        if let Some(Ok(snapshot)) = self.snapshot.get() {
155            snapshot.save_digests(&workspace.root);
156        }
157        // Before the uploads, which a save may add to.
158        for save in std::mem::take(&mut *self.saves.lock().unwrap()) {
159            let _ = save.join();
160        }
161        if let Some(remote) = &self.remote {
162            let failures = remote.finish();
163            for failure in &failures {
164                qk_executor::status!("qk: remote cache upload failed for {failure}");
165            }
166        }
167        let _profile = profile::span("cache", "restore_cleanup_wait");
168        self.cleanup.lock().unwrap().take();
169    }
170
171    fn snapshot(
172        &self,
173        workspace: &Workspace,
174        graph: &TaskGraph,
175    ) -> std::result::Result<&hash::Snapshot, String> {
176        self.snapshot
177            .get_or_init(|| {
178                hash::Snapshot::new(workspace, graph, &self.root)
179                    .map_err(|error| format!("cannot snapshot the workspace: {error:#}"))
180            })
181            .as_ref()
182            .map_err(Clone::clone)
183    }
184
185    pub fn run(
186        &self,
187        workspace: &Workspace,
188        graph: &TaskGraph,
189        task: &Task,
190        prepared: &PreparedTask,
191        dependencies: &BTreeMap<String, Fingerprint>,
192        cancelled: &AtomicBool,
193    ) -> Result<TaskResult> {
194        let result = self.run_task(workspace, graph, task, prepared, dependencies, cancelled);
195        if let Some(Ok(snapshot)) = self.snapshot.get() {
196            snapshot.outputs_written(workspace, task);
197        }
198        result
199    }
200
201    fn run_task(
202        &self,
203        workspace: &Workspace,
204        graph: &TaskGraph,
205        task: &Task,
206        prepared: &PreparedTask,
207        dependencies: &BTreeMap<String, Fingerprint>,
208        cancelled: &AtomicBool,
209    ) -> Result<TaskResult> {
210        let cacheable = task.definition.cache == Some(true);
211        // Held until the task is done, whether it ran or came from the cache.
212        let _kept = self
213            .reuse
214            .then(|| warm::keep_across(workspace, graph, task));
215        let entries = if self.reuse {
216            warm::config(workspace, task).unwrap_or_else(|error| {
217                qk_executor::status!("qk: {}: warm state ignored ({error:#})", task.id);
218                Vec::new()
219            })
220        } else {
221            Vec::new()
222        };
223        // Names an entry in messages: the group it shares, if any.
224        let entry_label = |warm: &warm::Warm| match warm.group() {
225            Some(group) => format!(" (group {group})"),
226            None => String::new(),
227        };
228        // The warm variables point tools at their state; they are not inputs,
229        // so the key is computed from the task without them.
230        let mut running = prepared.clone();
231        for warm in &entries {
232            for (name, value) in &warm.env {
233                running.execution.insert(name.into(), value.into());
234            }
235        }
236        // What each entry restored, and which groups were on disk already.
237        let before = |running: &PreparedTask| -> (Vec<warm::Restored>, Vec<String>) {
238            let (mut restored, mut present) = (Vec::new(), Vec::new());
239            for warm in &entries {
240                match self
241                    .initialize()
242                    .and_then(|()| self.restore_warm(workspace, task, warm, running))
243                {
244                    Ok((entry, groups)) => {
245                        if !entry.groups.is_empty() {
246                            restored.push(entry);
247                        }
248                        present.extend(groups);
249                    }
250                    Err(error) => qk_executor::status!(
251                        "qk: {}: warm state{} not restored ({error:#})",
252                        task.id,
253                        entry_label(warm)
254                    ),
255                }
256            }
257            (restored, present)
258        };
259        let after = |outcome: Outcome,
260                     (restored, present): (Vec<warm::Restored>, Vec<String>),
261                     running: &PreparedTask|
262         -> Option<warm::WarmReport> {
263            if entries.is_empty() {
264                return None;
265            }
266            let mut save_ms = None;
267            let mut background = false;
268            for warm in entries.iter().filter(|_| outcome == Outcome::Success) {
269                let started = std::time::Instant::now();
270                let saved = if warm.background {
271                    self.save_warm_in_background(workspace, task, warm, running)
272                } else {
273                    self.save_warm(workspace, task, warm, running)
274                };
275                match saved {
276                    Ok(()) if warm.background => background = true,
277                    Ok(()) => {
278                        *save_ms.get_or_insert(0) += started.elapsed().as_millis() as u64;
279                    }
280                    Err(error) => qk_executor::status!(
281                        "qk: {}: warm state{} not saved ({error:#})",
282                        task.id,
283                        entry_label(warm)
284                    ),
285                }
286            }
287            Some(warm::WarmReport {
288                restored,
289                present,
290                save_ms,
291                background,
292            })
293        };
294        let fallback = |reason: String| {
295            let _profile = profile::span(&task.id, "execute");
296            execute(prepared, cancelled).map(|outcome| TaskResult::uncached(outcome, reason))
297        };
298        // Reports why this task runs uncached, attributing the reason to this task.
299        let bypass = |reason: String| {
300            if cacheable {
301                qk_executor::status!("qk: {}: cache bypassed ({reason})", task.id);
302            }
303            fallback(format!("{}: {reason}", task.id))
304        };
305        let dependencies = match dependencies
306            .iter()
307            .map(|(id, key)| key.clone().map(|key| (id.clone(), key)))
308            .collect::<std::result::Result<BTreeMap<_, _>, _>>()
309        {
310            Ok(dependencies) => dependencies,
311            Err(root) => {
312                // Keep the original task and reason so chains stay readable.
313                if cacheable {
314                    qk_executor::status!("qk: {}: cache bypassed (depends on {root})", task.id);
315                }
316                return fallback(root);
317            }
318        };
319        let snapshot = match self.snapshot(workspace, graph) {
320            Ok(snapshot) => snapshot,
321            Err(reason) => return bypass(reason),
322        };
323        let dependency_profile = profile::span(&task.id, "dependency_inputs");
324        let dependencies =
325            match hash::dependency_keys(snapshot, workspace, graph, task, &dependencies, cancelled)
326            {
327                Ok(dependencies) => dependencies,
328                Err(error) => return bypass(format!("{error:#}")),
329            };
330        drop(dependency_profile);
331        let input_profile = profile::span(&task.id, "inputs");
332        let (key, inputs) = match hash::inputs(
333            snapshot,
334            workspace,
335            task,
336            prepared,
337            &dependencies,
338            cancelled,
339        )
340        .and_then(|inputs| Ok((hash::key(&inputs)?, inputs)))
341        {
342            Ok(key) => key,
343            Err(error) => return bypass(format!("{error:#}")),
344        };
345        drop(input_profile);
346        let keyed = Some((key.clone(), inputs));
347        // As in Nx, the task sees its hash.
348        running
349            .execution
350            .insert("NX_TASK_HASH".into(), key.clone().into());
351        let running = &running;
352        let outputs = match paths::Outputs::new(workspace, task) {
353            Ok(outputs) => outputs,
354            Err(error) => return bypass(format!("{error:#}")),
355        };
356        // Only dependents read a fingerprint. Hashing the outputs of a task none
357        // depends on, after it ran, can mean reading gigabytes for nothing.
358        let depended_on = graph
359            .tasks
360            .values()
361            .any(|other| other.dependencies.contains(&task.id));
362        let fingerprint_outputs = |key: &str| {
363            if depended_on {
364                outputs_fingerprint(task, &workspace.root, &outputs, key)
365            } else {
366                Err(format!("{}: no task depends on it", task.id))
367            }
368        };
369        if !cacheable {
370            let restored = before(running);
371            let executing = profile::span(&task.id, "execute");
372            let outcome = execute(running, cancelled)?;
373            drop(executing);
374            let warm_report = after(outcome, restored, running);
375            let fingerprint = if self.unchanged(
376                snapshot,
377                workspace,
378                graph,
379                task,
380                prepared,
381                &dependencies,
382                &key,
383                outcome,
384                cancelled,
385            ) {
386                fingerprint_outputs(&key)
387            } else {
388                Err(format!("{}: did not complete unchanged", task.id))
389            };
390            return Ok(TaskResult {
391                outcome,
392                fingerprint,
393                cache: CacheStatus::Uncached,
394                key: keyed,
395                warm: warm_report,
396                execution: 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 = self.inputs_match(
405                snapshot,
406                workspace,
407                graph,
408                task,
409                prepared,
410                &dependencies,
411                &key,
412                cancelled,
413            );
414            let fingerprint = if outcome == Outcome::Success && inputs_unchanged {
415                fingerprint_outputs(&key)
416            } else {
417                Err(format!("{}: did not complete unchanged", task.id))
418            };
419            return Ok(TaskResult {
420                outcome,
421                fingerprint,
422                cache: CacheStatus::Uncached,
423                key: keyed,
424                warm: None,
425                execution: capture.take_log().map(|log| Execution {
426                    log,
427                    inputs_unchanged,
428                }),
429            });
430        }
431        if let Err(error) = self.initialize() {
432            return bypass(format!("cache unavailable: {error}"));
433        }
434        let (_lock, waited) = match self.lock(&key, cancelled) {
435            Ok(Some(lock)) => lock,
436            Ok(None) => {
437                return Ok(TaskResult::uncached(
438                    Outcome::Cancelled,
439                    format!("{}: cancelled", task.id),
440                ));
441            }
442            Err(error) => return bypass(format!("cache lock unavailable: {error}")),
443        };
444        // Inputs may have changed while another worktree held this key's lock.
445        if waited
446            && hash::recheck_dependency_keys(
447                snapshot,
448                workspace,
449                graph,
450                task,
451                &dependencies,
452                cancelled,
453            )
454            .and_then(|dependencies| {
455                hash::fingerprint(
456                    snapshot,
457                    workspace,
458                    task,
459                    prepared,
460                    &dependencies,
461                    cancelled,
462                )
463            })
464            .ok()
465            .as_ref()
466                != Some(&key)
467        {
468            return bypass("inputs changed while waiting for another run".into());
469        }
470        // An entry fetched ahead of this task is local, but it came from the
471        // remote store, and is reported as such.
472        let prefetched = self.remote.as_ref().is_some_and(|remote| {
473            remote.resolved(&key);
474            remote.prefetched(&key)
475        });
476        let local_profile = profile::span(&task.id, "local_restore");
477        match self.restore(
478            &workspace.root,
479            &task.id,
480            &task.dependencies,
481            &key,
482            &outputs,
483            &prepared.display,
484            if prefetched {
485                qk_executor::Shown::RemoteCache
486            } else {
487                qk_executor::Shown::LocalCache
488            },
489        ) {
490            Ok(Some(fingerprint)) => {
491                let claimed = match &self.remote {
492                    Some(remote) if prefetched && remote.claim(&key) => true,
493                    Some(remote) => {
494                        remote.held(&key);
495                        false
496                    }
497                    None => false,
498                };
499                qk_executor::report::event(qk_executor::report::Event::Cache {
500                    id: task.id.clone(),
501                    cached: if claimed {
502                        qk_executor::report::Cached::RemoteHit
503                    } else {
504                        qk_executor::report::Cached::Hit
505                    },
506                });
507                return Ok(TaskResult {
508                    outcome: Outcome::Success,
509                    fingerprint: Ok(fingerprint),
510                    cache: if claimed {
511                        CacheStatus::RemoteHit
512                    } else {
513                        CacheStatus::LocalHit
514                    },
515                    key: keyed,
516                    warm: None,
517                    execution: None,
518                });
519            }
520            Ok(None) => {}
521            Err(error) => {
522                qk_executor::status!("qk: {}: ignoring unusable cache entry ({error})", task.id)
523            }
524        }
525        drop(local_profile);
526        if let Some(remote) = &self.remote {
527            let fetch_profile = profile::span(&task.id, "remote_fetch");
528            let fetched = remote.fetch(&self.root, &key, &task.id, cancelled);
529            drop(fetch_profile);
530            let fetched = fetched.and_then(|found| {
531                if !found {
532                    return Ok(None);
533                }
534                self.restore(
535                    &workspace.root,
536                    &task.id,
537                    &task.dependencies,
538                    &key,
539                    &outputs,
540                    &prepared.display,
541                    qk_executor::Shown::RemoteCache,
542                )
543            });
544            match fetched {
545                Ok(Some(fingerprint)) => {
546                    qk_executor::report::event(qk_executor::report::Event::Cache {
547                        id: task.id.clone(),
548                        cached: qk_executor::report::Cached::RemoteHit,
549                    });
550                    return Ok(TaskResult {
551                        outcome: Outcome::Success,
552                        fingerprint: Ok(fingerprint),
553                        cache: CacheStatus::RemoteHit,
554                        key: keyed,
555                        warm: None,
556                        execution: None,
557                    });
558                }
559                Ok(None) => {}
560                Err(error) => {
561                    qk_executor::status!("qk: {}: remote cache unavailable ({error:#})", task.id)
562                }
563            }
564        }
565        qk_executor::report::event(qk_executor::report::Event::Cache {
566            id: task.id.clone(),
567            cached: qk_executor::report::Cached::Miss,
568        });
569        let log = match tempfile::NamedTempFile::new_in(self.root.join("tmp")) {
570            Ok(log) => log,
571            Err(error) => return bypass(format!("cache log unavailable: {error}")),
572        };
573        let capture =
574            Capture::new(Some(log.as_file().try_clone()?), prepared.display.clone()).retain_log();
575        let restored = before(running);
576        let executing = profile::span(&task.id, "execute");
577        let outcome = execute_captured(running, cancelled, Some(&capture))?;
578        drop(executing);
579        let warm_report = after(outcome, restored, running);
580        let inputs_unchanged = self.inputs_match(
581            snapshot,
582            workspace,
583            graph,
584            task,
585            prepared,
586            &dependencies,
587            &key,
588            cancelled,
589        );
590        let execution = capture.take_log().map(|log| Execution {
591            log,
592            inputs_unchanged,
593        });
594        let fingerprint = if outcome != Outcome::Success || !inputs_unchanged {
595            Err(format!("{}: did not complete unchanged", task.id))
596        } else if !capture.healthy() {
597            fingerprint_outputs(&key)
598        } else {
599            // The saved manifest already hashes every output; reuse it.
600            let publishing = profile::span(&task.id, "publish");
601            let published = self.publish(&workspace.root, &key, &outputs, log.path());
602            drop(publishing);
603            match published {
604                Ok(fingerprint) => {
605                    store::record_outputs(&workspace.root, &task.id, &key, &outputs);
606                    if let Some(remote) = &self.remote {
607                        remote.upload(&self.root, &key);
608                    }
609                    Ok(fingerprint)
610                }
611                Err(error) => {
612                    qk_executor::status!("qk: {}: could not save cache entry ({error})", task.id);
613                    fingerprint_outputs(&key)
614                }
615            }
616        };
617        Ok(TaskResult {
618            outcome,
619            fingerprint,
620            cache: CacheStatus::Miss,
621            key: keyed,
622            warm: warm_report,
623            execution,
624        })
625    }
626
627    /// Whether a successful task's inputs still match the key it ran under.
628    #[allow(clippy::too_many_arguments)]
629    fn unchanged(
630        &self,
631        snapshot: &hash::Snapshot,
632        workspace: &Workspace,
633        graph: &TaskGraph,
634        task: &Task,
635        prepared: &PreparedTask,
636        dependencies: &BTreeMap<String, String>,
637        before: &str,
638        outcome: Outcome,
639        cancelled: &AtomicBool,
640    ) -> bool {
641        if outcome != Outcome::Success || cancelled.load(Ordering::SeqCst) {
642            return false;
643        }
644        self.inputs_match(
645            snapshot,
646            workspace,
647            graph,
648            task,
649            prepared,
650            dependencies,
651            before,
652            cancelled,
653        )
654    }
655
656    /// Rechecks declared inputs for successful and failed executions alike.
657    #[allow(clippy::too_many_arguments)]
658    fn inputs_match(
659        &self,
660        snapshot: &hash::Snapshot,
661        workspace: &Workspace,
662        graph: &TaskGraph,
663        task: &Task,
664        prepared: &PreparedTask,
665        dependencies: &BTreeMap<String, String>,
666        before: &str,
667        cancelled: &AtomicBool,
668    ) -> bool {
669        if cancelled.load(Ordering::SeqCst) {
670            return false;
671        }
672        let _profile = profile::span(&task.id, "inputs_recheck");
673        let after = hash::recheck_dependency_keys(
674            snapshot,
675            workspace,
676            graph,
677            task,
678            dependencies,
679            cancelled,
680        )
681        .and_then(|dependencies| {
682            hash::fingerprint(
683                snapshot,
684                workspace,
685                task,
686                prepared,
687                &dependencies,
688                cancelled,
689            )
690        });
691        if after.as_deref().ok() != Some(before) {
692            if task.definition.cache == Some(true) {
693                if self.reuse {
694                    qk_executor::status!(
695                        "qk: {}: not caching because inputs changed during execution",
696                        task.id
697                    );
698                } else {
699                    qk_executor::status!(
700                        "qk: {}: excluding execution from flaky results because inputs changed during execution",
701                        task.id
702                    );
703                }
704            }
705            return false;
706        }
707        true
708    }
709}
710
711/// Per-output values for dependents' keys. Disk and manifest must agree exactly,
712/// so a dependent's key is the same whether this task was restored or executed.
713pub(crate) fn file_output(content: &str, mode: u32) -> Value {
714    json!({"content": content, "mode": mode})
715}
716
717pub(crate) fn link_output(target: &str) -> Value {
718    json!({"link": target})
719}
720
721pub(crate) fn directory_output() -> Value {
722    json!("directory")
723}
724
725/// What a task's dependents are keyed on. A task that declares outputs is
726/// known to them only through those outputs, so a run that reproduces them
727/// leaves its dependents cached however its own inputs changed. A task
728/// without declared outputs may affect its dependents in ways qk cannot see,
729/// so they are keyed on its inputs.
730pub(crate) fn combine_outputs(
731    input: &str,
732    files: &BTreeMap<String, Value>,
733    declared: bool,
734) -> Result<String> {
735    let identity = if declared { "declared outputs" } else { input };
736    Ok(blake3::hash(&serde_json::to_vec(&(identity, files))?)
737        .to_hex()
738        .to_string())
739}
740
741fn outputs_fingerprint(
742    task: &Task,
743    root: &Path,
744    outputs: &paths::Outputs,
745    input: &str,
746) -> Fingerprint {
747    output_fingerprint(root, outputs, input)
748        .map_err(|error| format!("{}: cannot fingerprint outputs: {error:#}", task.id))
749}
750
751fn output_fingerprint(root: &Path, outputs: &paths::Outputs, input: &str) -> Result<String> {
752    let mut files = BTreeMap::new();
753    for entry in outputs.entries(root)? {
754        let (path, metadata) = entry?;
755        paths::safe_parents(root, &path)?;
756        let absolute = root.join(&path);
757        let value = if metadata.file_type().is_symlink() {
758            link_output(
759                std::fs::read_link(absolute)?
760                    .to_str()
761                    .context("symlink target must be UTF-8")?,
762            )
763        } else if metadata.is_file() {
764            file_output(&hash::digest_file(&absolute)?, store::mode(&metadata))
765        } else {
766            directory_output()
767        };
768        files.insert(path, value);
769    }
770    combine_outputs(input, &files, outputs.declared())
771}
772
773/// A task's key, and the inputs it is computed from, or why it has none.
774pub type Keyed = std::result::Result<(String, Value), String>;
775
776/// The key each task in `graph` would be cached under if it ran now, and what
777/// it is computed from, without running a task. Runtime inputs are executed.
778/// A dependency counts as having succeeded, fingerprinted by its outputs as
779/// they are on disk.
780pub fn keys(
781    workspace: &Workspace,
782    graph: &TaskGraph,
783    prepared: &BTreeMap<String, PreparedTask>,
784    cancelled: &AtomicBool,
785) -> Result<BTreeMap<String, Keyed>> {
786    let snapshot = hash::Snapshot::new(workspace, graph, &paths::cache_location(workspace))?;
787    let mut keys = BTreeMap::new();
788    let mut fingerprints: BTreeMap<String, Fingerprint> = BTreeMap::new();
789    let mut pending: Vec<&Task> = graph.tasks.values().collect();
790    // Dependencies first, as the runner keys them.
791    while !pending.is_empty() {
792        let waiting = pending.len();
793        pending.retain(|task| {
794            if !task
795                .dependencies
796                .iter()
797                .all(|id| fingerprints.contains_key(id))
798            {
799                return true;
800            }
801            let keyed = key_now(
802                &snapshot,
803                workspace,
804                graph,
805                task,
806                prepared,
807                &fingerprints,
808                cancelled,
809            );
810            let fingerprint = if task.definition.continuous == Some(true) {
811                Err(format!(
812                    "{}: continuous tasks are not fingerprinted",
813                    task.id
814                ))
815            } else if !prepared[&task.id].ready_when.is_empty() {
816                Err(format!(
817                    "{}: tasks with readyWhen are not fingerprinted",
818                    task.id
819                ))
820            } else {
821                keyed.clone().and_then(|(key, _)| {
822                    let outputs = paths::Outputs::new(workspace, task)
823                        .map_err(|error| format!("{}: {error:#}", task.id))?;
824                    outputs_fingerprint(task, &workspace.root, &outputs, &key)
825                })
826            };
827            fingerprints.insert(task.id.clone(), fingerprint);
828            keys.insert(task.id.clone(), keyed);
829            false
830        });
831        anyhow::ensure!(pending.len() < waiting, "the task graph has a cycle");
832    }
833    Ok(keys)
834}
835
836fn key_now(
837    snapshot: &hash::Snapshot,
838    workspace: &Workspace,
839    graph: &TaskGraph,
840    task: &Task,
841    prepared: &BTreeMap<String, PreparedTask>,
842    fingerprints: &BTreeMap<String, Fingerprint>,
843    cancelled: &AtomicBool,
844) -> Keyed {
845    let dependencies = task
846        .dependencies
847        .iter()
848        .map(|id| fingerprints[id].clone().map(|key| (id.clone(), key)))
849        .collect::<std::result::Result<BTreeMap<_, _>, _>>()?;
850    let failed = |error: anyhow::Error| format!("{}: {error:#}", task.id);
851    let dependencies =
852        hash::dependency_keys(snapshot, workspace, graph, task, &dependencies, cancelled)
853            .map_err(failed)?;
854    let inputs = hash::inputs(
855        snapshot,
856        workspace,
857        task,
858        &prepared[&task.id],
859        &dependencies,
860        cancelled,
861    )
862    .map_err(failed)?;
863    Ok((hash::key(&inputs).map_err(failed)?, inputs))
864}
865
866/// Runs the private detached-upload protocol used by the qk executable.
867#[doc(hidden)]
868pub fn run_upload_worker() -> anyhow::Result<()> {
869    remote::background::run()
870}
871
872#[cfg(test)]
873mod tests {
874    use super::*;
875
876    #[test]
877    fn concurrent_blob_writes_keep_results_ordered_and_repair_corruption() {
878        let workspace = tempfile::tempdir().unwrap();
879        let root = workspace.path();
880        let cache = Cache::new(root.join("cache"));
881        cache.initialize().unwrap();
882        let mut sources = Vec::new();
883        for index in 0..130 {
884            let source = root.join(format!("source-{index}"));
885            std::fs::write(&source, format!("content-{}", index % 65)).unwrap();
886            sources.push(source);
887        }
888        let digest = cache.put_blob(&sources[0]).unwrap();
889        std::fs::write(cache.root.join("blobs").join(&digest), "corrupt").unwrap();
890        std::fs::remove_file(&sources[31]).unwrap();
891        let results = cache.put_blobs(&sources);
892        assert_eq!(results.len(), sources.len());
893        for (index, result) in results.into_iter().enumerate() {
894            if index == 31 {
895                assert_eq!(
896                    result
897                        .unwrap_err()
898                        .downcast_ref::<std::io::Error>()
899                        .unwrap()
900                        .kind(),
901                    std::io::ErrorKind::NotFound
902                );
903                continue;
904            }
905            let digest = result.unwrap();
906            let held = cache.root.join("blobs").join(&digest);
907            assert_eq!(
908                std::fs::read(&held).unwrap(),
909                format!("content-{}", index % 65).as_bytes()
910            );
911            assert_eq!(hash::digest_file(&held).unwrap(), digest);
912        }
913    }
914
915    /// Content the store holds already is reused, not copied over again.
916    #[cfg(unix)]
917    #[test]
918    fn a_blob_the_store_holds_is_not_written_again() {
919        use std::os::unix::fs::MetadataExt;
920        let workspace = tempfile::tempdir().unwrap();
921        let root = workspace.path();
922        let cache = Cache::new(root.join(".qk/cache"));
923        cache.initialize().unwrap();
924        std::fs::write(root.join("first"), "same").unwrap();
925        std::fs::write(root.join("second"), "same").unwrap();
926        std::fs::write(root.join("other"), "different").unwrap();
927        let first = cache.put_blob(&root.join("first")).unwrap();
928        let blob = cache.root.join("blobs").join(&first);
929        let stored = std::fs::metadata(&blob).unwrap().ino();
930        assert_eq!(cache.put_blob(&root.join("second")).unwrap(), first);
931        assert_eq!(std::fs::metadata(&blob).unwrap().ino(), stored);
932        // A stored blob whose content no longer matches is stored anew.
933        std::fs::write(&blob, "corrupt").unwrap();
934        assert_eq!(cache.put_blob(&root.join("second")).unwrap(), first);
935        assert_eq!(std::fs::read(&blob).unwrap(), b"same");
936        // So is one that cannot be read.
937        {
938            use std::os::unix::fs::PermissionsExt;
939            std::fs::set_permissions(&blob, std::fs::Permissions::from_mode(0o000)).unwrap();
940        }
941        assert_eq!(cache.put_blob(&root.join("second")).unwrap(), first);
942        assert_eq!(std::fs::read(&blob).unwrap(), b"same");
943        let other = cache.put_blob(&root.join("other")).unwrap();
944        assert_ne!(other, first);
945        assert_eq!(
946            std::fs::read(cache.root.join("blobs").join(other)).unwrap(),
947            b"different"
948        );
949    }
950
951    /// Complete-directory restore preserves contents, links, modes, timestamps
952    /// and fingerprints, and the subsequent hit keeps restored files in place.
953    #[test]
954    fn manifest_and_disk_output_fingerprints_agree() {
955        let workspace = tempfile::tempdir().unwrap();
956        let root = workspace.path();
957        std::fs::create_dir_all(root.join("dist/nested/empty")).unwrap();
958        std::fs::write(root.join("dist/nested/a.txt"), "a").unwrap();
959        std::fs::write(root.join("dist/b.bin"), [0, 1, 2]).unwrap();
960        std::fs::create_dir_all(root.join("bundle/empty")).unwrap();
961        std::fs::write(root.join("bundle/readonly"), "readonly").unwrap();
962        #[cfg(unix)]
963        {
964            use std::os::unix::fs::PermissionsExt;
965            let script = root.join("dist/run.sh");
966            std::fs::write(&script, "#!/bin/sh\n").unwrap();
967            std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
968            std::os::unix::fs::symlink("nested/a.txt", root.join("dist/link")).unwrap();
969            std::fs::set_permissions(
970                root.join("dist/nested"),
971                std::fs::Permissions::from_mode(0o500),
972            )
973            .unwrap();
974            std::fs::set_permissions(
975                root.join("bundle/readonly"),
976                std::fs::Permissions::from_mode(0o444),
977            )
978            .unwrap();
979        }
980        let log = root.join("log");
981        std::fs::write(&log, "").unwrap();
982
983        let cache = Cache::new(root.join(".qk/cache"));
984        cache.initialize().unwrap();
985        let outputs = paths::Outputs::from_patterns(&["dist", "bundle"]);
986        let key = "0".repeat(64);
987        let saved = cache.publish(root, &key, &outputs, &log).unwrap();
988        assert_eq!(saved, output_fingerprint(root, &outputs, &key).unwrap());
989
990        #[cfg(unix)]
991        {
992            use std::os::unix::fs::PermissionsExt;
993            // The manifest retains the restrictive mode; cleanup needs write
994            // permission on the old directory to remove its files.
995            std::fs::set_permissions(
996                root.join("dist/nested"),
997                std::fs::Permissions::from_mode(0o755),
998            )
999            .unwrap();
1000        }
1001        std::fs::remove_dir_all(root.join("dist")).unwrap();
1002        std::fs::remove_dir_all(root.join("bundle")).unwrap();
1003        let before = std::time::SystemTime::now();
1004        let restored = cache
1005            .restore(
1006                root,
1007                "app:build",
1008                &Default::default(),
1009                &key,
1010                &outputs,
1011                &qk_executor::Display::Stream,
1012                qk_executor::Shown::LocalCache,
1013            )
1014            .unwrap();
1015        assert_eq!(restored, Some(saved.clone()));
1016        assert_eq!(saved, output_fingerprint(root, &outputs, &key).unwrap());
1017        assert!(root.join("dist/nested/empty").is_dir());
1018        assert!(root.join("bundle/empty").is_dir());
1019        assert_eq!(
1020            std::fs::read_to_string(root.join("bundle/readonly")).unwrap(),
1021            "readonly"
1022        );
1023        #[cfg(unix)]
1024        {
1025            assert_eq!(
1026                std::fs::read_link(root.join("dist/link")).unwrap(),
1027                Path::new("nested/a.txt")
1028            );
1029            assert_eq!(
1030                store::mode(&std::fs::metadata(root.join("dist/run.sh")).unwrap()),
1031                0o755
1032            );
1033            assert_eq!(
1034                store::mode(&std::fs::metadata(root.join("dist/nested")).unwrap()),
1035                0o500
1036            );
1037            assert_eq!(
1038                store::mode(&std::fs::metadata(root.join("bundle/readonly")).unwrap()),
1039                0o444
1040            );
1041        }
1042        let path = root.join("dist/nested/a.txt");
1043        let modified = std::fs::metadata(&path).unwrap().modified().unwrap();
1044        assert!(modified >= before);
1045        let kept = cache
1046            .restore(
1047                root,
1048                "app:build",
1049                &Default::default(),
1050                &key,
1051                &outputs,
1052                &qk_executor::Display::Hidden,
1053                qk_executor::Shown::LocalCache,
1054            )
1055            .unwrap();
1056        assert_eq!(kept, Some(saved));
1057        assert_eq!(
1058            std::fs::metadata(&path).unwrap().modified().unwrap(),
1059            modified
1060        );
1061        #[cfg(unix)]
1062        {
1063            use std::os::unix::fs::PermissionsExt;
1064            std::fs::set_permissions(
1065                root.join("dist/nested"),
1066                std::fs::Permissions::from_mode(0o755),
1067            )
1068            .unwrap();
1069        }
1070    }
1071}