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