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