1mod evict;
4mod glob;
5mod hash;
6mod ignored;
7mod inputs;
8mod paths;
9pub mod profile;
10mod record;
11pub mod recorded;
12mod remote;
13mod store;
14pub mod ultracache;
15pub mod warm;
16
17use std::collections::{BTreeMap, BTreeSet};
18use std::path::PathBuf;
19use std::sync::OnceLock;
20use std::sync::atomic::{AtomicBool, Ordering};
21
22use anyhow::Result;
23use qk_config::Workspace;
24use qk_executor::{Capture, Outcome, PreparedTask, execute, execute_captured};
25use qk_taskgraph::{Task, TaskGraph};
26use serde_json::Value;
27
28pub use evict::{Pruned, max_size, parse_size, prune, prune_automatic};
29pub use glob::Pattern;
30pub use hash::{DependencyGlob, Selects, SourceIgnore, dependency_globs, source_files};
31pub use inputs::{
32 InputChange, InputChanges, JsonFields, ROOT_TSCONFIG_FILES, Resolution, Resolved, TsConfig,
33 manifest_for_dependents, resolve_each, resolve_tasks, selectively_hash_tsconfig,
34 without_resolution,
35};
36pub use paths::expand as expand_project_path;
38pub use paths::{
39 Outputs, cache_directory, cache_location, clear_worktree_state, resolved_outputs,
40 worktree_state,
41};
42
43pub struct Cache {
45 pub root: PathBuf,
46 snapshot: OnceLock<std::result::Result<hash::Snapshot, String>>,
47 remote: Option<std::sync::Arc<remote::Remote>>,
48 reuse: bool,
50 kept: std::sync::Mutex<std::collections::BTreeSet<String>>,
52 saves: std::sync::Mutex<Vec<std::thread::JoinHandle<()>>>,
54 cleanup: std::sync::Mutex<Option<store::Cleanup>>,
55 workspace_outputs: OnceLock<recorded::WorkspaceOutputs>,
57}
58
59#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
61#[serde(rename_all = "kebab-case")]
62pub enum CacheStatus {
63 LocalHit,
64 RemoteHit,
65 Miss,
66 Uncached,
68}
69
70pub struct Execution {
72 pub log: qk_executor::RecordedLog,
73 pub inputs_unchanged: bool,
74}
75
76pub struct TaskResult {
77 pub outcome: Outcome,
78 pub cache: CacheStatus,
79 pub key: Option<(String, Value)>,
81 pub warm: Option<warm::WarmReport>,
83 pub execution: Option<Execution>,
84 pub recorded: Option<Recorded>,
86 pub shadow: Option<Shadow>,
89}
90
91#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
93#[serde(rename_all = "camelCase", tag = "kind")]
94pub enum Shadow {
95 WouldHit { key: String },
97 WouldMiss,
99 Unrecorded,
101}
102
103enum Found {
105 Local(String),
107 Remote(String),
109 Nothing,
110}
111
112#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
114#[serde(rename_all = "camelCase", tag = "kind")]
115pub enum Recorded {
116 Hit { key: String },
118 Saved { key: String },
120 Missed,
122 Unrecorded,
125 Failed { reason: String },
127}
128
129impl TaskResult {
130 pub fn uncached(outcome: Outcome) -> Self {
131 Self {
132 outcome,
133 cache: CacheStatus::Uncached,
134 key: None,
135 warm: None,
136 execution: None,
137 recorded: None,
138 shadow: None,
139 }
140 }
141}
142
143impl Cache {
144 pub fn new(root: PathBuf) -> Self {
145 Self {
146 root,
147 snapshot: OnceLock::new(),
148 remote: None,
149 reuse: true,
150 kept: Default::default(),
151 saves: Default::default(),
152 cleanup: Default::default(),
153 workspace_outputs: OnceLock::new(),
154 }
155 }
156
157 pub fn for_observation(workspace: &Workspace) -> Self {
159 Self {
160 reuse: false,
161 ..Self::new(paths::cache_location(workspace))
162 }
163 }
164
165 pub fn for_workspace(
171 workspace: &Workspace,
172 environment: &BTreeMap<std::ffi::OsString, std::ffi::OsString>,
173 ) -> Self {
174 let root = paths::cache_location(workspace);
175 let extra = &workspace.config.extra;
176 let (s3, server) = match extra.get("qk:storeServer") {
179 Some(server) => (None, Some(server)),
180 None => (extra.get("s3"), extra.get("storeServer")),
181 };
182 let remote = match remote::configure(s3, server, environment) {
183 Ok(remote) => remote.map(std::sync::Arc::new),
184 Err(error) => {
185 qk_executor::status!("qk: remote cache disabled: {error:#}");
186 None
187 }
188 };
189 if let Some(remote) = &remote {
190 remote.read_index(&root);
191 }
192 Self {
193 remote,
194 ..Self::new(root)
195 }
196 }
197
198 pub fn finish(&self, workspace: &Workspace) {
201 if !self.reuse {
202 return;
203 }
204 if let Some(Ok(snapshot)) = self.snapshot.get() {
205 snapshot.save_digests(&workspace.root);
206 }
207 for save in std::mem::take(&mut *self.saves.lock().unwrap()) {
209 let _ = save.join();
210 }
211 if let Some(remote) = &self.remote {
212 let failures = remote.finish();
213 for failure in &failures {
214 qk_executor::status!("qk: remote cache upload failed for {failure}");
215 }
216 }
217 let _profile = profile::span("cache", "restore_cleanup_wait");
218 self.cleanup.lock().unwrap().take();
219 }
220
221 fn snapshot(
222 &self,
223 workspace: &Workspace,
224 graph: &TaskGraph,
225 ) -> std::result::Result<&hash::Snapshot, String> {
226 self.snapshot
227 .get_or_init(|| {
228 hash::Snapshot::new(workspace, graph, &self.root)
229 .map_err(|error| format!("cannot snapshot the workspace: {error:#}"))
230 })
231 .as_ref()
232 .map_err(Clone::clone)
233 }
234
235 pub fn run(
236 &self,
237 workspace: &Workspace,
238 graph: &TaskGraph,
239 task: &Task,
240 prepared: &PreparedTask,
241 cancelled: &AtomicBool,
242 ) -> Result<TaskResult> {
243 let result = self.run_task(workspace, graph, task, prepared, cancelled);
244 if let Some(Ok(snapshot)) = self.snapshot.get() {
245 snapshot.outputs_written(workspace, task);
246 }
247 result
248 }
249
250 fn run_task(
251 &self,
252 workspace: &Workspace,
253 graph: &TaskGraph,
254 task: &Task,
255 prepared: &PreparedTask,
256 cancelled: &AtomicBool,
257 ) -> Result<TaskResult> {
258 let cacheable = task.definition.cache == Some(true);
259 let _kept = self
261 .reuse
262 .then(|| warm::keep_across(workspace, graph, task));
263 let entries = if self.reuse {
264 warm::config(workspace, task).unwrap_or_else(|error| {
265 qk_executor::status!("qk: {}: warm state ignored ({error:#})", task.id);
266 Vec::new()
267 })
268 } else {
269 Vec::new()
270 };
271 let entry_label = |warm: &warm::Warm| match warm.group() {
273 Some(group) => format!(" (group {group})"),
274 None => String::new(),
275 };
276 let mut running = prepared.clone();
279 for warm in &entries {
280 for (name, value) in &warm.env {
281 running.execution.insert(name.into(), value.into());
282 }
283 }
284 let before = |running: &PreparedTask| -> (Vec<warm::Restored>, Vec<String>) {
286 let (mut restored, mut present) = (Vec::new(), Vec::new());
287 for warm in &entries {
288 match self
289 .initialize()
290 .and_then(|()| self.restore_warm(workspace, task, warm, running))
291 {
292 Ok((entry, groups)) => {
293 if !entry.groups.is_empty() {
294 restored.push(entry);
295 }
296 present.extend(groups);
297 }
298 Err(error) => qk_executor::status!(
299 "qk: {}: warm state{} not restored ({error:#})",
300 task.id,
301 entry_label(warm)
302 ),
303 }
304 }
305 (restored, present)
306 };
307 let after = |outcome: Outcome,
308 (restored, present): (Vec<warm::Restored>, Vec<String>),
309 running: &PreparedTask|
310 -> Option<warm::WarmReport> {
311 if entries.is_empty() {
312 return None;
313 }
314 let mut save_ms = None;
315 let mut background = false;
316 for warm in entries.iter().filter(|_| outcome == Outcome::Success) {
317 let started = std::time::Instant::now();
318 let saved = if warm.background {
319 self.save_warm_in_background(workspace, task, warm, running)
320 } else {
321 self.save_warm(workspace, task, warm, running)
322 };
323 match saved {
324 Ok(()) if warm.background => background = true,
325 Ok(()) => {
326 *save_ms.get_or_insert(0) += started.elapsed().as_millis() as u64;
327 }
328 Err(error) => qk_executor::status!(
329 "qk: {}: warm state{} not saved ({error:#})",
330 task.id,
331 entry_label(warm)
332 ),
333 }
334 }
335 Some(warm::WarmReport {
336 restored,
337 present,
338 save_ms,
339 background,
340 })
341 };
342 let bypass = |reason: String| {
344 if cacheable {
345 qk_executor::status!("qk: {}: cache bypassed ({reason})", task.id);
346 }
347 let _profile = profile::span(&task.id, "execute");
348 execute(prepared, cancelled).map(TaskResult::uncached)
349 };
350 let snapshot = match self.snapshot(workspace, graph) {
351 Ok(snapshot) => snapshot,
352 Err(reason) => return bypass(reason),
353 };
354 let dependency_profile = profile::span(&task.id, "dependency_inputs");
355 let dependencies = match hash::dependency_keys(snapshot, workspace, graph, task, cancelled)
356 {
357 Ok(dependencies) => dependencies,
358 Err(error) => return bypass(format!("{error:#}")),
359 };
360 drop(dependency_profile);
361 let input_profile = profile::span(&task.id, "inputs");
362 let (key, inputs) = match hash::inputs(
363 snapshot,
364 workspace,
365 task,
366 prepared,
367 &dependencies,
368 cancelled,
369 )
370 .and_then(|inputs| Ok((hash::key(&inputs)?, inputs)))
371 {
372 Ok(key) => key,
373 Err(error) => return bypass(format!("{error:#}")),
374 };
375 drop(input_profile);
376 let keyed = Some((key.clone(), inputs));
377 running
379 .execution
380 .insert("NX_TASK_HASH".into(), key.clone().into());
381 let running = &running;
382 let outputs = match paths::Outputs::new(workspace, task) {
383 Ok(outputs) => outputs,
384 Err(error) => return bypass(format!("{error:#}")),
385 };
386 if !cacheable {
387 let restored = before(running);
388 let executing = profile::span(&task.id, "execute");
389 let outcome = execute(running, cancelled)?;
390 drop(executing);
391 let warm_report = after(outcome, restored, running);
392 return Ok(TaskResult {
393 outcome,
394 cache: CacheStatus::Uncached,
395 key: keyed,
396 warm: warm_report,
397 execution: None,
398 recorded: None,
399 shadow: None,
400 });
401 }
402 if !self.reuse {
403 let capture = Capture::new(None, prepared.display.clone()).retain_log();
404 let executing = profile::span(&task.id, "execute");
405 let outcome = execute_captured(running, cancelled, Some(&capture))?;
406 drop(executing);
407 let inputs_unchanged =
408 self.inputs_match(snapshot, workspace, graph, task, prepared, &key, cancelled);
409 return Ok(TaskResult {
410 outcome,
411 cache: CacheStatus::Uncached,
412 key: keyed,
413 warm: None,
414 execution: capture.take_log().map(|log| Execution {
415 log,
416 inputs_unchanged,
417 }),
418 recorded: None,
419 shadow: None,
420 });
421 }
422 if let Err(error) = self.initialize() {
423 return bypass(format!("cache unavailable: {error}"));
424 }
425 let (_lock, waited) = match self.lock(&key, cancelled) {
426 Ok(Some(lock)) => lock,
427 Ok(None) => {
428 return Ok(TaskResult::uncached(Outcome::Cancelled));
429 }
430 Err(error) => return bypass(format!("cache lock unavailable: {error}")),
431 };
432 if waited
434 && hash::recheck_dependency_keys(snapshot, workspace, graph, task, cancelled)
435 .and_then(|dependencies| {
436 hash::fingerprint(
437 snapshot,
438 workspace,
439 task,
440 prepared,
441 &dependencies,
442 cancelled,
443 )
444 })
445 .ok()
446 .as_ref()
447 != Some(&key)
448 {
449 return bypass("inputs changed while waiting for another run".into());
450 }
451 let settings = ultracache::Settings::of(task).unwrap_or_else(|error| {
454 qk_executor::status!("qk: {}: qk:ultracache ignored ({error:#})", task.id);
455 None
456 });
457 let warm_outputs = paths::Outputs::from_paths(&warm::kept_paths(&entries)).ok();
458 let own: Vec<&paths::Outputs> = std::iter::once(&outputs)
459 .chain(warm_outputs.as_ref())
460 .collect();
461 let others = settings.as_ref().map(|_| recorded::Others {
465 outputs: self
466 .workspace_outputs
467 .get_or_init(|| workspace_outputs(workspace)),
468 upstream: upstream(graph, task),
469 });
470 let ignore: Vec<Pattern> = settings
471 .as_ref()
472 .map(|settings| {
473 settings
474 .ignore_reads
475 .iter()
476 .filter_map(|glob| Pattern::new(glob, false).ok())
477 .collect()
478 })
479 .unwrap_or_default();
480 let unlisted = recorded::Unlisted {
481 own: own.clone(),
482 others,
483 ignore: &ignore,
484 };
485 let inputs = &keyed.as_ref().expect("a cacheable task is keyed").1;
486 let mut recordings = settings.and_then(|settings| {
487 let weak = hash::weak_key(
488 snapshot,
489 workspace,
490 graph,
491 task,
492 prepared,
493 inputs,
494 &settings.ignore_reads,
495 cancelled,
496 );
497 match weak {
498 Ok(weak) => Some((recorded::load(&self.root, &weak), weak, settings)),
499 Err(error) => {
500 qk_executor::status!(
501 "qk: {}: not keyed by its recordings ({error:#})",
502 task.id
503 );
504 None
505 }
506 }
507 });
508 let mut shadow = None;
509 if let Some((sets, weak, settings)) = &mut recordings {
510 let recorded_profile = profile::span(&task.id, "recorded_restore");
511 let keyed_by = settings.mode == ultracache::Mode::On;
512 let found = self.by_recordings(
513 workspace, task, prepared, snapshot, &outputs, &unlisted, sets, weak, &key,
514 keyed_by, cancelled,
515 );
516 drop(recorded_profile);
517 match (keyed_by, found) {
518 (true, found @ (Found::Local(_) | Found::Remote(_))) => {
519 let (strong, remote) = match found {
520 Found::Local(strong) => {
521 if let Some(remote) = &self.remote {
522 remote.held(&strong);
523 }
524 (strong, None)
525 }
526 Found::Remote(strong) => (strong, Some(())),
527 Found::Nothing => unreachable!(),
528 };
529 recorded::touch(&self.root, weak);
530 qk_executor::report::event(qk_executor::report::Event::Cache {
531 id: task.id.clone(),
532 cached: if remote.is_some() {
533 qk_executor::report::Cached::RemoteHit
534 } else {
535 qk_executor::report::Cached::Hit
536 },
537 });
538 return Ok(TaskResult {
539 outcome: Outcome::Success,
540 cache: if remote.is_some() {
541 CacheStatus::RemoteHit
542 } else {
543 CacheStatus::LocalHit
544 },
545 key: keyed,
546 warm: None,
547 execution: None,
548 recorded: Some(Recorded::Hit { key: strong }),
549 shadow: None,
550 });
551 }
552 (true, Found::Nothing) => {}
553 (false, Found::Local(strong) | Found::Remote(strong)) => {
554 shadow = Some(Shadow::WouldHit { key: strong });
555 }
556 (false, Found::Nothing) if sets.is_empty() => shadow = Some(Shadow::Unrecorded),
557 (false, Found::Nothing) => shadow = Some(Shadow::WouldMiss),
558 }
559 }
560 let declared = recordings.as_ref().is_none_or(|(sets, _, settings)| {
564 sets.is_empty() || settings.mode == ultracache::Mode::Record
565 });
566 let unrecorded = recordings
568 .as_ref()
569 .filter(|(_, _, settings)| settings.mode == ultracache::Mode::On)
570 .map(|_| Recorded::Unrecorded);
571 let prefetched = declared
574 && self.remote.as_ref().is_some_and(|remote| {
575 remote.resolved(&key);
576 remote.prefetched(&key)
577 });
578 let local_profile = profile::span(&task.id, "local_restore");
579 let restored = if declared {
580 self.restore(
581 &workspace.root,
582 &task.id,
583 &task.dependencies,
584 &key,
585 &outputs,
586 &prepared.display,
587 if prefetched {
588 qk_executor::Shown::RemoteCache
589 } else {
590 qk_executor::Shown::LocalCache
591 },
592 )
593 } else {
594 Ok(false)
595 };
596 match restored {
597 Ok(true) => {
598 let claimed = match &self.remote {
599 Some(remote) if prefetched && remote.claim(&key) => true,
600 Some(remote) => {
601 remote.held(&key);
602 false
603 }
604 None => false,
605 };
606 qk_executor::report::event(qk_executor::report::Event::Cache {
607 id: task.id.clone(),
608 cached: if claimed {
609 qk_executor::report::Cached::RemoteHit
610 } else {
611 qk_executor::report::Cached::Hit
612 },
613 });
614 return Ok(TaskResult {
615 outcome: Outcome::Success,
616 cache: if claimed {
617 CacheStatus::RemoteHit
618 } else {
619 CacheStatus::LocalHit
620 },
621 key: keyed,
622 warm: None,
623 execution: None,
624 recorded: unrecorded,
625 shadow,
626 });
627 }
628 Ok(false) => {}
629 Err(error) => {
630 qk_executor::status!("qk: {}: ignoring unusable cache entry ({error})", task.id)
631 }
632 }
633 drop(local_profile);
634 if let Some(remote) = self.remote.as_ref().filter(|_| declared) {
635 let fetch_profile = profile::span(&task.id, "remote_fetch");
636 let fetched = remote.fetch(&self.root, &key, &task.id, cancelled);
637 drop(fetch_profile);
638 let fetched = fetched.and_then(|found| {
639 if !found {
640 return Ok(false);
641 }
642 self.restore(
643 &workspace.root,
644 &task.id,
645 &task.dependencies,
646 &key,
647 &outputs,
648 &prepared.display,
649 qk_executor::Shown::RemoteCache,
650 )
651 });
652 match fetched {
653 Ok(true) => {
654 qk_executor::report::event(qk_executor::report::Event::Cache {
655 id: task.id.clone(),
656 cached: qk_executor::report::Cached::RemoteHit,
657 });
658 return Ok(TaskResult {
659 outcome: Outcome::Success,
660 cache: CacheStatus::RemoteHit,
661 key: keyed,
662 warm: None,
663 execution: None,
664 recorded: unrecorded,
665 shadow,
666 });
667 }
668 Ok(false) => {}
669 Err(error) => {
670 qk_executor::status!("qk: {}: remote cache unavailable ({error:#})", task.id)
671 }
672 }
673 }
674 qk_executor::report::event(qk_executor::report::Event::Cache {
675 id: task.id.clone(),
676 cached: qk_executor::report::Cached::Miss,
677 });
678 let log = match tempfile::NamedTempFile::new_in(self.root.join("tmp")) {
679 Ok(log) => log,
680 Err(error) => return bypass(format!("cache log unavailable: {error}")),
681 };
682 let capture =
683 Capture::new(Some(log.as_file().try_clone()?), prepared.display.clone()).retain_log();
684 let restored = before(running);
685 let listed = match (&recordings, &prepared.recording) {
686 (Some(_), Some(_)) => {
687 let project_root = workspace
688 .projects
689 .get(&task.project)
690 .map_or("", |project| project.root.as_str());
691 recorded::Before::take(
692 &workspace.root,
693 unlisted.directories(project_root),
694 &unlisted,
695 )
696 }
697 _ => recorded::Before::default(),
698 };
699 let started = recorded::now(&self.root.join("tmp"));
700 let executing = profile::span(&task.id, "execute");
701 let outcome = execute_captured(running, cancelled, Some(&capture))?;
702 drop(executing);
703 let warm_report = after(outcome, restored, running);
704 let inputs_unchanged =
705 self.inputs_match(snapshot, workspace, graph, task, prepared, &key, cancelled);
706 let execution = capture.take_log().map(|log| Execution {
707 log,
708 inputs_unchanged,
709 });
710 let mut recorded = unrecorded.map(|unrecorded| match &recordings {
711 Some((sets, _, _)) if !sets.is_empty() => Recorded::Missed,
712 _ => unrecorded,
713 });
714 let path_set = match (&recordings, &prepared.recording) {
715 (Some((_, _, settings)), Some(recording)) => Some(
716 self.path_set(workspace, recording, prepared, settings, &own)
717 .and_then(|set| {
718 if set.is_empty() {
720 return Err("its trace named no path in the workspace".to_owned());
721 }
722 match set
723 .changed_since(&workspace.root, started, &unlisted, &listed)
724 .map(|changed| changed.to_string())
725 {
726 Some(reason) => Err(reason),
727 None => Ok(set),
728 }
729 }),
730 ),
731 _ => None,
732 };
733 if let Some(Err(reason)) = &path_set {
734 qk_executor::status!("qk: {}: not recorded ({reason})", task.id);
735 recorded = Some(Recorded::Failed {
736 reason: reason.clone(),
737 });
738 }
739 if outcome == Outcome::Success && inputs_unchanged && capture.healthy() {
740 let publishing = profile::span(&task.id, "publish");
741 let published = self.publish(&workspace.root, &key, &outputs, log.path());
742 drop(publishing);
743 match published {
744 Ok(()) => {
745 store::record_outputs(&workspace.root, &task.id, &key, &outputs);
746 if let Some(remote) = &self.remote {
747 remote.upload(&self.root, &key);
748 }
749 }
750 Err(error) => {
751 qk_executor::status!("qk: {}: could not save cache entry ({error})", task.id);
752 }
753 }
754 if let (Some((_, weak, _)), Some(Ok(set))) = (&recordings, path_set) {
756 let saved = recorded::strong_key(weak, &set, snapshot, &workspace.root, &unlisted)
757 .and_then(|strong| {
758 self.publish(&workspace.root, &strong, &outputs, log.path())?;
759 recorded::save(&self.root, weak, set)?;
760 if let Some(remote) = &self.remote {
763 remote.upload(&self.root, &strong);
764 if let Some(record) = recorded::record(&self.root, weak) {
765 remote.upload_path_sets(&self.root, weak, record);
766 }
767 }
768 Ok(strong)
769 });
770 recorded = Some(match saved {
771 Ok(key) => Recorded::Saved { key },
772 Err(error) => {
773 qk_executor::status!("qk: {}: recording not saved ({error:#})", task.id);
774 Recorded::Failed {
775 reason: format!("{error:#}"),
776 }
777 }
778 });
779 }
780 }
781 Ok(TaskResult {
782 outcome,
783 cache: CacheStatus::Miss,
784 key: keyed,
785 warm: warm_report,
786 execution,
787 recorded,
788 shadow,
789 })
790 }
791
792 #[allow(clippy::too_many_arguments)]
798 fn by_recordings(
799 &self,
800 workspace: &Workspace,
801 task: &Task,
802 prepared: &PreparedTask,
803 snapshot: &hash::Snapshot,
804 outputs: &paths::Outputs,
805 unlisted: &recorded::Unlisted,
806 sets: &mut Vec<recorded::PathSet>,
807 weak: &str,
808 declared: &str,
809 restore: bool,
810 cancelled: &AtomicBool,
811 ) -> Found {
812 let strong = |set: &recorded::PathSet| {
813 recorded::strong_key(weak, set, snapshot, &workspace.root, unlisted)
814 .inspect_err(|error| {
815 qk_executor::status!("qk: {}: ignoring a path set ({error:#})", task.id);
816 })
817 .ok()
818 };
819 let restored = |strong: &str, shown| {
820 self.restore(
821 &workspace.root,
822 &task.id,
823 &task.dependencies,
824 strong,
825 outputs,
826 &prepared.display,
827 shown,
828 )
829 .unwrap_or_else(|error| {
830 qk_executor::status!("qk: {}: ignoring unusable cache entry ({error})", task.id);
831 false
832 })
833 };
834 let local = |strong: &str| {
835 if restore {
836 restored(strong, qk_executor::Shown::LocalCache)
837 } else {
838 self.root
839 .join("entries")
840 .join(format!("{strong}.json"))
841 .is_file()
842 }
843 };
844 let mut keys = Vec::new();
845 for set in sets.iter() {
846 if let Some(strong) = strong(set) {
847 if local(&strong) {
848 return Found::Local(strong);
849 }
850 keys.push(strong);
851 }
852 }
853 let Some(remote) = &self.remote else {
854 return Found::Nothing;
855 };
856 if restore
861 && sets.is_empty()
862 && self
863 .root
864 .join("entries")
865 .join(format!("{declared}.json"))
866 .is_file()
867 {
868 return Found::Nothing;
869 }
870 let fetched = remote
872 .fetch_path_sets(&self.root, weak, &task.id)
873 .and_then(|record| match record {
874 Some(record) => recorded::merge(&self.root, weak, &record),
875 None => Ok(Vec::new()),
876 });
877 match fetched {
878 Ok(added) => {
879 for set in added {
880 if let Some(strong) = strong(&set) {
881 if local(&strong) {
882 sets.push(set);
883 return Found::Local(strong);
884 }
885 keys.push(strong);
886 }
887 sets.push(set);
888 }
889 }
890 Err(error) => {
891 qk_executor::status!("qk: {}: remote path sets unavailable ({error:#})", task.id)
892 }
893 }
894 for strong in keys {
895 let found = if restore {
896 remote
897 .fetch(&self.root, &strong, &task.id, cancelled)
898 .unwrap_or_else(|error| {
899 qk_executor::status!(
900 "qk: {}: remote cache unavailable ({error:#})",
901 task.id
902 );
903 false
904 })
905 && restored(&strong, qk_executor::Shown::RemoteCache)
906 } else {
907 remote.holds(&strong).unwrap_or(false)
908 };
909 if found {
910 return Found::Remote(strong);
911 }
912 }
913 Found::Nothing
914 }
915
916 fn path_set(
918 &self,
919 workspace: &Workspace,
920 recording: &qk_executor::Recording,
921 prepared: &PreparedTask,
922 settings: &ultracache::Settings,
923 own: &[&paths::Outputs],
924 ) -> std::result::Result<recorded::PathSet, String> {
925 let failed = |error: anyhow::Error| format!("{error:#}");
926 let canonical = workspace
927 .root
928 .canonicalize()
929 .map_err(|e| failed(e.into()))?;
930 let keep = |path: &std::path::Path| {
933 [&workspace.root, &canonical].iter().any(|root| {
934 path.strip_prefix(root).is_ok_and(|relative| {
935 !relative.components().any(|component| {
936 matches!(
937 component.as_os_str().to_str(),
938 Some("node_modules" | ".git")
939 )
940 })
941 })
942 })
943 };
944 let limits = qk_input_analysis::strace::Limits {
945 records: 1_000_000,
946 bytes: 256 * 1024 * 1024,
947 accesses: 500_000,
948 keep: Some(&keep),
949 };
950 let (accesses, diagnostics) =
951 qk_input_analysis::strace::collect_within(&recording.directory, &prepared.cwd, &limits)
952 .map_err(failed)?;
953 if let Some(diagnostic) = diagnostics.first() {
955 return Err(diagnostic.clone());
956 }
957 let ignore = settings
958 .ignore_reads
959 .iter()
960 .map(|glob| Pattern::new(glob, false))
961 .collect::<Result<Vec<_>>>()
962 .map_err(failed)?;
963 let state = [self.root.clone(), worktree_state(&workspace.root)]
965 .iter()
966 .filter_map(|path| {
967 [&workspace.root, &canonical]
968 .iter()
969 .find_map(|root| path.strip_prefix(root).ok())
970 .and_then(|relative| relative.to_str())
971 .map(|relative| relative.replace('\\', "/"))
972 })
973 .collect();
974 let exclusions = recorded::Exclusions {
975 roots: [&workspace.root, &canonical],
976 outputs: own,
977 ignore,
978 state,
979 };
980 Ok(recorded::PathSet::from_accesses(
981 &accesses,
982 &exclusions,
983 &canonical,
984 ))
985 }
986
987 #[allow(clippy::too_many_arguments)]
989 fn inputs_match(
990 &self,
991 snapshot: &hash::Snapshot,
992 workspace: &Workspace,
993 graph: &TaskGraph,
994 task: &Task,
995 prepared: &PreparedTask,
996 before: &str,
997 cancelled: &AtomicBool,
998 ) -> bool {
999 if cancelled.load(Ordering::SeqCst) {
1000 return false;
1001 }
1002 let _profile = profile::span(&task.id, "inputs_recheck");
1003 let after = hash::recheck_dependency_keys(snapshot, workspace, graph, task, cancelled)
1004 .and_then(|dependencies| {
1005 hash::fingerprint(
1006 snapshot,
1007 workspace,
1008 task,
1009 prepared,
1010 &dependencies,
1011 cancelled,
1012 )
1013 });
1014 if after.as_deref().ok() != Some(before) {
1015 if task.definition.cache == Some(true) {
1016 if self.reuse {
1017 qk_executor::status!(
1018 "qk: {}: not caching because inputs changed during execution",
1019 task.id
1020 );
1021 } else {
1022 qk_executor::status!(
1023 "qk: {}: excluding execution from flaky results because inputs changed during execution",
1024 task.id
1025 );
1026 }
1027 }
1028 return false;
1029 }
1030 true
1031 }
1032}
1033
1034pub type Keyed = std::result::Result<(String, Value), String>;
1036
1037pub fn keys(
1041 workspace: &Workspace,
1042 graph: &TaskGraph,
1043 prepared: &BTreeMap<String, PreparedTask>,
1044 cancelled: &AtomicBool,
1045) -> Result<BTreeMap<String, Keyed>> {
1046 let snapshot = hash::Snapshot::new(workspace, graph, &paths::cache_location(workspace))?;
1047 Ok(graph
1048 .tasks
1049 .values()
1050 .map(|task| {
1051 let keyed = key_now(&snapshot, workspace, graph, task, prepared, cancelled);
1052 (task.id.clone(), keyed)
1053 })
1054 .collect())
1055}
1056
1057fn key_now(
1058 snapshot: &hash::Snapshot,
1059 workspace: &Workspace,
1060 graph: &TaskGraph,
1061 task: &Task,
1062 prepared: &BTreeMap<String, PreparedTask>,
1063 cancelled: &AtomicBool,
1064) -> Keyed {
1065 let failed = |error: anyhow::Error| format!("{}: {error:#}", task.id);
1066 let dependencies =
1067 hash::dependency_keys(snapshot, workspace, graph, task, cancelled).map_err(failed)?;
1068 let inputs = hash::inputs(
1069 snapshot,
1070 workspace,
1071 task,
1072 &prepared[&task.id],
1073 &dependencies,
1074 cancelled,
1075 )
1076 .map_err(failed)?;
1077 Ok((hash::key(&inputs).map_err(failed)?, inputs))
1078}
1079
1080#[doc(hidden)]
1082pub fn run_upload_worker() -> anyhow::Result<()> {
1083 remote::background::run()
1084}
1085
1086fn workspace_outputs(workspace: &Workspace) -> recorded::WorkspaceOutputs {
1090 let mut outputs = Vec::new();
1091 for (name, project) in &workspace.projects {
1092 for (target, definition) in &project.targets {
1093 let configured = definition
1094 .configurations
1095 .iter()
1096 .map(|(configuration, options)| {
1097 let mut definition = definition.clone();
1098 definition.options.extend(options.clone());
1099 (Some(configuration.clone()), definition)
1100 });
1101 for (configuration, definition) in
1102 std::iter::once((None, definition.clone())).chain(configured)
1103 {
1104 let task = Task {
1105 id: format!("{name}:{target}"),
1106 project: name.clone(),
1107 target: target.clone(),
1108 configuration: configuration.clone(),
1109 args: Vec::new(),
1110 definition,
1111 dependencies: Default::default(),
1112 edges: Default::default(),
1113 };
1114 if let Some(declared) = paths::Outputs::new(workspace, &task)
1115 .ok()
1116 .filter(paths::Outputs::declared)
1117 {
1118 outputs.push(((name.clone(), target.clone(), configuration), declared));
1119 }
1120 }
1121 }
1122 }
1123 let roots = workspace
1124 .projects
1125 .iter()
1126 .map(|(name, project)| (name.clone(), project.root.clone()))
1127 .collect();
1128 recorded::WorkspaceOutputs::new(outputs, &roots)
1129}
1130
1131fn upstream(graph: &TaskGraph, task: &Task) -> BTreeSet<recorded::TargetOf> {
1134 let of = |task: &Task| {
1135 (
1136 task.project.clone(),
1137 task.target.clone(),
1138 task.configuration.clone(),
1139 )
1140 };
1141 let mut targets = BTreeSet::from([of(task)]);
1142 let mut seen = BTreeSet::new();
1143 let mut pending: Vec<&String> = task.dependencies.iter().collect();
1144 while let Some(id) = pending.pop() {
1145 if !seen.insert(id) {
1146 continue;
1147 }
1148 if let Some(dependency) = graph.tasks.get(id) {
1149 targets.insert(of(dependency));
1150 pending.extend(&dependency.dependencies);
1151 }
1152 }
1153 targets
1154}
1155
1156#[cfg(test)]
1157mod tests {
1158 use super::*;
1159
1160 #[test]
1161 fn concurrent_blob_writes_keep_results_ordered_and_repair_corruption() {
1162 let workspace = tempfile::tempdir().unwrap();
1163 let root = workspace.path();
1164 let cache = Cache::new(root.join("cache"));
1165 cache.initialize().unwrap();
1166 let mut sources = Vec::new();
1167 for index in 0..130 {
1168 let source = root.join(format!("source-{index}"));
1169 std::fs::write(&source, format!("content-{}", index % 65)).unwrap();
1170 sources.push(source);
1171 }
1172 let digest = cache.put_blob(&sources[0]).unwrap();
1173 std::fs::write(cache.root.join("blobs").join(&digest), "corrupt").unwrap();
1174 std::fs::remove_file(&sources[31]).unwrap();
1175 let results = cache.put_blobs(&sources);
1176 assert_eq!(results.len(), sources.len());
1177 for (index, result) in results.into_iter().enumerate() {
1178 if index == 31 {
1179 assert_eq!(
1180 result
1181 .unwrap_err()
1182 .downcast_ref::<std::io::Error>()
1183 .unwrap()
1184 .kind(),
1185 std::io::ErrorKind::NotFound
1186 );
1187 continue;
1188 }
1189 let digest = result.unwrap();
1190 let held = cache.root.join("blobs").join(&digest);
1191 assert_eq!(
1192 std::fs::read(&held).unwrap(),
1193 format!("content-{}", index % 65).as_bytes()
1194 );
1195 assert_eq!(hash::digest_file(&held).unwrap(), digest);
1196 }
1197 }
1198
1199 #[cfg(unix)]
1201 #[test]
1202 fn a_blob_the_store_holds_is_not_written_again() {
1203 use std::os::unix::fs::MetadataExt;
1204 let workspace = tempfile::tempdir().unwrap();
1205 let root = workspace.path();
1206 let cache = Cache::new(root.join(".qk/cache"));
1207 cache.initialize().unwrap();
1208 std::fs::write(root.join("first"), "same").unwrap();
1209 std::fs::write(root.join("second"), "same").unwrap();
1210 std::fs::write(root.join("other"), "different").unwrap();
1211 let first = cache.put_blob(&root.join("first")).unwrap();
1212 let blob = cache.root.join("blobs").join(&first);
1213 let stored = std::fs::metadata(&blob).unwrap().ino();
1214 assert_eq!(cache.put_blob(&root.join("second")).unwrap(), first);
1215 assert_eq!(std::fs::metadata(&blob).unwrap().ino(), stored);
1216 std::fs::write(&blob, "corrupt").unwrap();
1218 assert_eq!(cache.put_blob(&root.join("second")).unwrap(), first);
1219 assert_eq!(std::fs::read(&blob).unwrap(), b"same");
1220 {
1222 use std::os::unix::fs::PermissionsExt;
1223 std::fs::set_permissions(&blob, std::fs::Permissions::from_mode(0o000)).unwrap();
1224 }
1225 assert_eq!(cache.put_blob(&root.join("second")).unwrap(), first);
1226 assert_eq!(std::fs::read(&blob).unwrap(), b"same");
1227 let other = cache.put_blob(&root.join("other")).unwrap();
1228 assert_ne!(other, first);
1229 assert_eq!(
1230 std::fs::read(cache.root.join("blobs").join(other)).unwrap(),
1231 b"different"
1232 );
1233 }
1234
1235 #[test]
1238 fn complete_directory_restores_preserve_outputs() {
1239 let workspace = tempfile::tempdir().unwrap();
1240 let root = workspace.path();
1241 std::fs::create_dir_all(root.join("dist/nested/empty")).unwrap();
1242 std::fs::write(root.join("dist/nested/a.txt"), "a").unwrap();
1243 std::fs::write(root.join("dist/b.bin"), [0, 1, 2]).unwrap();
1244 std::fs::create_dir_all(root.join("bundle/empty")).unwrap();
1245 std::fs::write(root.join("bundle/readonly"), "readonly").unwrap();
1246 #[cfg(unix)]
1247 {
1248 use std::os::unix::fs::PermissionsExt;
1249 let script = root.join("dist/run.sh");
1250 std::fs::write(&script, "#!/bin/sh\n").unwrap();
1251 std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
1252 std::os::unix::fs::symlink("nested/a.txt", root.join("dist/link")).unwrap();
1253 std::fs::set_permissions(
1254 root.join("dist/nested"),
1255 std::fs::Permissions::from_mode(0o500),
1256 )
1257 .unwrap();
1258 std::fs::set_permissions(
1259 root.join("bundle/readonly"),
1260 std::fs::Permissions::from_mode(0o444),
1261 )
1262 .unwrap();
1263 }
1264 let log = root.join("log");
1265 std::fs::write(&log, "").unwrap();
1266
1267 let cache = Cache::new(root.join(".qk/cache"));
1268 cache.initialize().unwrap();
1269 let outputs = paths::Outputs::from_patterns(&["dist", "bundle"]);
1270 let key = "0".repeat(64);
1271 cache.publish(root, &key, &outputs, &log).unwrap();
1272
1273 #[cfg(unix)]
1274 {
1275 use std::os::unix::fs::PermissionsExt;
1276 std::fs::set_permissions(
1279 root.join("dist/nested"),
1280 std::fs::Permissions::from_mode(0o755),
1281 )
1282 .unwrap();
1283 }
1284 std::fs::remove_dir_all(root.join("dist")).unwrap();
1285 std::fs::remove_dir_all(root.join("bundle")).unwrap();
1286 let before = std::time::SystemTime::now();
1287 let restored = cache
1288 .restore(
1289 root,
1290 "app:build",
1291 &Default::default(),
1292 &key,
1293 &outputs,
1294 &qk_executor::Display::Stream,
1295 qk_executor::Shown::LocalCache,
1296 )
1297 .unwrap();
1298 assert!(restored);
1299 assert!(root.join("dist/nested/empty").is_dir());
1300 assert!(root.join("bundle/empty").is_dir());
1301 assert_eq!(
1302 std::fs::read_to_string(root.join("bundle/readonly")).unwrap(),
1303 "readonly"
1304 );
1305 #[cfg(unix)]
1306 {
1307 assert_eq!(
1308 std::fs::read_link(root.join("dist/link")).unwrap(),
1309 std::path::Path::new("nested/a.txt")
1310 );
1311 assert_eq!(
1312 store::mode(&std::fs::metadata(root.join("dist/run.sh")).unwrap()),
1313 0o755
1314 );
1315 assert_eq!(
1316 store::mode(&std::fs::metadata(root.join("dist/nested")).unwrap()),
1317 0o500
1318 );
1319 assert_eq!(
1320 store::mode(&std::fs::metadata(root.join("bundle/readonly")).unwrap()),
1321 0o444
1322 );
1323 }
1324 let path = root.join("dist/nested/a.txt");
1325 let modified = std::fs::metadata(&path).unwrap().modified().unwrap();
1326 assert!(modified >= before);
1327 let kept = cache
1328 .restore(
1329 root,
1330 "app:build",
1331 &Default::default(),
1332 &key,
1333 &outputs,
1334 &qk_executor::Display::Hidden,
1335 qk_executor::Shown::LocalCache,
1336 )
1337 .unwrap();
1338 assert!(kept);
1339 assert_eq!(
1340 std::fs::metadata(&path).unwrap().modified().unwrap(),
1341 modified
1342 );
1343 #[cfg(unix)]
1344 {
1345 use std::os::unix::fs::PermissionsExt;
1346 std::fs::set_permissions(
1347 root.join("dist/nested"),
1348 std::fs::Permissions::from_mode(0o755),
1349 )
1350 .unwrap();
1351 }
1352 }
1353
1354 #[test]
1355 fn listings_leave_out_other_targets_outputs_but_the_upstreams() {
1356 let temp = tempfile::tempdir().unwrap();
1357 let root = temp.path();
1358 std::fs::write(root.join("nx.json"), "{}").unwrap();
1359 std::fs::create_dir_all(root.join("app")).unwrap();
1360 std::fs::create_dir_all(root.join("lib")).unwrap();
1361 std::fs::write(
1364 root.join("project.json"),
1365 r#"{"name":"workspace","targets":{"cache":{"command":"true",
1366 "outputs":["{projectRoot}/.cache/build","{projectRoot}/app/extra/out"]}}}"#,
1367 )
1368 .unwrap();
1369 std::fs::write(
1370 root.join("app/project.json"),
1371 r#"{"name":"app","root":"./app","targets":{
1372 "build":{"command":"true","outputs":["{options.outputPath}"],
1373 "options":{"outputPath":"app/dist"},
1374 "configurations":{"production":{"outputPath":"app/dist-production"}}},
1375 "lint":{"command":"true"},
1376 "wasm":{"command":"true","outputs":["{projectRoot}/target/wasm/release/*.wasm"]},
1377 "test":{"command":"true","dependsOn":[{"projects":["lib"],"target":"build"}],
1378 "configurations":{"production":{}}}}}"#,
1379 )
1380 .unwrap();
1381 std::fs::write(
1382 root.join("lib/project.json"),
1383 r#"{"name":"lib","targets":{"build":{"command":"true","outputs":["{options.outputPath}"],
1384 "options":{"outputPath":"lib/dist"},
1385 "configurations":{"production":{"outputPath":"lib/dist-production"}}}}}"#,
1386 )
1387 .unwrap();
1388 let workspace = Workspace::load(root).unwrap();
1389 let graph = qk_taskgraph::TaskGraph::build(
1390 &workspace,
1391 &[
1392 qk_taskgraph::Request::parse("app:lint").unwrap(),
1393 qk_taskgraph::Request::parse("app:test").unwrap(),
1394 qk_taskgraph::Request::parse("app:test:production").unwrap(),
1395 ],
1396 )
1397 .unwrap();
1398 let outputs = workspace_outputs(&workspace);
1399 let others = |task: &str| recorded::Others {
1400 outputs: &outputs,
1401 upstream: upstream(&graph, &graph.tasks[task]),
1402 };
1403 let lint = others("app:lint");
1404 assert!(lint.matches("app/dist/main.js"));
1405 assert!(lint.matches("app/dist-production/main.js"));
1406 assert!(lint.matches("lib/dist"));
1407 assert!(!lint.matches("app/src/main.ts"));
1408 assert!(lint.matches("app/target"));
1410 assert!(lint.matches("app/target/wasm"));
1411 assert!(!lint.matches("app"));
1412 assert!(lint.matches(".cache"));
1414 assert!(lint.matches("app/extra"));
1415 assert!(!lint.matches("lib"));
1416 let unlisted = recorded::Unlisted {
1419 own: Vec::new(),
1420 others: Some(lint),
1421 ignore: &[],
1422 };
1423 let directories = unlisted.directories("lib");
1424 for directory in [".", ".cache", "app", "app/extra"] {
1425 assert!(directories.contains(directory), "{directory}");
1426 }
1427 assert!(!others("app:test").matches("lib/dist"));
1430 assert!(others("app:test").matches("lib/dist-production"));
1431 let production = others("app:test:production");
1432 assert!(!production.matches("lib/dist-production"));
1433 assert!(production.matches("lib/dist"));
1434 }
1435}