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.changed_since(&workspace.root, started, &unlisted, &listed) {
723 Some(path) => Err(format!("{path} changed while it ran")),
724 None => Ok(set),
725 }
726 }),
727 ),
728 _ => None,
729 };
730 if let Some(Err(reason)) = &path_set {
731 qk_executor::status!("qk: {}: not recorded ({reason})", task.id);
732 recorded = Some(Recorded::Failed {
733 reason: reason.clone(),
734 });
735 }
736 if outcome == Outcome::Success && inputs_unchanged && capture.healthy() {
737 let publishing = profile::span(&task.id, "publish");
738 let published = self.publish(&workspace.root, &key, &outputs, log.path());
739 drop(publishing);
740 match published {
741 Ok(()) => {
742 store::record_outputs(&workspace.root, &task.id, &key, &outputs);
743 if let Some(remote) = &self.remote {
744 remote.upload(&self.root, &key);
745 }
746 }
747 Err(error) => {
748 qk_executor::status!("qk: {}: could not save cache entry ({error})", task.id);
749 }
750 }
751 if let (Some((_, weak, _)), Some(Ok(set))) = (&recordings, path_set) {
753 let saved = recorded::strong_key(weak, &set, snapshot, &workspace.root, &unlisted)
754 .and_then(|strong| {
755 self.publish(&workspace.root, &strong, &outputs, log.path())?;
756 recorded::save(&self.root, weak, set)?;
757 if let Some(remote) = &self.remote {
760 remote.upload(&self.root, &strong);
761 if let Some(record) = recorded::record(&self.root, weak) {
762 remote.upload_path_sets(&self.root, weak, record);
763 }
764 }
765 Ok(strong)
766 });
767 recorded = Some(match saved {
768 Ok(key) => Recorded::Saved { key },
769 Err(error) => {
770 qk_executor::status!("qk: {}: recording not saved ({error:#})", task.id);
771 Recorded::Failed {
772 reason: format!("{error:#}"),
773 }
774 }
775 });
776 }
777 }
778 Ok(TaskResult {
779 outcome,
780 cache: CacheStatus::Miss,
781 key: keyed,
782 warm: warm_report,
783 execution,
784 recorded,
785 shadow,
786 })
787 }
788
789 #[allow(clippy::too_many_arguments)]
795 fn by_recordings(
796 &self,
797 workspace: &Workspace,
798 task: &Task,
799 prepared: &PreparedTask,
800 snapshot: &hash::Snapshot,
801 outputs: &paths::Outputs,
802 unlisted: &recorded::Unlisted,
803 sets: &mut Vec<recorded::PathSet>,
804 weak: &str,
805 declared: &str,
806 restore: bool,
807 cancelled: &AtomicBool,
808 ) -> Found {
809 let strong = |set: &recorded::PathSet| {
810 recorded::strong_key(weak, set, snapshot, &workspace.root, unlisted)
811 .inspect_err(|error| {
812 qk_executor::status!("qk: {}: ignoring a path set ({error:#})", task.id);
813 })
814 .ok()
815 };
816 let restored = |strong: &str, shown| {
817 self.restore(
818 &workspace.root,
819 &task.id,
820 &task.dependencies,
821 strong,
822 outputs,
823 &prepared.display,
824 shown,
825 )
826 .unwrap_or_else(|error| {
827 qk_executor::status!("qk: {}: ignoring unusable cache entry ({error})", task.id);
828 false
829 })
830 };
831 let local = |strong: &str| {
832 if restore {
833 restored(strong, qk_executor::Shown::LocalCache)
834 } else {
835 self.root
836 .join("entries")
837 .join(format!("{strong}.json"))
838 .is_file()
839 }
840 };
841 let mut keys = Vec::new();
842 for set in sets.iter() {
843 if let Some(strong) = strong(set) {
844 if local(&strong) {
845 return Found::Local(strong);
846 }
847 keys.push(strong);
848 }
849 }
850 let Some(remote) = &self.remote else {
851 return Found::Nothing;
852 };
853 if restore
858 && sets.is_empty()
859 && self
860 .root
861 .join("entries")
862 .join(format!("{declared}.json"))
863 .is_file()
864 {
865 return Found::Nothing;
866 }
867 let fetched = remote
869 .fetch_path_sets(&self.root, weak, &task.id)
870 .and_then(|record| match record {
871 Some(record) => recorded::merge(&self.root, weak, &record),
872 None => Ok(Vec::new()),
873 });
874 match fetched {
875 Ok(added) => {
876 for set in added {
877 if let Some(strong) = strong(&set) {
878 if local(&strong) {
879 sets.push(set);
880 return Found::Local(strong);
881 }
882 keys.push(strong);
883 }
884 sets.push(set);
885 }
886 }
887 Err(error) => {
888 qk_executor::status!("qk: {}: remote path sets unavailable ({error:#})", task.id)
889 }
890 }
891 for strong in keys {
892 let found = if restore {
893 remote
894 .fetch(&self.root, &strong, &task.id, cancelled)
895 .unwrap_or_else(|error| {
896 qk_executor::status!(
897 "qk: {}: remote cache unavailable ({error:#})",
898 task.id
899 );
900 false
901 })
902 && restored(&strong, qk_executor::Shown::RemoteCache)
903 } else {
904 remote.holds(&strong).unwrap_or(false)
905 };
906 if found {
907 return Found::Remote(strong);
908 }
909 }
910 Found::Nothing
911 }
912
913 fn path_set(
915 &self,
916 workspace: &Workspace,
917 recording: &qk_executor::Recording,
918 prepared: &PreparedTask,
919 settings: &ultracache::Settings,
920 own: &[&paths::Outputs],
921 ) -> std::result::Result<recorded::PathSet, String> {
922 let failed = |error: anyhow::Error| format!("{error:#}");
923 let canonical = workspace
924 .root
925 .canonicalize()
926 .map_err(|e| failed(e.into()))?;
927 let keep = |path: &std::path::Path| {
930 [&workspace.root, &canonical].iter().any(|root| {
931 path.strip_prefix(root).is_ok_and(|relative| {
932 !relative.components().any(|component| {
933 matches!(
934 component.as_os_str().to_str(),
935 Some("node_modules" | ".git")
936 )
937 })
938 })
939 })
940 };
941 let limits = qk_input_analysis::strace::Limits {
942 records: 1_000_000,
943 bytes: 256 * 1024 * 1024,
944 accesses: 500_000,
945 keep: Some(&keep),
946 };
947 let (accesses, diagnostics) =
948 qk_input_analysis::strace::collect_within(&recording.directory, &prepared.cwd, &limits)
949 .map_err(failed)?;
950 if let Some(diagnostic) = diagnostics.first() {
952 return Err(diagnostic.clone());
953 }
954 let ignore = settings
955 .ignore_reads
956 .iter()
957 .map(|glob| Pattern::new(glob, false))
958 .collect::<Result<Vec<_>>>()
959 .map_err(failed)?;
960 let state = [self.root.clone(), worktree_state(&workspace.root)]
962 .iter()
963 .filter_map(|path| {
964 [&workspace.root, &canonical]
965 .iter()
966 .find_map(|root| path.strip_prefix(root).ok())
967 .and_then(|relative| relative.to_str())
968 .map(|relative| relative.replace('\\', "/"))
969 })
970 .collect();
971 let exclusions = recorded::Exclusions {
972 roots: [&workspace.root, &canonical],
973 outputs: own,
974 ignore,
975 state,
976 };
977 Ok(recorded::PathSet::from_accesses(
978 &accesses,
979 &exclusions,
980 &canonical,
981 ))
982 }
983
984 #[allow(clippy::too_many_arguments)]
986 fn inputs_match(
987 &self,
988 snapshot: &hash::Snapshot,
989 workspace: &Workspace,
990 graph: &TaskGraph,
991 task: &Task,
992 prepared: &PreparedTask,
993 before: &str,
994 cancelled: &AtomicBool,
995 ) -> bool {
996 if cancelled.load(Ordering::SeqCst) {
997 return false;
998 }
999 let _profile = profile::span(&task.id, "inputs_recheck");
1000 let after = hash::recheck_dependency_keys(snapshot, workspace, graph, task, cancelled)
1001 .and_then(|dependencies| {
1002 hash::fingerprint(
1003 snapshot,
1004 workspace,
1005 task,
1006 prepared,
1007 &dependencies,
1008 cancelled,
1009 )
1010 });
1011 if after.as_deref().ok() != Some(before) {
1012 if task.definition.cache == Some(true) {
1013 if self.reuse {
1014 qk_executor::status!(
1015 "qk: {}: not caching because inputs changed during execution",
1016 task.id
1017 );
1018 } else {
1019 qk_executor::status!(
1020 "qk: {}: excluding execution from flaky results because inputs changed during execution",
1021 task.id
1022 );
1023 }
1024 }
1025 return false;
1026 }
1027 true
1028 }
1029}
1030
1031pub type Keyed = std::result::Result<(String, Value), String>;
1033
1034pub fn keys(
1038 workspace: &Workspace,
1039 graph: &TaskGraph,
1040 prepared: &BTreeMap<String, PreparedTask>,
1041 cancelled: &AtomicBool,
1042) -> Result<BTreeMap<String, Keyed>> {
1043 let snapshot = hash::Snapshot::new(workspace, graph, &paths::cache_location(workspace))?;
1044 Ok(graph
1045 .tasks
1046 .values()
1047 .map(|task| {
1048 let keyed = key_now(&snapshot, workspace, graph, task, prepared, cancelled);
1049 (task.id.clone(), keyed)
1050 })
1051 .collect())
1052}
1053
1054fn key_now(
1055 snapshot: &hash::Snapshot,
1056 workspace: &Workspace,
1057 graph: &TaskGraph,
1058 task: &Task,
1059 prepared: &BTreeMap<String, PreparedTask>,
1060 cancelled: &AtomicBool,
1061) -> Keyed {
1062 let failed = |error: anyhow::Error| format!("{}: {error:#}", task.id);
1063 let dependencies =
1064 hash::dependency_keys(snapshot, workspace, graph, task, cancelled).map_err(failed)?;
1065 let inputs = hash::inputs(
1066 snapshot,
1067 workspace,
1068 task,
1069 &prepared[&task.id],
1070 &dependencies,
1071 cancelled,
1072 )
1073 .map_err(failed)?;
1074 Ok((hash::key(&inputs).map_err(failed)?, inputs))
1075}
1076
1077#[doc(hidden)]
1079pub fn run_upload_worker() -> anyhow::Result<()> {
1080 remote::background::run()
1081}
1082
1083fn workspace_outputs(workspace: &Workspace) -> recorded::WorkspaceOutputs {
1087 let mut outputs = Vec::new();
1088 for (name, project) in &workspace.projects {
1089 for (target, definition) in &project.targets {
1090 let configured = definition
1091 .configurations
1092 .iter()
1093 .map(|(configuration, options)| {
1094 let mut definition = definition.clone();
1095 definition.options.extend(options.clone());
1096 (Some(configuration.clone()), definition)
1097 });
1098 for (configuration, definition) in
1099 std::iter::once((None, definition.clone())).chain(configured)
1100 {
1101 let task = Task {
1102 id: format!("{name}:{target}"),
1103 project: name.clone(),
1104 target: target.clone(),
1105 configuration: configuration.clone(),
1106 args: Vec::new(),
1107 definition,
1108 dependencies: Default::default(),
1109 edges: Default::default(),
1110 };
1111 if let Some(declared) = paths::Outputs::new(workspace, &task)
1112 .ok()
1113 .filter(paths::Outputs::declared)
1114 {
1115 outputs.push(((name.clone(), target.clone(), configuration), declared));
1116 }
1117 }
1118 }
1119 }
1120 recorded::WorkspaceOutputs::new(outputs)
1121}
1122
1123fn upstream(graph: &TaskGraph, task: &Task) -> BTreeSet<recorded::TargetOf> {
1126 let of = |task: &Task| {
1127 (
1128 task.project.clone(),
1129 task.target.clone(),
1130 task.configuration.clone(),
1131 )
1132 };
1133 let mut targets = BTreeSet::from([of(task)]);
1134 let mut seen = BTreeSet::new();
1135 let mut pending: Vec<&String> = task.dependencies.iter().collect();
1136 while let Some(id) = pending.pop() {
1137 if !seen.insert(id) {
1138 continue;
1139 }
1140 if let Some(dependency) = graph.tasks.get(id) {
1141 targets.insert(of(dependency));
1142 pending.extend(&dependency.dependencies);
1143 }
1144 }
1145 targets
1146}
1147
1148#[cfg(test)]
1149mod tests {
1150 use super::*;
1151
1152 #[test]
1153 fn concurrent_blob_writes_keep_results_ordered_and_repair_corruption() {
1154 let workspace = tempfile::tempdir().unwrap();
1155 let root = workspace.path();
1156 let cache = Cache::new(root.join("cache"));
1157 cache.initialize().unwrap();
1158 let mut sources = Vec::new();
1159 for index in 0..130 {
1160 let source = root.join(format!("source-{index}"));
1161 std::fs::write(&source, format!("content-{}", index % 65)).unwrap();
1162 sources.push(source);
1163 }
1164 let digest = cache.put_blob(&sources[0]).unwrap();
1165 std::fs::write(cache.root.join("blobs").join(&digest), "corrupt").unwrap();
1166 std::fs::remove_file(&sources[31]).unwrap();
1167 let results = cache.put_blobs(&sources);
1168 assert_eq!(results.len(), sources.len());
1169 for (index, result) in results.into_iter().enumerate() {
1170 if index == 31 {
1171 assert_eq!(
1172 result
1173 .unwrap_err()
1174 .downcast_ref::<std::io::Error>()
1175 .unwrap()
1176 .kind(),
1177 std::io::ErrorKind::NotFound
1178 );
1179 continue;
1180 }
1181 let digest = result.unwrap();
1182 let held = cache.root.join("blobs").join(&digest);
1183 assert_eq!(
1184 std::fs::read(&held).unwrap(),
1185 format!("content-{}", index % 65).as_bytes()
1186 );
1187 assert_eq!(hash::digest_file(&held).unwrap(), digest);
1188 }
1189 }
1190
1191 #[cfg(unix)]
1193 #[test]
1194 fn a_blob_the_store_holds_is_not_written_again() {
1195 use std::os::unix::fs::MetadataExt;
1196 let workspace = tempfile::tempdir().unwrap();
1197 let root = workspace.path();
1198 let cache = Cache::new(root.join(".qk/cache"));
1199 cache.initialize().unwrap();
1200 std::fs::write(root.join("first"), "same").unwrap();
1201 std::fs::write(root.join("second"), "same").unwrap();
1202 std::fs::write(root.join("other"), "different").unwrap();
1203 let first = cache.put_blob(&root.join("first")).unwrap();
1204 let blob = cache.root.join("blobs").join(&first);
1205 let stored = std::fs::metadata(&blob).unwrap().ino();
1206 assert_eq!(cache.put_blob(&root.join("second")).unwrap(), first);
1207 assert_eq!(std::fs::metadata(&blob).unwrap().ino(), stored);
1208 std::fs::write(&blob, "corrupt").unwrap();
1210 assert_eq!(cache.put_blob(&root.join("second")).unwrap(), first);
1211 assert_eq!(std::fs::read(&blob).unwrap(), b"same");
1212 {
1214 use std::os::unix::fs::PermissionsExt;
1215 std::fs::set_permissions(&blob, std::fs::Permissions::from_mode(0o000)).unwrap();
1216 }
1217 assert_eq!(cache.put_blob(&root.join("second")).unwrap(), first);
1218 assert_eq!(std::fs::read(&blob).unwrap(), b"same");
1219 let other = cache.put_blob(&root.join("other")).unwrap();
1220 assert_ne!(other, first);
1221 assert_eq!(
1222 std::fs::read(cache.root.join("blobs").join(other)).unwrap(),
1223 b"different"
1224 );
1225 }
1226
1227 #[test]
1230 fn complete_directory_restores_preserve_outputs() {
1231 let workspace = tempfile::tempdir().unwrap();
1232 let root = workspace.path();
1233 std::fs::create_dir_all(root.join("dist/nested/empty")).unwrap();
1234 std::fs::write(root.join("dist/nested/a.txt"), "a").unwrap();
1235 std::fs::write(root.join("dist/b.bin"), [0, 1, 2]).unwrap();
1236 std::fs::create_dir_all(root.join("bundle/empty")).unwrap();
1237 std::fs::write(root.join("bundle/readonly"), "readonly").unwrap();
1238 #[cfg(unix)]
1239 {
1240 use std::os::unix::fs::PermissionsExt;
1241 let script = root.join("dist/run.sh");
1242 std::fs::write(&script, "#!/bin/sh\n").unwrap();
1243 std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
1244 std::os::unix::fs::symlink("nested/a.txt", root.join("dist/link")).unwrap();
1245 std::fs::set_permissions(
1246 root.join("dist/nested"),
1247 std::fs::Permissions::from_mode(0o500),
1248 )
1249 .unwrap();
1250 std::fs::set_permissions(
1251 root.join("bundle/readonly"),
1252 std::fs::Permissions::from_mode(0o444),
1253 )
1254 .unwrap();
1255 }
1256 let log = root.join("log");
1257 std::fs::write(&log, "").unwrap();
1258
1259 let cache = Cache::new(root.join(".qk/cache"));
1260 cache.initialize().unwrap();
1261 let outputs = paths::Outputs::from_patterns(&["dist", "bundle"]);
1262 let key = "0".repeat(64);
1263 cache.publish(root, &key, &outputs, &log).unwrap();
1264
1265 #[cfg(unix)]
1266 {
1267 use std::os::unix::fs::PermissionsExt;
1268 std::fs::set_permissions(
1271 root.join("dist/nested"),
1272 std::fs::Permissions::from_mode(0o755),
1273 )
1274 .unwrap();
1275 }
1276 std::fs::remove_dir_all(root.join("dist")).unwrap();
1277 std::fs::remove_dir_all(root.join("bundle")).unwrap();
1278 let before = std::time::SystemTime::now();
1279 let restored = cache
1280 .restore(
1281 root,
1282 "app:build",
1283 &Default::default(),
1284 &key,
1285 &outputs,
1286 &qk_executor::Display::Stream,
1287 qk_executor::Shown::LocalCache,
1288 )
1289 .unwrap();
1290 assert!(restored);
1291 assert!(root.join("dist/nested/empty").is_dir());
1292 assert!(root.join("bundle/empty").is_dir());
1293 assert_eq!(
1294 std::fs::read_to_string(root.join("bundle/readonly")).unwrap(),
1295 "readonly"
1296 );
1297 #[cfg(unix)]
1298 {
1299 assert_eq!(
1300 std::fs::read_link(root.join("dist/link")).unwrap(),
1301 std::path::Path::new("nested/a.txt")
1302 );
1303 assert_eq!(
1304 store::mode(&std::fs::metadata(root.join("dist/run.sh")).unwrap()),
1305 0o755
1306 );
1307 assert_eq!(
1308 store::mode(&std::fs::metadata(root.join("dist/nested")).unwrap()),
1309 0o500
1310 );
1311 assert_eq!(
1312 store::mode(&std::fs::metadata(root.join("bundle/readonly")).unwrap()),
1313 0o444
1314 );
1315 }
1316 let path = root.join("dist/nested/a.txt");
1317 let modified = std::fs::metadata(&path).unwrap().modified().unwrap();
1318 assert!(modified >= before);
1319 let kept = cache
1320 .restore(
1321 root,
1322 "app:build",
1323 &Default::default(),
1324 &key,
1325 &outputs,
1326 &qk_executor::Display::Hidden,
1327 qk_executor::Shown::LocalCache,
1328 )
1329 .unwrap();
1330 assert!(kept);
1331 assert_eq!(
1332 std::fs::metadata(&path).unwrap().modified().unwrap(),
1333 modified
1334 );
1335 #[cfg(unix)]
1336 {
1337 use std::os::unix::fs::PermissionsExt;
1338 std::fs::set_permissions(
1339 root.join("dist/nested"),
1340 std::fs::Permissions::from_mode(0o755),
1341 )
1342 .unwrap();
1343 }
1344 }
1345
1346 #[test]
1347 fn listings_leave_out_other_targets_outputs_but_the_upstreams() {
1348 let temp = tempfile::tempdir().unwrap();
1349 let root = temp.path();
1350 std::fs::write(root.join("nx.json"), "{}").unwrap();
1351 std::fs::create_dir_all(root.join("app")).unwrap();
1352 std::fs::create_dir_all(root.join("lib")).unwrap();
1353 std::fs::write(
1354 root.join("app/project.json"),
1355 r#"{"name":"app","targets":{
1356 "build":{"command":"true","outputs":["{options.outputPath}"],
1357 "options":{"outputPath":"app/dist"},
1358 "configurations":{"production":{"outputPath":"app/dist-production"}}},
1359 "lint":{"command":"true"},
1360 "test":{"command":"true","dependsOn":[{"projects":["lib"],"target":"build"}],
1361 "configurations":{"production":{}}}}}"#,
1362 )
1363 .unwrap();
1364 std::fs::write(
1365 root.join("lib/project.json"),
1366 r#"{"name":"lib","targets":{"build":{"command":"true","outputs":["{options.outputPath}"],
1367 "options":{"outputPath":"lib/dist"},
1368 "configurations":{"production":{"outputPath":"lib/dist-production"}}}}}"#,
1369 )
1370 .unwrap();
1371 let workspace = Workspace::load(root).unwrap();
1372 let graph = qk_taskgraph::TaskGraph::build(
1373 &workspace,
1374 &[
1375 qk_taskgraph::Request::parse("app:lint").unwrap(),
1376 qk_taskgraph::Request::parse("app:test").unwrap(),
1377 qk_taskgraph::Request::parse("app:test:production").unwrap(),
1378 ],
1379 )
1380 .unwrap();
1381 let outputs = workspace_outputs(&workspace);
1382 let others = |task: &str| recorded::Others {
1383 outputs: &outputs,
1384 upstream: upstream(&graph, &graph.tasks[task]),
1385 };
1386 let lint = others("app:lint");
1387 assert!(lint.matches("app/dist/main.js"));
1388 assert!(lint.matches("app/dist-production/main.js"));
1389 assert!(lint.matches("lib/dist"));
1390 assert!(!lint.matches("app/src/main.ts"));
1391 assert!(!others("app:test").matches("lib/dist"));
1394 assert!(others("app:test").matches("lib/dist-production"));
1395 let production = others("app:test:production");
1396 assert!(!production.matches("lib/dist-production"));
1397 assert!(production.matches("lib/dist"));
1398 }
1399}