1mod ahead;
4mod evict;
5pub mod expectations;
6mod glob;
7mod hash;
8mod ignored;
9mod inputs;
10mod paths;
11pub mod profile;
12mod record;
13pub mod recorded;
14mod remote;
15pub mod scratch;
16mod store;
17pub mod ultracache;
18pub mod warm;
19
20use std::collections::{BTreeMap, BTreeSet};
21use std::path::PathBuf;
22use std::sync::OnceLock;
23use std::sync::atomic::{AtomicBool, Ordering};
24
25use anyhow::Result;
26use qk_config::Workspace;
27use qk_executor::{Capture, Outcome, PreparedTask, execute, execute_captured};
28use qk_taskgraph::{Task, TaskGraph};
29use serde_json::Value;
30
31pub use evict::{Pruned, max_size, parse_size, prune, prune_automatic};
32pub use glob::Pattern;
33pub use hash::{
34 DependencyGlob, Selects, SourceIgnore, dependency_globs, keyed_definition, source_files,
35 task_configuration,
36};
37pub use inputs::{
38 InputChange, InputChanges, JsonFields, ROOT_TSCONFIG_FILES, Resolution, Resolved, TsConfig,
39 manifest_for_dependents, resolve_each, resolve_tasks, selectively_hash_tsconfig,
40 without_resolution,
41};
42pub use paths::expand as expand_project_path;
44pub use paths::git as workspace_git;
45pub use paths::{
46 Outputs, cache_directory, cache_location, clear_worktree_state, resolved_outputs,
47 worktree_state,
48};
49
50fn remote_settings(workspace: &Workspace) -> (Option<&Value>, Option<&Value>) {
54 let extra = &workspace.config.extra;
55 (extra.get("s3"), extra.get("qk:storeServer"))
56}
57
58fn execute_uncached(
61 task: &PreparedTask,
62 cancelled: &AtomicBool,
63) -> Result<(Outcome, Option<Execution>)> {
64 if task.sandbox.is_none() {
65 return Ok((execute(task, cancelled)?, None));
66 }
67 let capture = Capture::new(None, task.display.clone()).retain_log();
68 let outcome = execute_captured(task, cancelled, Some(&capture))?;
69 let execution = capture.take_log().map(|log| Execution {
70 log,
71 inputs_unchanged: false,
72 });
73 Ok((outcome, execution))
74}
75
76fn scratch_patterns<'a>(
79 root: &'a std::path::Path,
80 prepared: &'a PreparedTask,
81) -> impl Iterator<Item = Pattern> + 'a {
82 prepared.scratch.iter().filter_map(move |scratch| {
83 let relative = scratch.directory.strip_prefix(root).ok()?.to_str()?;
84 let named = glob::escape(&relative.replace('\\', "/"));
86 Pattern::fileset(&named, false).ok()
87 })
88}
89
90impl Cache {
91 fn ignored_reads(
95 &self,
96 workspace: &Workspace,
97 task: &Task,
98 prepared: &PreparedTask,
99 written: &[String],
100 ) -> Result<ultracache::IgnoredReads> {
101 let projects = written
102 .iter()
103 .any(|glob| glob.starts_with('^'))
104 .then(|| {
105 self.projects
106 .get_or_init(|| qk_graph::ProjectGraph::build(workspace).ok())
107 .as_ref()
108 })
109 .flatten();
110 Ok(
111 ultracache::IgnoredReads::new(written, workspace, projects, &task.project)?
112 .with_patterns(scratch_patterns(&workspace.root, prepared)),
113 )
114 }
115}
116
117pub struct Cache {
119 pub root: PathBuf,
120 snapshot: OnceLock<std::result::Result<hash::Snapshot, String>>,
121 remote: Option<std::sync::Arc<remote::Remote>>,
122 reuse: bool,
124 cold: bool,
128 cleared: std::sync::Mutex<std::collections::BTreeSet<String>>,
131 prefetches: OnceLock<warm::Prefetches>,
133 kept: std::sync::Mutex<std::collections::BTreeSet<String>>,
135 saves: std::sync::Mutex<Vec<std::thread::JoinHandle<()>>>,
137 cleanup: std::sync::Mutex<Option<store::Cleanup>>,
138 workspace_outputs: OnceLock<recorded::WorkspaceOutputs>,
140 projects: OnceLock<Option<qk_graph::ProjectGraph>>,
143}
144
145#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
147#[serde(rename_all = "kebab-case")]
148pub enum CacheStatus {
149 LocalHit,
150 RemoteHit,
151 Miss,
152 Uncached,
154}
155
156pub struct Execution {
158 pub log: qk_executor::RecordedLog,
159 pub inputs_unchanged: bool,
160}
161
162pub struct TaskResult {
163 pub outcome: Outcome,
164 pub cache: CacheStatus,
165 pub key: Option<(String, Value)>,
167 pub warm: Option<warm::WarmReport>,
169 pub execution: Option<Execution>,
170 pub recorded: Option<Recorded>,
172 pub shadow: Option<Shadow>,
175}
176
177#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
179#[serde(rename_all = "camelCase", tag = "kind")]
180pub enum Shadow {
181 WouldHit {
183 key: String,
184 #[serde(default, skip_serializing_if = "Option::is_none")]
186 check: Option<Check>,
187 },
188 WouldMiss {
190 #[serde(default, skip_serializing_if = "Vec::is_empty")]
197 changed: Vec<String>,
198 #[serde(default, skip_serializing_if = "Vec::is_empty")]
202 uncovered: Vec<String>,
203 },
204 Unrecorded,
206}
207
208pub const UNCOVERED: usize = 5;
210
211#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
216#[serde(rename_all = "camelCase")]
217pub struct Check {
218 pub matched: bool,
219 #[serde(default, skip_serializing_if = "Vec::is_empty")]
222 pub differences: Vec<String>,
223}
224
225enum Shadowing {
229 Settled(Shadow),
232 Fetching {
235 keys: Vec<String>,
236 fetch: std::thread::JoinHandle<Result<Option<Vec<u8>>>>,
237 },
238}
239
240fn recording_key(
243 weak: &str,
244 set: &recorded::PathSet,
245 snapshot: &hash::Snapshot,
246 workspace: &Workspace,
247 unlisted: &recorded::Unlisted,
248 task: &Task,
249) -> Option<String> {
250 recorded::strong_key(weak, set, snapshot, &workspace.root, unlisted)
251 .inspect_err(|error| {
252 qk_executor::status!("qk: {}: ignoring a path set ({error:#})", task.id);
253 })
254 .ok()
255}
256
257enum Found {
259 Local(String),
261 Remote(String),
263 Nothing,
264}
265
266#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
268#[serde(rename_all = "camelCase", tag = "kind")]
269pub enum Recorded {
270 Hit { key: String },
272 Saved { key: String },
274 Missed,
276 Unrecorded,
279 Declared { key: String },
282 Failed { reason: String },
284}
285
286impl TaskResult {
287 pub fn uncached(outcome: Outcome) -> Self {
288 Self {
289 outcome,
290 cache: CacheStatus::Uncached,
291 key: None,
292 warm: None,
293 execution: None,
294 recorded: None,
295 shadow: None,
296 }
297 }
298}
299
300impl Cache {
301 pub fn new(root: PathBuf) -> Self {
302 Self {
303 root,
304 snapshot: OnceLock::new(),
305 remote: None,
306 reuse: true,
307 cold: false,
308 cleared: Default::default(),
309 prefetches: OnceLock::new(),
310 kept: Default::default(),
311 saves: Default::default(),
312 cleanup: Default::default(),
313 workspace_outputs: OnceLock::new(),
314 projects: OnceLock::new(),
315 }
316 }
317
318 pub fn for_observation(workspace: &Workspace) -> Self {
320 Self {
321 reuse: false,
322 ..Self::new(paths::cache_location(workspace))
323 }
324 }
325
326 pub fn for_workspace(
332 workspace: &Workspace,
333 environment: &BTreeMap<std::ffi::OsString, std::ffi::OsString>,
334 ) -> Self {
335 let root = paths::cache_location(workspace);
336 let (s3, server) = remote_settings(workspace);
337 let remote = match remote::configure(s3, server, environment) {
338 Ok(remote) => remote.map(std::sync::Arc::new),
339 Err(error) => {
340 qk_executor::status!("qk: remote cache disabled: {error:#}");
341 None
342 }
343 };
344 if let Some(remote) = &remote {
345 remote.read_index(&root);
346 }
347 Self {
348 remote,
349 ..Self::new(root)
350 }
351 }
352
353 pub fn cold(self) -> Self {
356 Self { cold: true, ..self }
357 }
358
359 pub fn finish(&self, workspace: &Workspace) {
362 if !self.reuse {
363 return;
364 }
365 if let Some(Ok(snapshot)) = self.snapshot.get() {
366 snapshot.save_digests(&workspace.root);
367 }
368 self.finish_prefetches();
369 for save in std::mem::take(&mut *self.saves.lock().unwrap()) {
371 let _ = save.join();
372 }
373 if let Some(remote) = &self.remote {
374 let failures = remote.finish();
375 for failure in &failures {
376 qk_executor::status!("qk: remote cache upload failed for {failure}");
377 }
378 }
379 let _profile = profile::span("cache", "restore_cleanup_wait");
380 self.cleanup.lock().unwrap().take();
381 }
382
383 fn snapshot(
384 &self,
385 workspace: &Workspace,
386 graph: &TaskGraph,
387 ) -> std::result::Result<&hash::Snapshot, String> {
388 self.snapshot
389 .get_or_init(|| {
390 hash::Snapshot::new(workspace, graph, &self.root)
391 .map_err(|error| format!("cannot snapshot the workspace: {error:#}"))
392 })
393 .as_ref()
394 .map_err(Clone::clone)
395 }
396
397 pub fn run(
398 &self,
399 workspace: &Workspace,
400 graph: &TaskGraph,
401 task: &Task,
402 prepared: &PreparedTask,
403 cancelled: &AtomicBool,
404 ) -> Result<TaskResult> {
405 let result = self.run_task(workspace, graph, task, prepared, cancelled);
406 if let Some(Ok(snapshot)) = self.snapshot.get() {
407 snapshot.outputs_written(workspace, task);
408 }
409 result
410 }
411
412 fn run_task(
413 &self,
414 workspace: &Workspace,
415 graph: &TaskGraph,
416 task: &Task,
417 prepared: &PreparedTask,
418 cancelled: &AtomicBool,
419 ) -> Result<TaskResult> {
420 let cacheable = task.definition.cache == Some(true);
421 let _kept = self
423 .reuse
424 .then(|| warm::keep_across(workspace, graph, task));
425 let entries = if self.reuse {
426 warm::config(workspace, task).unwrap_or_else(|error| {
427 qk_executor::status!("qk: {}: warm state ignored ({error:#})", task.id);
428 Vec::new()
429 })
430 } else {
431 Vec::new()
432 };
433 let entry_label = |warm: &warm::Warm| match warm.group() {
435 Some(group) => format!(" (group {group})"),
436 None => String::new(),
437 };
438 let mut running = prepared.clone();
441 for warm in &entries {
442 for (name, value) in &warm.env {
443 running.execution.insert(name.into(), value.into());
444 }
445 }
446 let uncleared = std::sync::Mutex::new(std::collections::BTreeSet::new());
450 let before = |running: &PreparedTask| -> (Vec<warm::Restored>, Vec<String>) {
451 let (mut restored, mut present) = (Vec::new(), Vec::new());
452 for warm in &entries {
453 if self.cold {
454 if let Err(error) = self.clear_warm(workspace, task, warm) {
456 qk_executor::status!(
457 "qk: {}: warm state{} not cleared, so not saved ({error:#})",
458 task.id,
459 entry_label(warm)
460 );
461 uncleared.lock().unwrap().insert(warm.identity.clone());
462 }
463 continue;
464 }
465 match self
466 .initialize()
467 .and_then(|()| self.restore_warm(workspace, task, warm, running))
468 {
469 Ok((entry, groups)) => {
470 if !entry.groups.is_empty() {
471 restored.push(entry);
472 }
473 present.extend(groups);
474 }
475 Err(error) => qk_executor::status!(
476 "qk: {}: warm state{} not restored ({error:#})",
477 task.id,
478 entry_label(warm)
479 ),
480 }
481 }
482 (restored, present)
483 };
484 let after = |outcome: Outcome,
485 (restored, present): (Vec<warm::Restored>, Vec<String>),
486 running: &PreparedTask|
487 -> Option<warm::WarmReport> {
488 if entries.is_empty() {
489 return None;
490 }
491 let mut save_ms = None;
492 let mut background = false;
493 let uncleared = uncleared.lock().unwrap();
494 for warm in entries
495 .iter()
496 .filter(|warm| outcome == Outcome::Success && !uncleared.contains(&warm.identity))
497 {
498 let started = std::time::Instant::now();
499 let saved = if warm.background {
500 self.save_warm_in_background(workspace, task, warm, running)
501 } else {
502 self.save_warm(workspace, task, warm, running)
503 };
504 match saved {
505 Ok(()) if warm.background => background = true,
506 Ok(()) => {
507 *save_ms.get_or_insert(0) += started.elapsed().as_millis() as u64;
508 }
509 Err(error) => qk_executor::status!(
510 "qk: {}: warm state{} not saved ({error:#})",
511 task.id,
512 entry_label(warm)
513 ),
514 }
515 }
516 Some(warm::WarmReport {
517 restored,
518 present,
519 save_ms,
520 background,
521 })
522 };
523 let bypass = |reason: String| {
525 if cacheable {
526 qk_executor::status!("qk: {}: cache bypassed ({reason})", task.id);
527 }
528 let _profile = profile::span(&task.id, "execute");
529 let (outcome, execution) = execute_uncached(prepared, cancelled)?;
530 Ok(TaskResult {
531 execution,
532 ..TaskResult::uncached(outcome)
533 })
534 };
535 let snapshot = match self.snapshot(workspace, graph) {
536 Ok(snapshot) => snapshot,
537 Err(reason) => return bypass(reason),
538 };
539 let dependency_profile = profile::span(&task.id, "dependency_inputs");
540 let dependencies = match hash::dependency_keys(snapshot, workspace, graph, task, cancelled)
541 {
542 Ok(dependencies) => dependencies,
543 Err(error) => return bypass(format!("{error:#}")),
544 };
545 drop(dependency_profile);
546 let input_profile = profile::span(&task.id, "inputs");
547 let (key, inputs) = match hash::inputs(
548 snapshot,
549 workspace,
550 task,
551 prepared,
552 &dependencies,
553 cancelled,
554 )
555 .and_then(|inputs| Ok((hash::key(&inputs)?, inputs)))
556 {
557 Ok(key) => key,
558 Err(error) => return bypass(format!("{error:#}")),
559 };
560 drop(input_profile);
561 let keyed = Some((key.clone(), inputs));
562 running
564 .execution
565 .insert("NX_TASK_HASH".into(), key.clone().into());
566 let running = &running;
567 let outputs = match paths::Outputs::new(workspace, task) {
568 Ok(outputs) => outputs,
569 Err(error) => return bypass(format!("{error:#}")),
570 };
571 if !cacheable {
572 let restored = before(running);
573 let executing = profile::span(&task.id, "execute");
574 let (outcome, execution) = execute_uncached(running, cancelled)?;
575 drop(executing);
576 let warm_report = after(outcome, restored, running);
577 return Ok(TaskResult {
578 outcome,
579 cache: CacheStatus::Uncached,
580 key: keyed,
581 warm: warm_report,
582 execution,
583 recorded: None,
584 shadow: None,
585 });
586 }
587 if !self.reuse {
588 let capture = Capture::new(None, prepared.display.clone()).retain_log();
589 let executing = profile::span(&task.id, "execute");
590 let outcome = execute_captured(running, cancelled, Some(&capture))?;
591 drop(executing);
592 let inputs_unchanged =
593 self.inputs_match(snapshot, workspace, graph, task, prepared, &key, cancelled);
594 return Ok(TaskResult {
595 outcome,
596 cache: CacheStatus::Uncached,
597 key: keyed,
598 warm: None,
599 execution: capture.take_log().map(|log| Execution {
600 log,
601 inputs_unchanged,
602 }),
603 recorded: None,
604 shadow: None,
605 });
606 }
607 if let Err(error) = self.initialize() {
608 return bypass(format!("cache unavailable: {error}"));
609 }
610 let (_lock, waited) = match self.lock(&key, cancelled) {
611 Ok(Some(lock)) => lock,
612 Ok(None) => {
613 return Ok(TaskResult::uncached(Outcome::Cancelled));
614 }
615 Err(error) => return bypass(format!("cache lock unavailable: {error}")),
616 };
617 if waited
619 && hash::recheck_dependency_keys(snapshot, workspace, graph, task, cancelled)
620 .and_then(|dependencies| {
621 hash::fingerprint(
622 snapshot,
623 workspace,
624 task,
625 prepared,
626 &dependencies,
627 cancelled,
628 )
629 })
630 .ok()
631 .as_ref()
632 != Some(&key)
633 {
634 return bypass("inputs changed while waiting for another run".into());
635 }
636 let settings = ultracache::Settings::of(task).unwrap_or_else(|error| {
639 qk_executor::status!("qk: {}: qk:ultracache ignored ({error:#})", task.id);
640 None
641 });
642 let warm_outputs = paths::Outputs::from_paths(&warm::kept_paths(&entries)).ok();
643 let own: Vec<&paths::Outputs> = std::iter::once(&outputs)
644 .chain(warm_outputs.as_ref())
645 .collect();
646 let others = settings.as_ref().map(|_| recorded::Others {
650 outputs: self
651 .workspace_outputs
652 .get_or_init(|| workspace_outputs(workspace)),
653 upstream: upstream(graph, task),
654 });
655 let ignore = settings
656 .as_ref()
657 .and_then(|settings| {
658 self.ignored_reads(workspace, task, prepared, &settings.ignore_reads)
659 .ok()
660 })
661 .unwrap_or_default();
662 let unlisted = recorded::Unlisted {
663 own: own.clone(),
664 others,
665 ignore: &ignore,
666 };
667 let inputs = &keyed.as_ref().expect("a cacheable task is keyed").1;
668 let mut recordings = settings.and_then(|settings| {
669 let weak = hash::weak_key(
670 snapshot,
671 workspace,
672 graph,
673 task,
674 prepared,
675 inputs,
676 &settings.ignore_reads,
677 &ignore.scope(),
678 cancelled,
679 );
680 match weak {
681 Ok(weak) => Some((recorded::load(&self.root, &weak), weak, settings)),
682 Err(error) => {
683 qk_executor::status!(
684 "qk: {}: not keyed by its recordings ({error:#})",
685 task.id
686 );
687 None
688 }
689 }
690 });
691 let mut shadowing = None;
692 let mut replaced = Vec::new();
695 if let Some((sets, weak, settings)) = &mut recordings {
696 if self.cold {
697 replaced = sets
699 .iter()
700 .filter_map(|set| {
701 recording_key(weak, set, snapshot, workspace, &unlisted, task)
702 })
703 .collect();
704 } else if settings.mode == ultracache::Mode::Record {
705 let _lookup = profile::span(&task.id, "recorded_lookup");
706 shadowing =
707 Some(self.start_shadow(workspace, task, snapshot, &unlisted, sets, weak));
708 } else {
709 let recorded_profile = profile::span(&task.id, "recorded_restore");
710 let found = self.by_recordings(
711 workspace, task, prepared, snapshot, &outputs, &unlisted, sets, weak, &key,
712 cancelled,
713 );
714 drop(recorded_profile);
715 if let found @ (Found::Local(_) | Found::Remote(_)) = found {
716 let (strong, remote) = match found {
717 Found::Local(strong) => {
718 if let Some(remote) = &self.remote {
719 remote.held(&strong);
720 }
721 (strong, None)
722 }
723 Found::Remote(strong) => (strong, Some(())),
724 Found::Nothing => unreachable!(),
725 };
726 recorded::touch(&self.root, weak);
727 qk_executor::report::event(qk_executor::report::Event::Cache {
728 id: task.id.clone(),
729 cached: if remote.is_some() {
730 qk_executor::report::Cached::RemoteHit
731 } else {
732 qk_executor::report::Cached::Hit
733 },
734 });
735 return Ok(TaskResult {
736 outcome: Outcome::Success,
737 cache: if remote.is_some() {
738 CacheStatus::RemoteHit
739 } else {
740 CacheStatus::LocalHit
741 },
742 key: keyed,
743 warm: None,
744 execution: None,
745 recorded: Some(Recorded::Hit { key: strong }),
746 shadow: None,
747 });
748 }
749 }
750 }
751 let mut settle = |recordings: &mut Option<(Vec<recorded::PathSet>, String, _)>| {
753 let shadowing = shadowing.take()?;
754 let (sets, weak, _) = recordings.as_mut()?;
755 let _wait = profile::span(&task.id, "recorded_wait");
756 Some(self.settle_shadow(shadowing, workspace, task, snapshot, &unlisted, sets, weak))
757 };
758 let declared = recordings.as_ref().is_none_or(|(sets, _, settings)| {
763 settings.mode == ultracache::Mode::Record
764 || sets.is_empty()
765 || hash::coverage(snapshot, workspace, graph, task, prepared, cancelled).is_ok_and(
766 |coverage| {
767 let outputs = self
768 .workspace_outputs
769 .get_or_init(|| workspace_outputs(workspace));
770 let output = |path: &str| outputs.matches(path);
771 sets.iter()
772 .all(|set| coverage.covers(snapshot, &workspace.root, set, &output))
773 },
774 )
775 });
776 let unrecorded = recordings
778 .as_ref()
779 .filter(|(_, _, settings)| settings.mode == ultracache::Mode::On)
780 .map(|(sets, _, _)| {
781 if sets.is_empty() {
782 Recorded::Unrecorded
783 } else {
784 Recorded::Declared { key: key.clone() }
785 }
786 });
787 let prefetched = declared
790 && self.remote.as_ref().is_some_and(|remote| {
791 remote.resolved(&key);
792 remote.prefetched(&key)
793 });
794 let local_profile = profile::span(&task.id, "local_restore");
795 let restored = if declared && !self.cold {
796 self.restore(
797 &workspace.root,
798 &task.id,
799 &task.dependencies,
800 &key,
801 &outputs,
802 &prepared.display,
803 if prefetched {
804 qk_executor::Shown::RemoteCache
805 } else {
806 qk_executor::Shown::LocalCache
807 },
808 )
809 } else {
810 Ok(false)
811 };
812 match restored {
813 Ok(true) => {
814 let claimed = match &self.remote {
815 Some(remote) if prefetched && remote.claim(&key) => true,
816 Some(remote) => {
817 remote.held(&key);
818 false
819 }
820 None => false,
821 };
822 qk_executor::report::event(qk_executor::report::Event::Cache {
823 id: task.id.clone(),
824 cached: if claimed {
825 qk_executor::report::Cached::RemoteHit
826 } else {
827 qk_executor::report::Cached::Hit
828 },
829 });
830 let shadow = settle(&mut recordings);
831 let shadow = self.with_changes(
832 shadow,
833 recordings.as_ref(),
834 (snapshot, workspace, graph, task, prepared, &unlisted),
835 cancelled,
836 );
837 return Ok(TaskResult {
838 outcome: Outcome::Success,
839 cache: if claimed {
840 CacheStatus::RemoteHit
841 } else {
842 CacheStatus::LocalHit
843 },
844 key: keyed,
845 warm: None,
846 execution: None,
847 recorded: unrecorded,
848 shadow,
849 });
850 }
851 Ok(false) => {}
852 Err(error) => {
853 qk_executor::status!("qk: {}: ignoring unusable cache entry ({error})", task.id)
854 }
855 }
856 drop(local_profile);
857 if let Some(remote) = self.remote.as_ref().filter(|_| declared && !self.cold) {
858 let fetch_profile = profile::span(&task.id, "remote_fetch");
859 let fetched = remote.fetch(&self.root, &key, &task.id, cancelled);
860 drop(fetch_profile);
861 let fetched = fetched.and_then(|found| {
862 if !found {
863 return Ok(false);
864 }
865 self.restore(
866 &workspace.root,
867 &task.id,
868 &task.dependencies,
869 &key,
870 &outputs,
871 &prepared.display,
872 qk_executor::Shown::RemoteCache,
873 )
874 });
875 match fetched {
876 Ok(true) => {
877 qk_executor::report::event(qk_executor::report::Event::Cache {
878 id: task.id.clone(),
879 cached: qk_executor::report::Cached::RemoteHit,
880 });
881 let shadow = settle(&mut recordings);
882 let shadow = self.with_changes(
883 shadow,
884 recordings.as_ref(),
885 (snapshot, workspace, graph, task, prepared, &unlisted),
886 cancelled,
887 );
888 return Ok(TaskResult {
889 outcome: Outcome::Success,
890 cache: CacheStatus::RemoteHit,
891 key: keyed,
892 warm: None,
893 execution: None,
894 recorded: unrecorded,
895 shadow,
896 });
897 }
898 Ok(false) => {}
899 Err(error) => {
900 qk_executor::status!("qk: {}: remote cache unavailable ({error:#})", task.id)
901 }
902 }
903 }
904 let shadow = settle(&mut recordings);
906 qk_executor::report::event(qk_executor::report::Event::Cache {
907 id: task.id.clone(),
908 cached: qk_executor::report::Cached::Miss,
909 });
910 let log = match tempfile::NamedTempFile::new_in(self.root.join("tmp")) {
911 Ok(log) => log,
912 Err(error) => return bypass(format!("cache log unavailable: {error}")),
913 };
914 let capture =
915 Capture::new(Some(log.as_file().try_clone()?), prepared.display.clone()).retain_log();
916 let restored = before(running);
917 let keying = prepared
920 .recording
921 .as_ref()
922 .filter(|_| prepared.recording_keys);
923 let listed = match (&recordings, keying) {
924 (Some(_), Some(_)) => {
925 let project_root = workspace
926 .projects
927 .get(&task.project)
928 .map_or("", |project| project.root.as_str());
929 recorded::Before::take(
930 &workspace.root,
931 unlisted.directories(project_root),
932 &unlisted,
933 )
934 }
935 _ => recorded::Before::default(),
936 };
937 let started = recorded::now(&self.root.join("tmp"));
938 let executing = profile::span(&task.id, "execute");
939 let outcome = execute_captured(running, cancelled, Some(&capture))?;
940 drop(executing);
941 let warm_report = after(outcome, restored, running);
942 let inputs_unchanged =
943 self.inputs_match(snapshot, workspace, graph, task, prepared, &key, cancelled);
944 let execution = capture.take_log().map(|log| Execution {
945 log,
946 inputs_unchanged,
947 });
948 let mut recorded = unrecorded.map(|unrecorded| match &recordings {
949 Some((sets, _, _)) if !sets.is_empty() => Recorded::Missed,
950 _ => unrecorded,
951 });
952 let path_set = match (&recordings, keying) {
953 (Some((_, _, settings)), Some(recording)) => Some(
954 self.path_set(workspace, task, recording, prepared, settings, &own)
955 .and_then(|set| {
956 if set.is_empty() {
958 return Err("its trace named no path in the workspace".to_owned());
959 }
960 match set
961 .changed_since(&workspace.root, started, &unlisted, &listed)
962 .map(|changed| changed.to_string())
963 {
964 Some(reason) => Err(reason),
965 None => Ok(set),
966 }
967 }),
968 ),
969 _ => None,
970 };
971 if let Some(Err(reason)) = &path_set {
972 qk_executor::status!("qk: {}: not recorded ({reason})", task.id);
973 recorded = Some(Recorded::Failed {
974 reason: reason.clone(),
975 });
976 }
977 let mut published = false;
978 let kept = outcome == Outcome::Success && inputs_unchanged && capture.healthy();
979 if kept {
980 let publishing = profile::span(&task.id, "publish");
981 let publish = self.publish(&workspace.root, &key, &outputs, log.path());
982 drop(publishing);
983 match publish {
984 Ok(()) => {
985 published = true;
986 store::record_outputs(&workspace.root, &task.id, &key, &outputs);
987 if let Some(remote) = &self.remote {
988 remote.upload(&self.root, &key);
989 }
990 for strong in &replaced {
991 if let Err(error) =
992 self.publish(&workspace.root, strong, &outputs, log.path())
993 {
994 qk_executor::status!(
995 "qk: {}: could not replace a recorded cache entry ({error})",
996 task.id
997 );
998 }
999 }
1000 }
1001 Err(error) => {
1002 qk_executor::status!("qk: {}: could not save cache entry ({error})", task.id);
1003 }
1004 }
1005 }
1006 let check = match &shadow {
1010 Some(Shadow::WouldHit { key: recorded, .. }) => match outcome {
1011 Outcome::Failed(code) => Some(Check {
1012 matched: false,
1013 differences: vec![format!("failed (exit {code})")],
1014 }),
1015 Outcome::Success if published => {
1016 let _checking = profile::span(&task.id, "recorded_check");
1017 self.check(&task.id, &key, recorded)
1018 }
1019 _ => None,
1020 },
1021 _ => None,
1022 };
1023 if kept {
1024 if let (Some((_, weak, _)), Some(Ok(set))) = (&recordings, path_set) {
1026 let saved = recorded::with_values(weak, set, snapshot, &workspace.root, &unlisted)
1027 .and_then(|(strong, set)| {
1028 self.publish(&workspace.root, &strong, &outputs, log.path())?;
1029 recorded::save(&self.root, weak, set)?;
1030 if let Some(remote) = &self.remote {
1033 remote.upload(&self.root, &strong);
1034 if let Some(record) = recorded::record(&self.root, weak) {
1035 remote.upload_path_sets(&self.root, weak, record);
1036 }
1037 }
1038 Ok(strong)
1039 });
1040 recorded = Some(match saved {
1041 Ok(key) => Recorded::Saved { key },
1042 Err(error) => {
1043 qk_executor::status!("qk: {}: recording not saved ({error:#})", task.id);
1044 Recorded::Failed {
1045 reason: format!("{error:#}"),
1046 }
1047 }
1048 });
1049 }
1050 }
1051 let shadow = match shadow {
1052 Some(Shadow::WouldHit { key: recorded, .. }) => Some(Shadow::WouldHit {
1053 key: recorded,
1054 check,
1055 }),
1056 shadow => shadow,
1057 };
1058 Ok(TaskResult {
1059 outcome,
1060 cache: CacheStatus::Miss,
1061 key: keyed,
1062 warm: warm_report,
1063 execution,
1064 recorded,
1065 shadow,
1066 })
1067 }
1068
1069 fn check(&self, task: &str, ran: &str, recorded: &str) -> Option<Check> {
1073 let compared = self
1074 .artifacts(ran, task)
1075 .and_then(|ran| Ok(ran.zip(self.artifacts(recorded, task)?)));
1076 match compared {
1077 Ok(Some((ran, recorded))) => {
1078 let differences = store::differences(&ran, &recorded, UNCOVERED);
1079 Some(Check {
1080 matched: differences.is_empty(),
1081 differences,
1082 })
1083 }
1084 Ok(None) => None,
1085 Err(error) => {
1086 qk_executor::status!(
1087 "qk: {task}: not compared with its recorded result ({error:#})"
1088 );
1089 None
1090 }
1091 }
1092 }
1093
1094 fn with_changes(
1098 &self,
1099 shadow: Option<Shadow>,
1100 recordings: Option<&(Vec<recorded::PathSet>, String, ultracache::Settings)>,
1101 (snapshot, workspace, graph, task, prepared, unlisted): (
1102 &hash::Snapshot,
1103 &Workspace,
1104 &TaskGraph,
1105 &Task,
1106 &PreparedTask,
1107 &recorded::Unlisted,
1108 ),
1109 cancelled: &AtomicBool,
1110 ) -> Option<Shadow> {
1111 let (Some(Shadow::WouldMiss { .. }), Some((sets, _, _))) = (&shadow, recordings) else {
1112 return shadow;
1113 };
1114 let Some(newest) = sets.first() else {
1115 return shadow;
1116 };
1117 let _explaining = profile::span(&task.id, "recorded_changes");
1118 let Ok(coverage) = hash::coverage(snapshot, workspace, graph, task, prepared, cancelled)
1119 else {
1120 return shadow;
1121 };
1122 let outputs = self
1123 .workspace_outputs
1124 .get_or_init(|| workspace_outputs(workspace));
1125 let output = |path: &str| outputs.matches(path);
1126 let uncovered: BTreeMap<&str, &str> = coverage
1127 .uncovered(snapshot, &workspace.root, newest, &output)
1128 .collect();
1129 let named = |paths: Vec<String>| {
1130 let more = paths.len().saturating_sub(UNCOVERED - 1);
1131 let mut paths = paths;
1132 if more > 1 {
1133 paths.truncate(UNCOVERED - 1);
1134 paths.push(format!("{more} more"));
1135 }
1136 paths
1137 };
1138 if newest.recorded.is_some() {
1139 let changed = newest
1140 .changed(snapshot, &workspace.root, unlisted)
1141 .unwrap_or_default()
1142 .into_iter()
1143 .map(|(path, how)| match uncovered.contains_key(path) {
1144 true => format!("{path} ({how})"),
1145 false => format!("{path} ({how}, declared)"),
1146 })
1147 .collect();
1148 return Some(Shadow::WouldMiss {
1149 changed: named(changed),
1150 uncovered: vec![],
1151 });
1152 }
1153 let uncovered = uncovered
1154 .into_iter()
1155 .map(|(path, how)| format!("{path} ({how})"))
1156 .collect();
1157 Some(Shadow::WouldMiss {
1158 changed: vec![],
1159 uncovered: named(uncovered),
1160 })
1161 }
1162
1163 #[allow(clippy::too_many_arguments)]
1168 fn by_recordings(
1169 &self,
1170 workspace: &Workspace,
1171 task: &Task,
1172 prepared: &PreparedTask,
1173 snapshot: &hash::Snapshot,
1174 outputs: &paths::Outputs,
1175 unlisted: &recorded::Unlisted,
1176 sets: &mut Vec<recorded::PathSet>,
1177 weak: &str,
1178 declared: &str,
1179 cancelled: &AtomicBool,
1180 ) -> Found {
1181 let strong =
1182 |set: &recorded::PathSet| recording_key(weak, set, snapshot, workspace, unlisted, task);
1183 let restored = |strong: &str, shown| {
1184 self.restore(
1185 &workspace.root,
1186 &task.id,
1187 &task.dependencies,
1188 strong,
1189 outputs,
1190 &prepared.display,
1191 shown,
1192 )
1193 .unwrap_or_else(|error| {
1194 qk_executor::status!("qk: {}: ignoring unusable cache entry ({error})", task.id);
1195 false
1196 })
1197 };
1198 let local = |strong: &str| restored(strong, qk_executor::Shown::LocalCache);
1199 let mut keys = Vec::new();
1200 for set in sets.iter() {
1201 if let Some(strong) = strong(set) {
1202 if local(&strong) {
1203 return Found::Local(strong);
1204 }
1205 keys.push(strong);
1206 }
1207 }
1208 let Some(remote) = &self.remote else {
1209 return Found::Nothing;
1210 };
1211 if sets.is_empty()
1215 && self
1216 .root
1217 .join("entries")
1218 .join(format!("{declared}.json"))
1219 .is_file()
1220 {
1221 return Found::Nothing;
1222 }
1223 let fetched = remote
1225 .fetch_path_sets(&self.root, weak, &task.id)
1226 .and_then(|record| match record {
1227 Some(record) => recorded::merge(&self.root, weak, &record),
1228 None => Ok(Vec::new()),
1229 });
1230 match fetched {
1231 Ok(added) => {
1232 for set in added {
1233 if let Some(strong) = strong(&set) {
1234 if local(&strong) {
1235 sets.push(set);
1236 return Found::Local(strong);
1237 }
1238 keys.push(strong);
1239 }
1240 sets.push(set);
1241 }
1242 }
1243 Err(error) => {
1244 qk_executor::status!("qk: {}: remote path sets unavailable ({error:#})", task.id)
1245 }
1246 }
1247 for strong in keys {
1248 let found = remote
1249 .fetch(&self.root, &strong, &task.id, cancelled)
1250 .unwrap_or_else(|error| {
1251 qk_executor::status!("qk: {}: remote cache unavailable ({error:#})", task.id);
1252 false
1253 })
1254 && restored(&strong, qk_executor::Shown::RemoteCache);
1255 if found {
1256 return Found::Remote(strong);
1257 }
1258 }
1259 Found::Nothing
1260 }
1261
1262 fn start_shadow(
1267 &self,
1268 workspace: &Workspace,
1269 task: &Task,
1270 snapshot: &hash::Snapshot,
1271 unlisted: &recorded::Unlisted,
1272 sets: &[recorded::PathSet],
1273 weak: &str,
1274 ) -> Shadowing {
1275 let mut keys = Vec::new();
1276 for set in sets {
1277 if let Some(strong) = recording_key(weak, set, snapshot, workspace, unlisted, task) {
1278 if self.holds_entry(&strong) {
1279 return Shadowing::Settled(Shadow::WouldHit {
1280 key: strong,
1281 check: None,
1282 });
1283 }
1284 keys.push(strong);
1285 }
1286 }
1287 let Some(remote) = &self.remote else {
1288 return Shadowing::Settled(if sets.is_empty() {
1289 Shadow::Unrecorded
1290 } else {
1291 Shadow::WouldMiss {
1292 changed: vec![],
1293 uncovered: vec![],
1294 }
1295 });
1296 };
1297 let (remote, root, weak, id) = (
1298 std::sync::Arc::clone(remote),
1299 self.root.clone(),
1300 weak.to_owned(),
1301 task.id.clone(),
1302 );
1303 Shadowing::Fetching {
1304 keys,
1305 fetch: std::thread::spawn(move || remote.fetch_path_sets(&root, &weak, &id)),
1306 }
1307 }
1308
1309 #[allow(clippy::too_many_arguments)]
1313 fn settle_shadow(
1314 &self,
1315 shadowing: Shadowing,
1316 workspace: &Workspace,
1317 task: &Task,
1318 snapshot: &hash::Snapshot,
1319 unlisted: &recorded::Unlisted,
1320 sets: &mut Vec<recorded::PathSet>,
1321 weak: &str,
1322 ) -> Shadow {
1323 let (mut keys, fetch) = match shadowing {
1324 Shadowing::Settled(shadow) => return shadow,
1325 Shadowing::Fetching { keys, fetch } => (keys, fetch),
1326 };
1327 let fetched = fetch
1328 .join()
1329 .unwrap_or_else(|_| Err(anyhow::anyhow!("fetching them panicked")))
1330 .and_then(|record| match record {
1331 Some(record) => recorded::merge(&self.root, weak, &record),
1332 None => Ok(Vec::new()),
1333 });
1334 match fetched {
1335 Ok(added) => {
1336 for set in added {
1337 if let Some(strong) =
1338 recording_key(weak, &set, snapshot, workspace, unlisted, task)
1339 {
1340 if self.holds_entry(&strong) {
1341 sets.push(set);
1342 return Shadow::WouldHit {
1343 key: strong,
1344 check: None,
1345 };
1346 }
1347 keys.push(strong);
1348 }
1349 sets.push(set);
1350 }
1351 }
1352 Err(error) => {
1353 qk_executor::status!("qk: {}: remote path sets unavailable ({error:#})", task.id)
1354 }
1355 }
1356 let remote = self
1357 .remote
1358 .as_ref()
1359 .expect("only a remote store is fetched from");
1360 keys.into_iter()
1361 .find(|strong| remote.holds(strong).unwrap_or(false))
1362 .map_or(
1363 if sets.is_empty() {
1364 Shadow::Unrecorded
1365 } else {
1366 Shadow::WouldMiss {
1367 changed: vec![],
1368 uncovered: vec![],
1369 }
1370 },
1371 |key| Shadow::WouldHit { key, check: None },
1372 )
1373 }
1374
1375 fn holds_entry(&self, key: &str) -> bool {
1377 self.root
1378 .join("entries")
1379 .join(format!("{key}.json"))
1380 .is_file()
1381 }
1382
1383 fn path_set(
1385 &self,
1386 workspace: &Workspace,
1387 task: &Task,
1388 recording: &qk_executor::Recording,
1389 prepared: &PreparedTask,
1390 settings: &ultracache::Settings,
1391 own: &[&paths::Outputs],
1392 ) -> std::result::Result<recorded::PathSet, String> {
1393 let failed = |error: anyhow::Error| format!("{error:#}");
1394 let canonical = workspace
1395 .root
1396 .canonicalize()
1397 .map_err(|e| failed(e.into()))?;
1398 let keep = |path: &std::path::Path| {
1401 [&workspace.root, &canonical].iter().any(|root| {
1402 path.strip_prefix(root).is_ok_and(|relative| {
1403 !relative.components().any(|component| {
1404 matches!(
1405 component.as_os_str().to_str(),
1406 Some("node_modules" | ".git")
1407 )
1408 })
1409 })
1410 })
1411 };
1412 let limits = qk_input_analysis::strace::Limits {
1413 records: 1_000_000,
1414 bytes: 256 * 1024 * 1024,
1415 accesses: 500_000,
1416 keep: Some(&keep),
1417 resolved: false,
1419 distinct: true,
1421 };
1422 recording.settle().map_err(failed)?;
1423 let (accesses, diagnostics) =
1424 qk_input_analysis::strace::collect_within(&recording.directory, &prepared.cwd, &limits)
1425 .map_err(failed)?;
1426 if let Some(diagnostic) = diagnostics.first() {
1428 return Err(diagnostic.clone());
1429 }
1430 let ignore = self
1431 .ignored_reads(workspace, task, prepared, &settings.ignore_reads)
1432 .map_err(failed)?;
1433 let state = [self.root.clone(), worktree_state(&workspace.root)]
1435 .iter()
1436 .filter_map(|path| {
1437 [&workspace.root, &canonical]
1438 .iter()
1439 .find_map(|root| path.strip_prefix(root).ok())
1440 .and_then(|relative| relative.to_str())
1441 .map(|relative| relative.replace('\\', "/"))
1442 })
1443 .collect();
1444 let exclusions = recorded::Exclusions {
1445 roots: [&workspace.root, &canonical],
1446 outputs: own,
1447 ignore,
1448 state,
1449 };
1450 Ok(recorded::PathSet::from_accesses(
1451 &accesses,
1452 &exclusions,
1453 &canonical,
1454 ))
1455 }
1456
1457 #[allow(clippy::too_many_arguments)]
1459 fn inputs_match(
1460 &self,
1461 snapshot: &hash::Snapshot,
1462 workspace: &Workspace,
1463 graph: &TaskGraph,
1464 task: &Task,
1465 prepared: &PreparedTask,
1466 before: &str,
1467 cancelled: &AtomicBool,
1468 ) -> bool {
1469 if cancelled.load(Ordering::SeqCst) {
1470 return false;
1471 }
1472 let _profile = profile::span(&task.id, "inputs_recheck");
1473 let after = hash::recheck_dependency_keys(snapshot, workspace, graph, task, cancelled)
1474 .and_then(|dependencies| {
1475 hash::fingerprint(
1476 snapshot,
1477 workspace,
1478 task,
1479 prepared,
1480 &dependencies,
1481 cancelled,
1482 )
1483 });
1484 if after.as_deref().ok() != Some(before) {
1485 if task.definition.cache == Some(true) {
1486 if self.reuse {
1487 qk_executor::status!(
1488 "qk: {}: not caching because inputs changed during execution",
1489 task.id
1490 );
1491 } else {
1492 qk_executor::status!(
1493 "qk: {}: excluding execution from flaky results because inputs changed during execution",
1494 task.id
1495 );
1496 }
1497 }
1498 return false;
1499 }
1500 true
1501 }
1502}
1503
1504pub type Keyed = std::result::Result<(String, Value), String>;
1506
1507pub fn keys(
1511 workspace: &Workspace,
1512 graph: &TaskGraph,
1513 prepared: &BTreeMap<String, PreparedTask>,
1514 cancelled: &AtomicBool,
1515) -> Result<BTreeMap<String, Keyed>> {
1516 let snapshot = hash::Snapshot::new(workspace, graph, &paths::cache_location(workspace))?;
1517 Ok(graph
1518 .tasks
1519 .values()
1520 .map(|task| {
1521 let keyed = key_now(&snapshot, workspace, graph, task, prepared, cancelled);
1522 (task.id.clone(), keyed)
1523 })
1524 .collect())
1525}
1526
1527fn key_now(
1528 snapshot: &hash::Snapshot,
1529 workspace: &Workspace,
1530 graph: &TaskGraph,
1531 task: &Task,
1532 prepared: &BTreeMap<String, PreparedTask>,
1533 cancelled: &AtomicBool,
1534) -> Keyed {
1535 let failed = |error: anyhow::Error| format!("{}: {error:#}", task.id);
1536 let dependencies =
1537 hash::dependency_keys(snapshot, workspace, graph, task, cancelled).map_err(failed)?;
1538 let inputs = hash::inputs(
1539 snapshot,
1540 workspace,
1541 task,
1542 &prepared[&task.id],
1543 &dependencies,
1544 cancelled,
1545 )
1546 .map_err(failed)?;
1547 Ok((hash::key(&inputs).map_err(failed)?, inputs))
1548}
1549
1550#[doc(hidden)]
1552pub fn run_upload_worker() -> anyhow::Result<()> {
1553 remote::background::run()
1554}
1555
1556fn workspace_outputs(workspace: &Workspace) -> recorded::WorkspaceOutputs {
1560 let mut outputs = Vec::new();
1561 for (name, project) in &workspace.projects {
1562 for (target, definition) in &project.targets {
1563 let configured = definition
1564 .configurations
1565 .iter()
1566 .map(|(configuration, options)| {
1567 let mut definition = definition.clone();
1568 definition.options.extend(options.clone());
1569 (Some(configuration.clone()), definition)
1570 });
1571 for (configuration, definition) in
1572 std::iter::once((None, definition.clone())).chain(configured)
1573 {
1574 let task = Task {
1575 id: format!("{name}:{target}"),
1576 project: name.clone(),
1577 target: target.clone(),
1578 configuration: configuration.clone(),
1579 args: Vec::new(),
1580 definition,
1581 dependencies: Default::default(),
1582 edges: Default::default(),
1583 };
1584 if let Some(declared) = paths::Outputs::new(workspace, &task)
1585 .ok()
1586 .filter(paths::Outputs::declared)
1587 {
1588 outputs.push(((name.clone(), target.clone(), configuration), declared));
1589 }
1590 }
1591 }
1592 }
1593 let roots = workspace
1594 .projects
1595 .iter()
1596 .map(|(name, project)| (name.clone(), project.root.clone()))
1597 .collect();
1598 recorded::WorkspaceOutputs::new(outputs, &roots)
1599}
1600
1601fn upstream(graph: &TaskGraph, task: &Task) -> BTreeSet<recorded::TargetOf> {
1605 let of = |task: &Task| {
1606 (
1607 task.project.clone(),
1608 task.target.clone(),
1609 task.configuration.clone(),
1610 )
1611 };
1612 let mut targets = BTreeSet::new();
1613 let mut seen = BTreeSet::new();
1614 let mut pending: Vec<&String> = task.dependencies.iter().collect();
1615 while let Some(id) = pending.pop() {
1616 if !seen.insert(id) {
1617 continue;
1618 }
1619 if let Some(dependency) = graph.tasks.get(id) {
1620 targets.insert(of(dependency));
1621 pending.extend(&dependency.dependencies);
1622 }
1623 }
1624 targets
1625}
1626
1627#[cfg(test)]
1628mod tests {
1629 use super::*;
1630
1631 #[test]
1632 fn concurrent_blob_writes_keep_results_ordered_and_repair_corruption() {
1633 let workspace = tempfile::tempdir().unwrap();
1634 let root = workspace.path();
1635 let cache = Cache::new(root.join("cache"));
1636 cache.initialize().unwrap();
1637 let mut sources = Vec::new();
1638 for index in 0..130 {
1639 let source = root.join(format!("source-{index}"));
1640 std::fs::write(&source, format!("content-{}", index % 65)).unwrap();
1641 sources.push(source);
1642 }
1643 let digest = cache.put_blob(&sources[0]).unwrap();
1644 std::fs::write(cache.root.join("blobs").join(&digest), "corrupt").unwrap();
1645 std::fs::remove_file(&sources[31]).unwrap();
1646 let results = cache.put_blobs(&sources);
1647 assert_eq!(results.len(), sources.len());
1648 for (index, result) in results.into_iter().enumerate() {
1649 if index == 31 {
1650 assert_eq!(
1651 result
1652 .unwrap_err()
1653 .downcast_ref::<std::io::Error>()
1654 .unwrap()
1655 .kind(),
1656 std::io::ErrorKind::NotFound
1657 );
1658 continue;
1659 }
1660 let digest = result.unwrap();
1661 let held = cache.root.join("blobs").join(&digest);
1662 assert_eq!(
1663 std::fs::read(&held).unwrap(),
1664 format!("content-{}", index % 65).as_bytes()
1665 );
1666 assert_eq!(hash::digest_file(&held).unwrap(), digest);
1667 }
1668 }
1669
1670 #[cfg(unix)]
1672 #[test]
1673 fn a_blob_the_store_holds_is_not_written_again() {
1674 use std::os::unix::fs::MetadataExt;
1675 let workspace = tempfile::tempdir().unwrap();
1676 let root = workspace.path();
1677 let cache = Cache::new(root.join(".qk/cache"));
1678 cache.initialize().unwrap();
1679 std::fs::write(root.join("first"), "same").unwrap();
1680 std::fs::write(root.join("second"), "same").unwrap();
1681 std::fs::write(root.join("other"), "different").unwrap();
1682 let first = cache.put_blob(&root.join("first")).unwrap();
1683 let blob = cache.root.join("blobs").join(&first);
1684 let stored = std::fs::metadata(&blob).unwrap().ino();
1685 assert_eq!(cache.put_blob(&root.join("second")).unwrap(), first);
1686 assert_eq!(std::fs::metadata(&blob).unwrap().ino(), stored);
1687 std::fs::write(&blob, "corrupt").unwrap();
1689 assert_eq!(cache.put_blob(&root.join("second")).unwrap(), first);
1690 assert_eq!(std::fs::read(&blob).unwrap(), b"same");
1691 {
1693 use std::os::unix::fs::PermissionsExt;
1694 std::fs::set_permissions(&blob, std::fs::Permissions::from_mode(0o000)).unwrap();
1695 }
1696 assert_eq!(cache.put_blob(&root.join("second")).unwrap(), first);
1697 assert_eq!(std::fs::read(&blob).unwrap(), b"same");
1698 let other = cache.put_blob(&root.join("other")).unwrap();
1699 assert_ne!(other, first);
1700 assert_eq!(
1701 std::fs::read(cache.root.join("blobs").join(other)).unwrap(),
1702 b"different"
1703 );
1704 }
1705
1706 #[test]
1709 fn complete_directory_restores_preserve_outputs() {
1710 let workspace = tempfile::tempdir().unwrap();
1711 let root = workspace.path();
1712 std::fs::create_dir_all(root.join("dist/nested/empty")).unwrap();
1713 std::fs::write(root.join("dist/nested/a.txt"), "a").unwrap();
1714 std::fs::write(root.join("dist/b.bin"), [0, 1, 2]).unwrap();
1715 std::fs::create_dir_all(root.join("bundle/empty")).unwrap();
1716 std::fs::write(root.join("bundle/readonly"), "readonly").unwrap();
1717 #[cfg(unix)]
1718 {
1719 use std::os::unix::fs::PermissionsExt;
1720 let script = root.join("dist/run.sh");
1721 std::fs::write(&script, "#!/bin/sh\n").unwrap();
1722 std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
1723 std::os::unix::fs::symlink("nested/a.txt", root.join("dist/link")).unwrap();
1724 std::fs::set_permissions(
1725 root.join("dist/nested"),
1726 std::fs::Permissions::from_mode(0o500),
1727 )
1728 .unwrap();
1729 std::fs::set_permissions(
1730 root.join("bundle/readonly"),
1731 std::fs::Permissions::from_mode(0o444),
1732 )
1733 .unwrap();
1734 }
1735 let log = root.join("log");
1736 std::fs::write(&log, "").unwrap();
1737
1738 let cache = Cache::new(root.join(".qk/cache"));
1739 cache.initialize().unwrap();
1740 let outputs = paths::Outputs::from_patterns(&["dist", "bundle"]);
1741 let key = "0".repeat(64);
1742 cache.publish(root, &key, &outputs, &log).unwrap();
1743
1744 #[cfg(unix)]
1745 {
1746 use std::os::unix::fs::PermissionsExt;
1747 std::fs::set_permissions(
1750 root.join("dist/nested"),
1751 std::fs::Permissions::from_mode(0o755),
1752 )
1753 .unwrap();
1754 }
1755 std::fs::remove_dir_all(root.join("dist")).unwrap();
1756 std::fs::remove_dir_all(root.join("bundle")).unwrap();
1757 let before = std::time::SystemTime::now();
1758 let restored = cache
1759 .restore(
1760 root,
1761 "app:build",
1762 &Default::default(),
1763 &key,
1764 &outputs,
1765 &qk_executor::Display::Stream,
1766 qk_executor::Shown::LocalCache,
1767 )
1768 .unwrap();
1769 assert!(restored);
1770 assert!(root.join("dist/nested/empty").is_dir());
1771 assert!(root.join("bundle/empty").is_dir());
1772 assert_eq!(
1773 std::fs::read_to_string(root.join("bundle/readonly")).unwrap(),
1774 "readonly"
1775 );
1776 #[cfg(unix)]
1777 {
1778 assert_eq!(
1779 std::fs::read_link(root.join("dist/link")).unwrap(),
1780 std::path::Path::new("nested/a.txt")
1781 );
1782 assert_eq!(
1783 store::mode(&std::fs::metadata(root.join("dist/run.sh")).unwrap()),
1784 0o755
1785 );
1786 assert_eq!(
1787 store::mode(&std::fs::metadata(root.join("dist/nested")).unwrap()),
1788 0o500
1789 );
1790 assert_eq!(
1791 store::mode(&std::fs::metadata(root.join("bundle/readonly")).unwrap()),
1792 0o444
1793 );
1794 }
1795 let path = root.join("dist/nested/a.txt");
1796 let modified = std::fs::metadata(&path).unwrap().modified().unwrap();
1797 assert!(modified >= before);
1798 let kept = cache
1799 .restore(
1800 root,
1801 "app:build",
1802 &Default::default(),
1803 &key,
1804 &outputs,
1805 &qk_executor::Display::Hidden,
1806 qk_executor::Shown::LocalCache,
1807 )
1808 .unwrap();
1809 assert!(kept);
1810 assert_eq!(
1811 std::fs::metadata(&path).unwrap().modified().unwrap(),
1812 modified
1813 );
1814 #[cfg(unix)]
1815 {
1816 use std::os::unix::fs::PermissionsExt;
1817 std::fs::set_permissions(
1818 root.join("dist/nested"),
1819 std::fs::Permissions::from_mode(0o755),
1820 )
1821 .unwrap();
1822 }
1823 }
1824
1825 #[test]
1826 fn listings_leave_out_other_targets_outputs_but_the_upstreams() {
1827 let temp = tempfile::tempdir().unwrap();
1828 let root = temp.path();
1829 std::fs::write(root.join("nx.json"), "{}").unwrap();
1830 std::fs::create_dir_all(root.join("app")).unwrap();
1831 std::fs::create_dir_all(root.join("lib")).unwrap();
1832 std::fs::write(
1835 root.join("project.json"),
1836 r#"{"name":"workspace","targets":{"cache":{"command":"true",
1837 "outputs":["{projectRoot}/.cache/build","{projectRoot}/app/extra/out"]}}}"#,
1838 )
1839 .unwrap();
1840 std::fs::write(
1841 root.join("app/project.json"),
1842 r#"{"name":"app","root":"./app","targets":{
1843 "build":{"command":"true","outputs":["{options.outputPath}"],
1844 "options":{"outputPath":"app/dist"},
1845 "configurations":{"production":{"outputPath":"app/dist-production"}}},
1846 "lint":{"command":"true"},
1847 "cover":{"command":"true","outputs":["{projectRoot}/.coverage/unit"]},
1848 "cover-browser":{"command":"true","outputs":["{projectRoot}/.coverage/browser"]},
1849 "wasm":{"command":"true","outputs":["{projectRoot}/target/wasm/release/*.wasm"]},
1850 "test":{"command":"true","dependsOn":[{"projects":["lib"],"target":"build"}],
1851 "configurations":{"production":{}}}}}"#,
1852 )
1853 .unwrap();
1854 std::fs::write(
1855 root.join("lib/project.json"),
1856 r#"{"name":"lib","targets":{"build":{"command":"true","outputs":["{options.outputPath}"],
1857 "options":{"outputPath":"lib/dist"},
1858 "configurations":{"production":{"outputPath":"lib/dist-production"}}}}}"#,
1859 )
1860 .unwrap();
1861 let workspace = Workspace::load(root).unwrap();
1862 let graph = qk_taskgraph::TaskGraph::build(
1863 &workspace,
1864 &[
1865 qk_taskgraph::Request::parse("app:lint").unwrap(),
1866 qk_taskgraph::Request::parse("app:cover").unwrap(),
1867 qk_taskgraph::Request::parse("app:test").unwrap(),
1868 qk_taskgraph::Request::parse("app:test:production").unwrap(),
1869 ],
1870 )
1871 .unwrap();
1872 let outputs = workspace_outputs(&workspace);
1873 let others = |task: &str| recorded::Others {
1874 outputs: &outputs,
1875 upstream: upstream(&graph, &graph.tasks[task]),
1876 };
1877 let lint = others("app:lint");
1878 assert!(lint.matches("app/dist/main.js"));
1879 assert!(lint.matches("app/dist-production/main.js"));
1880 assert!(lint.matches("lib/dist"));
1881 assert!(!lint.matches("app/src/main.ts"));
1882 assert!(lint.matches("app/target"));
1884 assert!(lint.matches("app/target/wasm"));
1885 assert!(!lint.matches("app"));
1886 assert!(lint.matches(".cache"));
1888 assert!(lint.matches("app/extra"));
1889 assert!(!lint.matches("lib"));
1890 let unlisted = recorded::Unlisted {
1893 own: Vec::new(),
1894 others: Some(lint),
1895 ignore: &ultracache::IgnoredReads::none(),
1896 };
1897 let directories = unlisted.directories("lib");
1898 for directory in [".", ".cache", "app", "app/extra"] {
1899 assert!(directories.contains(directory), "{directory}");
1900 }
1901 assert!(!others("app:test").matches("lib/dist"));
1904 assert!(others("app:test").matches("lib/dist-production"));
1905 assert!(others("app:cover").matches("app/.coverage"));
1908 let production = others("app:test:production");
1909 assert!(!production.matches("lib/dist-production"));
1910 assert!(production.matches("lib/dist"));
1911 }
1912}