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