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