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};
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        if !cacheable {
352            let restored = before(running);
353            let outcome = execute(running, cancelled)?;
354            let warm_report = after(outcome, restored, running);
355            let fingerprint = if self.unchanged(
356                snapshot,
357                workspace,
358                graph,
359                task,
360                prepared,
361                &dependencies,
362                &key,
363                outcome,
364                cancelled,
365            ) {
366                outputs_fingerprint(task, &workspace.root, &outputs, &key)
367            } else {
368                Err(format!("{}: did not complete unchanged", task.id))
369            };
370            return Ok(TaskResult {
371                outcome,
372                fingerprint,
373                cache: CacheStatus::Uncached,
374                key: keyed,
375                warm: warm_report,
376                execution: None,
377            });
378        }
379        if !self.reuse {
380            let capture = Capture::new(None, prepared.display.clone()).retain_log();
381            let outcome = execute_captured(running, cancelled, Some(&capture))?;
382            let inputs_unchanged = self.inputs_match(
383                snapshot,
384                workspace,
385                graph,
386                task,
387                prepared,
388                &dependencies,
389                &key,
390                cancelled,
391            );
392            let fingerprint = if outcome == Outcome::Success && inputs_unchanged {
393                outputs_fingerprint(task, &workspace.root, &outputs, &key)
394            } else {
395                Err(format!("{}: did not complete unchanged", task.id))
396            };
397            return Ok(TaskResult {
398                outcome,
399                fingerprint,
400                cache: CacheStatus::Uncached,
401                key: keyed,
402                warm: None,
403                execution: capture.take_log().map(|log| Execution {
404                    log,
405                    inputs_unchanged,
406                }),
407            });
408        }
409        if let Err(error) = self.initialize() {
410            return bypass(format!("cache unavailable: {error}"));
411        }
412        let (_lock, waited) = match self.lock(&key, cancelled) {
413            Ok(Some(lock)) => lock,
414            Ok(None) => {
415                return Ok(TaskResult::uncached(
416                    Outcome::Cancelled,
417                    format!("{}: cancelled", task.id),
418                ));
419            }
420            Err(error) => return bypass(format!("cache lock unavailable: {error}")),
421        };
422        // Inputs may have changed while another worktree held this key's lock.
423        if waited
424            && hash::recheck_dependency_keys(
425                snapshot,
426                workspace,
427                graph,
428                task,
429                &dependencies,
430                cancelled,
431            )
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        let local_profile = profile::span(&task.id, "local_restore");
449        match self.restore(
450            &workspace.root,
451            &task.id,
452            &task.dependencies,
453            &key,
454            &outputs,
455            &prepared.display,
456            qk_executor::Shown::LocalCache,
457        ) {
458            Ok(Some(fingerprint)) => {
459                if let Some(remote) = &self.remote {
460                    remote.held(&key);
461                }
462                qk_executor::report::event(qk_executor::report::Event::Cache {
463                    id: task.id.clone(),
464                    cached: qk_executor::report::Cached::Hit,
465                });
466                return Ok(TaskResult {
467                    outcome: Outcome::Success,
468                    fingerprint: Ok(fingerprint),
469                    cache: CacheStatus::LocalHit,
470                    key: keyed,
471                    warm: None,
472                    execution: None,
473                });
474            }
475            Ok(None) => {}
476            Err(error) => {
477                qk_executor::status!("qk: {}: ignoring unusable cache entry ({error})", task.id)
478            }
479        }
480        drop(local_profile);
481        if let Some(remote) = &self.remote {
482            let fetch_profile = profile::span(&task.id, "remote_fetch");
483            let fetched = remote.fetch(&self.root, &key);
484            drop(fetch_profile);
485            let fetched = fetched.and_then(|found| {
486                if !found {
487                    return Ok(None);
488                }
489                self.restore(
490                    &workspace.root,
491                    &task.id,
492                    &task.dependencies,
493                    &key,
494                    &outputs,
495                    &prepared.display,
496                    qk_executor::Shown::RemoteCache,
497                )
498            });
499            match fetched {
500                Ok(Some(fingerprint)) => {
501                    qk_executor::report::event(qk_executor::report::Event::Cache {
502                        id: task.id.clone(),
503                        cached: qk_executor::report::Cached::RemoteHit,
504                    });
505                    return Ok(TaskResult {
506                        outcome: Outcome::Success,
507                        fingerprint: Ok(fingerprint),
508                        cache: CacheStatus::RemoteHit,
509                        key: keyed,
510                        warm: None,
511                        execution: None,
512                    });
513                }
514                Ok(None) => {}
515                Err(error) => {
516                    qk_executor::status!("qk: {}: remote cache unavailable ({error:#})", task.id)
517                }
518            }
519        }
520        qk_executor::report::event(qk_executor::report::Event::Cache {
521            id: task.id.clone(),
522            cached: qk_executor::report::Cached::Miss,
523        });
524        let log = match tempfile::NamedTempFile::new_in(self.root.join("tmp")) {
525            Ok(log) => log,
526            Err(error) => return bypass(format!("cache log unavailable: {error}")),
527        };
528        let capture =
529            Capture::new(Some(log.as_file().try_clone()?), prepared.display.clone()).retain_log();
530        let restored = before(running);
531        let outcome = execute_captured(running, cancelled, Some(&capture))?;
532        let warm_report = after(outcome, restored, running);
533        let inputs_unchanged = self.inputs_match(
534            snapshot,
535            workspace,
536            graph,
537            task,
538            prepared,
539            &dependencies,
540            &key,
541            cancelled,
542        );
543        let execution = capture.take_log().map(|log| Execution {
544            log,
545            inputs_unchanged,
546        });
547        let fingerprint = if outcome != Outcome::Success || !inputs_unchanged {
548            Err(format!("{}: did not complete unchanged", task.id))
549        } else if !capture.healthy() {
550            outputs_fingerprint(task, &workspace.root, &outputs, &key)
551        } else {
552            // The saved manifest already hashes every output; reuse it.
553            match self.publish(&workspace.root, &key, &outputs, log.path()) {
554                Ok(fingerprint) => {
555                    store::record_outputs(&workspace.root, &task.id, &key, &outputs);
556                    if let Some(remote) = &self.remote {
557                        remote.upload(&self.root, &key);
558                    }
559                    Ok(fingerprint)
560                }
561                Err(error) => {
562                    qk_executor::status!("qk: {}: could not save cache entry ({error})", task.id);
563                    outputs_fingerprint(task, &workspace.root, &outputs, &key)
564                }
565            }
566        };
567        Ok(TaskResult {
568            outcome,
569            fingerprint,
570            cache: CacheStatus::Miss,
571            key: keyed,
572            warm: warm_report,
573            execution,
574        })
575    }
576
577    /// Whether a successful task's inputs still match the key it ran under.
578    #[allow(clippy::too_many_arguments)]
579    fn unchanged(
580        &self,
581        snapshot: &hash::Snapshot,
582        workspace: &Workspace,
583        graph: &TaskGraph,
584        task: &Task,
585        prepared: &PreparedTask,
586        dependencies: &BTreeMap<String, String>,
587        before: &str,
588        outcome: Outcome,
589        cancelled: &AtomicBool,
590    ) -> bool {
591        if outcome != Outcome::Success || cancelled.load(Ordering::SeqCst) {
592            return false;
593        }
594        self.inputs_match(
595            snapshot,
596            workspace,
597            graph,
598            task,
599            prepared,
600            dependencies,
601            before,
602            cancelled,
603        )
604    }
605
606    /// Rechecks declared inputs for successful and failed executions alike.
607    #[allow(clippy::too_many_arguments)]
608    fn inputs_match(
609        &self,
610        snapshot: &hash::Snapshot,
611        workspace: &Workspace,
612        graph: &TaskGraph,
613        task: &Task,
614        prepared: &PreparedTask,
615        dependencies: &BTreeMap<String, String>,
616        before: &str,
617        cancelled: &AtomicBool,
618    ) -> bool {
619        if cancelled.load(Ordering::SeqCst) {
620            return false;
621        }
622        let after = hash::recheck_dependency_keys(
623            snapshot,
624            workspace,
625            graph,
626            task,
627            dependencies,
628            cancelled,
629        )
630        .and_then(|dependencies| {
631            hash::fingerprint(
632                snapshot,
633                workspace,
634                task,
635                prepared,
636                &dependencies,
637                cancelled,
638            )
639        });
640        if after.as_deref().ok() != Some(before) {
641            if task.definition.cache == Some(true) {
642                if self.reuse {
643                    qk_executor::status!(
644                        "qk: {}: not caching because inputs changed during execution",
645                        task.id
646                    );
647                } else {
648                    qk_executor::status!(
649                        "qk: {}: excluding execution from flaky results because inputs changed during execution",
650                        task.id
651                    );
652                }
653            }
654            return false;
655        }
656        true
657    }
658}
659
660/// Per-output values for dependents' keys. Disk and manifest must agree exactly,
661/// so a dependent's key is the same whether this task was restored or executed.
662pub(crate) fn file_output(content: &str, mode: u32) -> Value {
663    json!({"content": content, "mode": mode})
664}
665
666pub(crate) fn link_output(target: &str) -> Value {
667    json!({"link": target})
668}
669
670pub(crate) fn directory_output() -> Value {
671    json!("directory")
672}
673
674/// What a task's dependents are keyed on. A task that declares outputs is
675/// known to them only through those outputs, so a run that reproduces them
676/// leaves its dependents cached however its own inputs changed. A task
677/// without declared outputs may affect its dependents in ways qk cannot see,
678/// so they are keyed on its inputs.
679pub(crate) fn combine_outputs(
680    input: &str,
681    files: &BTreeMap<String, Value>,
682    declared: bool,
683) -> Result<String> {
684    let identity = if declared { "declared outputs" } else { input };
685    Ok(blake3::hash(&serde_json::to_vec(&(identity, files))?)
686        .to_hex()
687        .to_string())
688}
689
690fn outputs_fingerprint(
691    task: &Task,
692    root: &Path,
693    outputs: &paths::Outputs,
694    input: &str,
695) -> Fingerprint {
696    output_fingerprint(root, outputs, input)
697        .map_err(|error| format!("{}: cannot fingerprint outputs: {error:#}", task.id))
698}
699
700fn output_fingerprint(root: &Path, outputs: &paths::Outputs, input: &str) -> Result<String> {
701    let mut files = BTreeMap::new();
702    for entry in outputs.entries(root)? {
703        let (path, metadata) = entry?;
704        paths::safe_parents(root, &path)?;
705        let absolute = root.join(&path);
706        let value = if metadata.file_type().is_symlink() {
707            link_output(
708                std::fs::read_link(absolute)?
709                    .to_str()
710                    .context("symlink target must be UTF-8")?,
711            )
712        } else if metadata.is_file() {
713            file_output(&hash::digest_file(&absolute)?, store::mode(&metadata))
714        } else {
715            directory_output()
716        };
717        files.insert(path, value);
718    }
719    combine_outputs(input, &files, outputs.declared())
720}
721
722/// A task's key, and the inputs it is computed from, or why it has none.
723pub type Keyed = std::result::Result<(String, Value), String>;
724
725/// The key each task in `graph` would be cached under if it ran now, and what
726/// it is computed from, without running a task. Runtime inputs are executed.
727/// A dependency counts as having succeeded, fingerprinted by its outputs as
728/// they are on disk.
729pub fn keys(
730    workspace: &Workspace,
731    graph: &TaskGraph,
732    prepared: &BTreeMap<String, PreparedTask>,
733    cancelled: &AtomicBool,
734) -> Result<BTreeMap<String, Keyed>> {
735    let snapshot = hash::Snapshot::new(workspace, graph, &paths::cache_location(workspace))?;
736    let mut keys = BTreeMap::new();
737    let mut fingerprints: BTreeMap<String, Fingerprint> = BTreeMap::new();
738    let mut pending: Vec<&Task> = graph.tasks.values().collect();
739    // Dependencies first, as the runner keys them.
740    while !pending.is_empty() {
741        let waiting = pending.len();
742        pending.retain(|task| {
743            if !task
744                .dependencies
745                .iter()
746                .all(|id| fingerprints.contains_key(id))
747            {
748                return true;
749            }
750            let keyed = key_now(
751                &snapshot,
752                workspace,
753                graph,
754                task,
755                prepared,
756                &fingerprints,
757                cancelled,
758            );
759            let fingerprint = if task.definition.continuous == Some(true) {
760                Err(format!(
761                    "{}: continuous tasks are not fingerprinted",
762                    task.id
763                ))
764            } else if !prepared[&task.id].ready_when.is_empty() {
765                Err(format!(
766                    "{}: tasks with readyWhen are not fingerprinted",
767                    task.id
768                ))
769            } else {
770                keyed.clone().and_then(|(key, _)| {
771                    let outputs = paths::Outputs::new(workspace, task)
772                        .map_err(|error| format!("{}: {error:#}", task.id))?;
773                    outputs_fingerprint(task, &workspace.root, &outputs, &key)
774                })
775            };
776            fingerprints.insert(task.id.clone(), fingerprint);
777            keys.insert(task.id.clone(), keyed);
778            false
779        });
780        anyhow::ensure!(pending.len() < waiting, "the task graph has a cycle");
781    }
782    Ok(keys)
783}
784
785fn key_now(
786    snapshot: &hash::Snapshot,
787    workspace: &Workspace,
788    graph: &TaskGraph,
789    task: &Task,
790    prepared: &BTreeMap<String, PreparedTask>,
791    fingerprints: &BTreeMap<String, Fingerprint>,
792    cancelled: &AtomicBool,
793) -> Keyed {
794    let dependencies = task
795        .dependencies
796        .iter()
797        .map(|id| fingerprints[id].clone().map(|key| (id.clone(), key)))
798        .collect::<std::result::Result<BTreeMap<_, _>, _>>()?;
799    let failed = |error: anyhow::Error| format!("{}: {error:#}", task.id);
800    let dependencies =
801        hash::dependency_keys(snapshot, workspace, graph, task, &dependencies, cancelled)
802            .map_err(failed)?;
803    let inputs = hash::inputs(
804        snapshot,
805        workspace,
806        task,
807        &prepared[&task.id],
808        &dependencies,
809        cancelled,
810    )
811    .map_err(failed)?;
812    Ok((hash::key(&inputs).map_err(failed)?, inputs))
813}
814
815/// Runs the private detached-upload protocol used by the qk executable.
816#[doc(hidden)]
817pub fn run_upload_worker() -> anyhow::Result<()> {
818    remote::background::run()
819}
820
821#[cfg(test)]
822mod tests {
823    use super::*;
824
825    #[test]
826    fn concurrent_blob_writes_keep_results_ordered_and_repair_corruption() {
827        let workspace = tempfile::tempdir().unwrap();
828        let root = workspace.path();
829        let cache = Cache::new(root.join("cache"));
830        cache.initialize().unwrap();
831        let mut sources = Vec::new();
832        for index in 0..130 {
833            let source = root.join(format!("source-{index}"));
834            std::fs::write(&source, format!("content-{}", index % 65)).unwrap();
835            sources.push(source);
836        }
837        let digest = cache.put_blob(&sources[0]).unwrap();
838        std::fs::write(cache.root.join("blobs").join(&digest), "corrupt").unwrap();
839        std::fs::remove_file(&sources[31]).unwrap();
840        let results = cache.put_blobs(&sources);
841        assert_eq!(results.len(), sources.len());
842        for (index, result) in results.into_iter().enumerate() {
843            if index == 31 {
844                assert_eq!(
845                    result
846                        .unwrap_err()
847                        .downcast_ref::<std::io::Error>()
848                        .unwrap()
849                        .kind(),
850                    std::io::ErrorKind::NotFound
851                );
852                continue;
853            }
854            let digest = result.unwrap();
855            let held = cache.root.join("blobs").join(&digest);
856            assert_eq!(
857                std::fs::read(&held).unwrap(),
858                format!("content-{}", index % 65).as_bytes()
859            );
860            assert_eq!(hash::digest_file(&held).unwrap(), digest);
861        }
862    }
863
864    /// Content the store holds already is reused, not copied over again.
865    #[cfg(unix)]
866    #[test]
867    fn a_blob_the_store_holds_is_not_written_again() {
868        use std::os::unix::fs::MetadataExt;
869        let workspace = tempfile::tempdir().unwrap();
870        let root = workspace.path();
871        let cache = Cache::new(root.join(".qk/cache"));
872        cache.initialize().unwrap();
873        std::fs::write(root.join("first"), "same").unwrap();
874        std::fs::write(root.join("second"), "same").unwrap();
875        std::fs::write(root.join("other"), "different").unwrap();
876        let first = cache.put_blob(&root.join("first")).unwrap();
877        let blob = cache.root.join("blobs").join(&first);
878        let stored = std::fs::metadata(&blob).unwrap().ino();
879        assert_eq!(cache.put_blob(&root.join("second")).unwrap(), first);
880        assert_eq!(std::fs::metadata(&blob).unwrap().ino(), stored);
881        // A stored blob whose content no longer matches is stored anew.
882        std::fs::write(&blob, "corrupt").unwrap();
883        assert_eq!(cache.put_blob(&root.join("second")).unwrap(), first);
884        assert_eq!(std::fs::read(&blob).unwrap(), b"same");
885        // So is one that cannot be read.
886        {
887            use std::os::unix::fs::PermissionsExt;
888            std::fs::set_permissions(&blob, std::fs::Permissions::from_mode(0o000)).unwrap();
889        }
890        assert_eq!(cache.put_blob(&root.join("second")).unwrap(), first);
891        assert_eq!(std::fs::read(&blob).unwrap(), b"same");
892        let other = cache.put_blob(&root.join("other")).unwrap();
893        assert_ne!(other, first);
894        assert_eq!(
895            std::fs::read(cache.root.join("blobs").join(other)).unwrap(),
896            b"different"
897        );
898    }
899
900    /// Complete-directory restore preserves contents, links, modes, timestamps
901    /// and fingerprints, and the subsequent hit keeps restored files in place.
902    #[test]
903    fn manifest_and_disk_output_fingerprints_agree() {
904        let workspace = tempfile::tempdir().unwrap();
905        let root = workspace.path();
906        std::fs::create_dir_all(root.join("dist/nested/empty")).unwrap();
907        std::fs::write(root.join("dist/nested/a.txt"), "a").unwrap();
908        std::fs::write(root.join("dist/b.bin"), [0, 1, 2]).unwrap();
909        std::fs::create_dir_all(root.join("bundle/empty")).unwrap();
910        std::fs::write(root.join("bundle/readonly"), "readonly").unwrap();
911        #[cfg(unix)]
912        {
913            use std::os::unix::fs::PermissionsExt;
914            let script = root.join("dist/run.sh");
915            std::fs::write(&script, "#!/bin/sh\n").unwrap();
916            std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
917            std::os::unix::fs::symlink("nested/a.txt", root.join("dist/link")).unwrap();
918            std::fs::set_permissions(
919                root.join("dist/nested"),
920                std::fs::Permissions::from_mode(0o500),
921            )
922            .unwrap();
923            std::fs::set_permissions(
924                root.join("bundle/readonly"),
925                std::fs::Permissions::from_mode(0o444),
926            )
927            .unwrap();
928        }
929        let log = root.join("log");
930        std::fs::write(&log, "").unwrap();
931
932        let cache = Cache::new(root.join(".qk/cache"));
933        cache.initialize().unwrap();
934        let outputs = paths::Outputs::from_patterns(&["dist", "bundle"]);
935        let key = "0".repeat(64);
936        let saved = cache.publish(root, &key, &outputs, &log).unwrap();
937        assert_eq!(saved, output_fingerprint(root, &outputs, &key).unwrap());
938
939        #[cfg(unix)]
940        {
941            use std::os::unix::fs::PermissionsExt;
942            // The manifest retains the restrictive mode; cleanup needs write
943            // permission on the old directory to remove its files.
944            std::fs::set_permissions(
945                root.join("dist/nested"),
946                std::fs::Permissions::from_mode(0o755),
947            )
948            .unwrap();
949        }
950        std::fs::remove_dir_all(root.join("dist")).unwrap();
951        std::fs::remove_dir_all(root.join("bundle")).unwrap();
952        let before = std::time::SystemTime::now();
953        let restored = cache
954            .restore(
955                root,
956                "app:build",
957                &Default::default(),
958                &key,
959                &outputs,
960                &qk_executor::Display::Stream,
961                qk_executor::Shown::LocalCache,
962            )
963            .unwrap();
964        assert_eq!(restored, Some(saved.clone()));
965        assert_eq!(saved, output_fingerprint(root, &outputs, &key).unwrap());
966        assert!(root.join("dist/nested/empty").is_dir());
967        assert!(root.join("bundle/empty").is_dir());
968        assert_eq!(
969            std::fs::read_to_string(root.join("bundle/readonly")).unwrap(),
970            "readonly"
971        );
972        #[cfg(unix)]
973        {
974            assert_eq!(
975                std::fs::read_link(root.join("dist/link")).unwrap(),
976                Path::new("nested/a.txt")
977            );
978            assert_eq!(
979                store::mode(&std::fs::metadata(root.join("dist/run.sh")).unwrap()),
980                0o755
981            );
982            assert_eq!(
983                store::mode(&std::fs::metadata(root.join("dist/nested")).unwrap()),
984                0o500
985            );
986            assert_eq!(
987                store::mode(&std::fs::metadata(root.join("bundle/readonly")).unwrap()),
988                0o444
989            );
990        }
991        let path = root.join("dist/nested/a.txt");
992        let modified = std::fs::metadata(&path).unwrap().modified().unwrap();
993        assert!(modified >= before);
994        let kept = cache
995            .restore(
996                root,
997                "app:build",
998                &Default::default(),
999                &key,
1000                &outputs,
1001                &qk_executor::Display::Hidden,
1002                qk_executor::Shown::LocalCache,
1003            )
1004            .unwrap();
1005        assert_eq!(kept, Some(saved));
1006        assert_eq!(
1007            std::fs::metadata(&path).unwrap().modified().unwrap(),
1008            modified
1009        );
1010        #[cfg(unix)]
1011        {
1012            use std::os::unix::fs::PermissionsExt;
1013            std::fs::set_permissions(
1014                root.join("dist/nested"),
1015                std::fs::Permissions::from_mode(0o755),
1016            )
1017            .unwrap();
1018        }
1019    }
1020}