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