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