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 inputs = &keyed.as_ref().expect("a cacheable task is keyed").1;
459 let mut recordings = settings.and_then(|settings| {
460 let weak = hash::weak_key(
461 snapshot,
462 workspace,
463 graph,
464 task,
465 prepared,
466 inputs,
467 &settings.ignore_reads,
468 cancelled,
469 );
470 match weak {
471 Ok(weak) => Some((recorded::load(&self.root, &weak), weak, settings)),
472 Err(error) => {
473 qk_executor::status!(
474 "qk: {}: not keyed by its recordings ({error:#})",
475 task.id
476 );
477 None
478 }
479 }
480 });
481 let mut shadow = None;
482 if let Some((sets, weak, settings)) = &mut recordings {
483 let recorded_profile = profile::span(&task.id, "recorded_restore");
484 let keyed_by = settings.mode == ultracache::Mode::On;
485 let found = self.by_recordings(
486 workspace, task, prepared, snapshot, &outputs, &own, sets, weak, &key, keyed_by,
487 cancelled,
488 );
489 drop(recorded_profile);
490 match (keyed_by, found) {
491 (true, found @ (Found::Local(_) | Found::Remote(_))) => {
492 let (strong, remote) = match found {
493 Found::Local(strong) => {
494 if let Some(remote) = &self.remote {
495 remote.held(&strong);
496 }
497 (strong, None)
498 }
499 Found::Remote(strong) => (strong, Some(())),
500 Found::Nothing => unreachable!(),
501 };
502 recorded::touch(&self.root, weak);
503 qk_executor::report::event(qk_executor::report::Event::Cache {
504 id: task.id.clone(),
505 cached: if remote.is_some() {
506 qk_executor::report::Cached::RemoteHit
507 } else {
508 qk_executor::report::Cached::Hit
509 },
510 });
511 return Ok(TaskResult {
512 outcome: Outcome::Success,
513 cache: if remote.is_some() {
514 CacheStatus::RemoteHit
515 } else {
516 CacheStatus::LocalHit
517 },
518 key: keyed,
519 warm: None,
520 execution: None,
521 recorded: Some(Recorded::Hit { key: strong }),
522 shadow: None,
523 });
524 }
525 (true, Found::Nothing) => {}
526 (false, Found::Local(strong) | Found::Remote(strong)) => {
527 shadow = Some(Shadow::WouldHit { key: strong });
528 }
529 (false, Found::Nothing) if sets.is_empty() => shadow = Some(Shadow::Unrecorded),
530 (false, Found::Nothing) => shadow = Some(Shadow::WouldMiss),
531 }
532 }
533 let declared = recordings.as_ref().is_none_or(|(sets, _, settings)| {
537 sets.is_empty() || settings.mode == ultracache::Mode::Record
538 });
539 let unrecorded = recordings
541 .as_ref()
542 .filter(|(_, _, settings)| settings.mode == ultracache::Mode::On)
543 .map(|_| Recorded::Unrecorded);
544 let prefetched = declared
547 && self.remote.as_ref().is_some_and(|remote| {
548 remote.resolved(&key);
549 remote.prefetched(&key)
550 });
551 let local_profile = profile::span(&task.id, "local_restore");
552 let restored = if declared {
553 self.restore(
554 &workspace.root,
555 &task.id,
556 &task.dependencies,
557 &key,
558 &outputs,
559 &prepared.display,
560 if prefetched {
561 qk_executor::Shown::RemoteCache
562 } else {
563 qk_executor::Shown::LocalCache
564 },
565 )
566 } else {
567 Ok(false)
568 };
569 match restored {
570 Ok(true) => {
571 let claimed = match &self.remote {
572 Some(remote) if prefetched && remote.claim(&key) => true,
573 Some(remote) => {
574 remote.held(&key);
575 false
576 }
577 None => false,
578 };
579 qk_executor::report::event(qk_executor::report::Event::Cache {
580 id: task.id.clone(),
581 cached: if claimed {
582 qk_executor::report::Cached::RemoteHit
583 } else {
584 qk_executor::report::Cached::Hit
585 },
586 });
587 return Ok(TaskResult {
588 outcome: Outcome::Success,
589 cache: if claimed {
590 CacheStatus::RemoteHit
591 } else {
592 CacheStatus::LocalHit
593 },
594 key: keyed,
595 warm: None,
596 execution: None,
597 recorded: unrecorded,
598 shadow,
599 });
600 }
601 Ok(false) => {}
602 Err(error) => {
603 qk_executor::status!("qk: {}: ignoring unusable cache entry ({error})", task.id)
604 }
605 }
606 drop(local_profile);
607 if let Some(remote) = self.remote.as_ref().filter(|_| declared) {
608 let fetch_profile = profile::span(&task.id, "remote_fetch");
609 let fetched = remote.fetch(&self.root, &key, &task.id, cancelled);
610 drop(fetch_profile);
611 let fetched = fetched.and_then(|found| {
612 if !found {
613 return Ok(false);
614 }
615 self.restore(
616 &workspace.root,
617 &task.id,
618 &task.dependencies,
619 &key,
620 &outputs,
621 &prepared.display,
622 qk_executor::Shown::RemoteCache,
623 )
624 });
625 match fetched {
626 Ok(true) => {
627 qk_executor::report::event(qk_executor::report::Event::Cache {
628 id: task.id.clone(),
629 cached: qk_executor::report::Cached::RemoteHit,
630 });
631 return Ok(TaskResult {
632 outcome: Outcome::Success,
633 cache: CacheStatus::RemoteHit,
634 key: keyed,
635 warm: None,
636 execution: None,
637 recorded: unrecorded,
638 shadow,
639 });
640 }
641 Ok(false) => {}
642 Err(error) => {
643 qk_executor::status!("qk: {}: remote cache unavailable ({error:#})", task.id)
644 }
645 }
646 }
647 qk_executor::report::event(qk_executor::report::Event::Cache {
648 id: task.id.clone(),
649 cached: qk_executor::report::Cached::Miss,
650 });
651 let log = match tempfile::NamedTempFile::new_in(self.root.join("tmp")) {
652 Ok(log) => log,
653 Err(error) => return bypass(format!("cache log unavailable: {error}")),
654 };
655 let capture =
656 Capture::new(Some(log.as_file().try_clone()?), prepared.display.clone()).retain_log();
657 let restored = before(running);
658 let started = recorded::now(&self.root.join("tmp"));
659 let executing = profile::span(&task.id, "execute");
660 let outcome = execute_captured(running, cancelled, Some(&capture))?;
661 drop(executing);
662 let warm_report = after(outcome, restored, running);
663 let inputs_unchanged =
664 self.inputs_match(snapshot, workspace, graph, task, prepared, &key, cancelled);
665 let execution = capture.take_log().map(|log| Execution {
666 log,
667 inputs_unchanged,
668 });
669 let mut recorded = unrecorded.map(|unrecorded| match &recordings {
670 Some((sets, _, _)) if !sets.is_empty() => Recorded::Missed,
671 _ => unrecorded,
672 });
673 let path_set = match (&recordings, &prepared.recording) {
674 (Some((_, _, settings)), Some(recording)) => Some(
675 self.path_set(workspace, recording, prepared, settings, &own)
676 .and_then(|set| {
677 if set.is_empty() {
679 return Err("its trace named no path in the workspace".to_owned());
680 }
681 match set.changed_since(&workspace.root, started, &own) {
682 Some(path) => Err(format!("{path} changed while it ran")),
683 None => Ok(set),
684 }
685 }),
686 ),
687 _ => None,
688 };
689 if let Some(Err(reason)) = &path_set {
690 qk_executor::status!("qk: {}: not recorded ({reason})", task.id);
691 recorded = Some(Recorded::Failed {
692 reason: reason.clone(),
693 });
694 }
695 if outcome == Outcome::Success && inputs_unchanged && capture.healthy() {
696 let publishing = profile::span(&task.id, "publish");
697 let published = self.publish(&workspace.root, &key, &outputs, log.path());
698 drop(publishing);
699 match published {
700 Ok(()) => {
701 store::record_outputs(&workspace.root, &task.id, &key, &outputs);
702 if let Some(remote) = &self.remote {
703 remote.upload(&self.root, &key);
704 }
705 }
706 Err(error) => {
707 qk_executor::status!("qk: {}: could not save cache entry ({error})", task.id);
708 }
709 }
710 if let (Some((_, weak, _)), Some(Ok(set))) = (&recordings, path_set) {
712 let saved = recorded::strong_key(weak, &set, snapshot, &workspace.root, &own)
713 .and_then(|strong| {
714 self.publish(&workspace.root, &strong, &outputs, log.path())?;
715 recorded::save(&self.root, weak, set)?;
716 if let Some(remote) = &self.remote {
719 remote.upload(&self.root, &strong);
720 if let Some(record) = recorded::record(&self.root, weak) {
721 remote.upload_path_sets(&self.root, weak, record);
722 }
723 }
724 Ok(strong)
725 });
726 recorded = Some(match saved {
727 Ok(key) => Recorded::Saved { key },
728 Err(error) => {
729 qk_executor::status!("qk: {}: recording not saved ({error:#})", task.id);
730 Recorded::Failed {
731 reason: format!("{error:#}"),
732 }
733 }
734 });
735 }
736 }
737 Ok(TaskResult {
738 outcome,
739 cache: CacheStatus::Miss,
740 key: keyed,
741 warm: warm_report,
742 execution,
743 recorded,
744 shadow,
745 })
746 }
747
748 #[allow(clippy::too_many_arguments)]
754 fn by_recordings(
755 &self,
756 workspace: &Workspace,
757 task: &Task,
758 prepared: &PreparedTask,
759 snapshot: &hash::Snapshot,
760 outputs: &paths::Outputs,
761 own: &[&paths::Outputs],
762 sets: &mut Vec<recorded::PathSet>,
763 weak: &str,
764 declared: &str,
765 restore: bool,
766 cancelled: &AtomicBool,
767 ) -> Found {
768 let strong = |set: &recorded::PathSet| {
769 recorded::strong_key(weak, set, snapshot, &workspace.root, own)
770 .inspect_err(|error| {
771 qk_executor::status!("qk: {}: ignoring a path set ({error:#})", task.id);
772 })
773 .ok()
774 };
775 let restored = |strong: &str, shown| {
776 self.restore(
777 &workspace.root,
778 &task.id,
779 &task.dependencies,
780 strong,
781 outputs,
782 &prepared.display,
783 shown,
784 )
785 .unwrap_or_else(|error| {
786 qk_executor::status!("qk: {}: ignoring unusable cache entry ({error})", task.id);
787 false
788 })
789 };
790 let local = |strong: &str| {
791 if restore {
792 restored(strong, qk_executor::Shown::LocalCache)
793 } else {
794 self.root
795 .join("entries")
796 .join(format!("{strong}.json"))
797 .is_file()
798 }
799 };
800 let mut keys = Vec::new();
801 for set in sets.iter() {
802 if let Some(strong) = strong(set) {
803 if local(&strong) {
804 return Found::Local(strong);
805 }
806 keys.push(strong);
807 }
808 }
809 let Some(remote) = &self.remote else {
810 return Found::Nothing;
811 };
812 if restore
817 && sets.is_empty()
818 && self
819 .root
820 .join("entries")
821 .join(format!("{declared}.json"))
822 .is_file()
823 {
824 return Found::Nothing;
825 }
826 let fetched = remote
828 .fetch_path_sets(&self.root, weak, &task.id)
829 .and_then(|record| match record {
830 Some(record) => recorded::merge(&self.root, weak, &record),
831 None => Ok(Vec::new()),
832 });
833 match fetched {
834 Ok(added) => {
835 for set in added {
836 if let Some(strong) = strong(&set) {
837 if local(&strong) {
838 sets.push(set);
839 return Found::Local(strong);
840 }
841 keys.push(strong);
842 }
843 sets.push(set);
844 }
845 }
846 Err(error) => {
847 qk_executor::status!("qk: {}: remote path sets unavailable ({error:#})", task.id)
848 }
849 }
850 for strong in keys {
851 let found = if restore {
852 remote
853 .fetch(&self.root, &strong, &task.id, cancelled)
854 .unwrap_or_else(|error| {
855 qk_executor::status!(
856 "qk: {}: remote cache unavailable ({error:#})",
857 task.id
858 );
859 false
860 })
861 && restored(&strong, qk_executor::Shown::RemoteCache)
862 } else {
863 remote.holds(&strong).unwrap_or(false)
864 };
865 if found {
866 return Found::Remote(strong);
867 }
868 }
869 Found::Nothing
870 }
871
872 fn path_set(
874 &self,
875 workspace: &Workspace,
876 recording: &qk_executor::Recording,
877 prepared: &PreparedTask,
878 settings: &ultracache::Settings,
879 own: &[&paths::Outputs],
880 ) -> std::result::Result<recorded::PathSet, String> {
881 let failed = |error: anyhow::Error| format!("{error:#}");
882 let canonical = workspace
883 .root
884 .canonicalize()
885 .map_err(|e| failed(e.into()))?;
886 let keep = |path: &std::path::Path| {
889 [&workspace.root, &canonical].iter().any(|root| {
890 path.strip_prefix(root).is_ok_and(|relative| {
891 !relative.components().any(|component| {
892 matches!(
893 component.as_os_str().to_str(),
894 Some("node_modules" | ".git")
895 )
896 })
897 })
898 })
899 };
900 let limits = qk_input_analysis::strace::Limits {
901 records: 1_000_000,
902 bytes: 256 * 1024 * 1024,
903 accesses: 500_000,
904 keep: Some(&keep),
905 };
906 let (accesses, diagnostics) =
907 qk_input_analysis::strace::collect_within(&recording.directory, &prepared.cwd, &limits)
908 .map_err(failed)?;
909 if let Some(diagnostic) = diagnostics.first() {
911 return Err(diagnostic.clone());
912 }
913 let ignore = settings
914 .ignore_reads
915 .iter()
916 .map(|glob| Pattern::new(glob, false))
917 .collect::<Result<Vec<_>>>()
918 .map_err(failed)?;
919 let state = [self.root.clone(), worktree_state(&workspace.root)]
921 .iter()
922 .filter_map(|path| {
923 [&workspace.root, &canonical]
924 .iter()
925 .find_map(|root| path.strip_prefix(root).ok())
926 .and_then(|relative| relative.to_str())
927 .map(|relative| relative.replace('\\', "/"))
928 })
929 .collect();
930 let exclusions = recorded::Exclusions {
931 roots: [&workspace.root, &canonical],
932 outputs: own,
933 ignore,
934 state,
935 };
936 Ok(recorded::PathSet::from_accesses(
937 &accesses,
938 &exclusions,
939 &canonical,
940 ))
941 }
942
943 #[allow(clippy::too_many_arguments)]
945 fn inputs_match(
946 &self,
947 snapshot: &hash::Snapshot,
948 workspace: &Workspace,
949 graph: &TaskGraph,
950 task: &Task,
951 prepared: &PreparedTask,
952 before: &str,
953 cancelled: &AtomicBool,
954 ) -> bool {
955 if cancelled.load(Ordering::SeqCst) {
956 return false;
957 }
958 let _profile = profile::span(&task.id, "inputs_recheck");
959 let after = hash::recheck_dependency_keys(snapshot, workspace, graph, task, cancelled)
960 .and_then(|dependencies| {
961 hash::fingerprint(
962 snapshot,
963 workspace,
964 task,
965 prepared,
966 &dependencies,
967 cancelled,
968 )
969 });
970 if after.as_deref().ok() != Some(before) {
971 if task.definition.cache == Some(true) {
972 if self.reuse {
973 qk_executor::status!(
974 "qk: {}: not caching because inputs changed during execution",
975 task.id
976 );
977 } else {
978 qk_executor::status!(
979 "qk: {}: excluding execution from flaky results because inputs changed during execution",
980 task.id
981 );
982 }
983 }
984 return false;
985 }
986 true
987 }
988}
989
990pub type Keyed = std::result::Result<(String, Value), String>;
992
993pub fn keys(
997 workspace: &Workspace,
998 graph: &TaskGraph,
999 prepared: &BTreeMap<String, PreparedTask>,
1000 cancelled: &AtomicBool,
1001) -> Result<BTreeMap<String, Keyed>> {
1002 let snapshot = hash::Snapshot::new(workspace, graph, &paths::cache_location(workspace))?;
1003 Ok(graph
1004 .tasks
1005 .values()
1006 .map(|task| {
1007 let keyed = key_now(&snapshot, workspace, graph, task, prepared, cancelled);
1008 (task.id.clone(), keyed)
1009 })
1010 .collect())
1011}
1012
1013fn key_now(
1014 snapshot: &hash::Snapshot,
1015 workspace: &Workspace,
1016 graph: &TaskGraph,
1017 task: &Task,
1018 prepared: &BTreeMap<String, PreparedTask>,
1019 cancelled: &AtomicBool,
1020) -> Keyed {
1021 let failed = |error: anyhow::Error| format!("{}: {error:#}", task.id);
1022 let dependencies =
1023 hash::dependency_keys(snapshot, workspace, graph, task, cancelled).map_err(failed)?;
1024 let inputs = hash::inputs(
1025 snapshot,
1026 workspace,
1027 task,
1028 &prepared[&task.id],
1029 &dependencies,
1030 cancelled,
1031 )
1032 .map_err(failed)?;
1033 Ok((hash::key(&inputs).map_err(failed)?, inputs))
1034}
1035
1036#[doc(hidden)]
1038pub fn run_upload_worker() -> anyhow::Result<()> {
1039 remote::background::run()
1040}
1041
1042#[cfg(test)]
1043mod tests {
1044 use super::*;
1045
1046 #[test]
1047 fn concurrent_blob_writes_keep_results_ordered_and_repair_corruption() {
1048 let workspace = tempfile::tempdir().unwrap();
1049 let root = workspace.path();
1050 let cache = Cache::new(root.join("cache"));
1051 cache.initialize().unwrap();
1052 let mut sources = Vec::new();
1053 for index in 0..130 {
1054 let source = root.join(format!("source-{index}"));
1055 std::fs::write(&source, format!("content-{}", index % 65)).unwrap();
1056 sources.push(source);
1057 }
1058 let digest = cache.put_blob(&sources[0]).unwrap();
1059 std::fs::write(cache.root.join("blobs").join(&digest), "corrupt").unwrap();
1060 std::fs::remove_file(&sources[31]).unwrap();
1061 let results = cache.put_blobs(&sources);
1062 assert_eq!(results.len(), sources.len());
1063 for (index, result) in results.into_iter().enumerate() {
1064 if index == 31 {
1065 assert_eq!(
1066 result
1067 .unwrap_err()
1068 .downcast_ref::<std::io::Error>()
1069 .unwrap()
1070 .kind(),
1071 std::io::ErrorKind::NotFound
1072 );
1073 continue;
1074 }
1075 let digest = result.unwrap();
1076 let held = cache.root.join("blobs").join(&digest);
1077 assert_eq!(
1078 std::fs::read(&held).unwrap(),
1079 format!("content-{}", index % 65).as_bytes()
1080 );
1081 assert_eq!(hash::digest_file(&held).unwrap(), digest);
1082 }
1083 }
1084
1085 #[cfg(unix)]
1087 #[test]
1088 fn a_blob_the_store_holds_is_not_written_again() {
1089 use std::os::unix::fs::MetadataExt;
1090 let workspace = tempfile::tempdir().unwrap();
1091 let root = workspace.path();
1092 let cache = Cache::new(root.join(".qk/cache"));
1093 cache.initialize().unwrap();
1094 std::fs::write(root.join("first"), "same").unwrap();
1095 std::fs::write(root.join("second"), "same").unwrap();
1096 std::fs::write(root.join("other"), "different").unwrap();
1097 let first = cache.put_blob(&root.join("first")).unwrap();
1098 let blob = cache.root.join("blobs").join(&first);
1099 let stored = std::fs::metadata(&blob).unwrap().ino();
1100 assert_eq!(cache.put_blob(&root.join("second")).unwrap(), first);
1101 assert_eq!(std::fs::metadata(&blob).unwrap().ino(), stored);
1102 std::fs::write(&blob, "corrupt").unwrap();
1104 assert_eq!(cache.put_blob(&root.join("second")).unwrap(), first);
1105 assert_eq!(std::fs::read(&blob).unwrap(), b"same");
1106 {
1108 use std::os::unix::fs::PermissionsExt;
1109 std::fs::set_permissions(&blob, std::fs::Permissions::from_mode(0o000)).unwrap();
1110 }
1111 assert_eq!(cache.put_blob(&root.join("second")).unwrap(), first);
1112 assert_eq!(std::fs::read(&blob).unwrap(), b"same");
1113 let other = cache.put_blob(&root.join("other")).unwrap();
1114 assert_ne!(other, first);
1115 assert_eq!(
1116 std::fs::read(cache.root.join("blobs").join(other)).unwrap(),
1117 b"different"
1118 );
1119 }
1120
1121 #[test]
1124 fn complete_directory_restores_preserve_outputs() {
1125 let workspace = tempfile::tempdir().unwrap();
1126 let root = workspace.path();
1127 std::fs::create_dir_all(root.join("dist/nested/empty")).unwrap();
1128 std::fs::write(root.join("dist/nested/a.txt"), "a").unwrap();
1129 std::fs::write(root.join("dist/b.bin"), [0, 1, 2]).unwrap();
1130 std::fs::create_dir_all(root.join("bundle/empty")).unwrap();
1131 std::fs::write(root.join("bundle/readonly"), "readonly").unwrap();
1132 #[cfg(unix)]
1133 {
1134 use std::os::unix::fs::PermissionsExt;
1135 let script = root.join("dist/run.sh");
1136 std::fs::write(&script, "#!/bin/sh\n").unwrap();
1137 std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
1138 std::os::unix::fs::symlink("nested/a.txt", root.join("dist/link")).unwrap();
1139 std::fs::set_permissions(
1140 root.join("dist/nested"),
1141 std::fs::Permissions::from_mode(0o500),
1142 )
1143 .unwrap();
1144 std::fs::set_permissions(
1145 root.join("bundle/readonly"),
1146 std::fs::Permissions::from_mode(0o444),
1147 )
1148 .unwrap();
1149 }
1150 let log = root.join("log");
1151 std::fs::write(&log, "").unwrap();
1152
1153 let cache = Cache::new(root.join(".qk/cache"));
1154 cache.initialize().unwrap();
1155 let outputs = paths::Outputs::from_patterns(&["dist", "bundle"]);
1156 let key = "0".repeat(64);
1157 cache.publish(root, &key, &outputs, &log).unwrap();
1158
1159 #[cfg(unix)]
1160 {
1161 use std::os::unix::fs::PermissionsExt;
1162 std::fs::set_permissions(
1165 root.join("dist/nested"),
1166 std::fs::Permissions::from_mode(0o755),
1167 )
1168 .unwrap();
1169 }
1170 std::fs::remove_dir_all(root.join("dist")).unwrap();
1171 std::fs::remove_dir_all(root.join("bundle")).unwrap();
1172 let before = std::time::SystemTime::now();
1173 let restored = cache
1174 .restore(
1175 root,
1176 "app:build",
1177 &Default::default(),
1178 &key,
1179 &outputs,
1180 &qk_executor::Display::Stream,
1181 qk_executor::Shown::LocalCache,
1182 )
1183 .unwrap();
1184 assert!(restored);
1185 assert!(root.join("dist/nested/empty").is_dir());
1186 assert!(root.join("bundle/empty").is_dir());
1187 assert_eq!(
1188 std::fs::read_to_string(root.join("bundle/readonly")).unwrap(),
1189 "readonly"
1190 );
1191 #[cfg(unix)]
1192 {
1193 assert_eq!(
1194 std::fs::read_link(root.join("dist/link")).unwrap(),
1195 std::path::Path::new("nested/a.txt")
1196 );
1197 assert_eq!(
1198 store::mode(&std::fs::metadata(root.join("dist/run.sh")).unwrap()),
1199 0o755
1200 );
1201 assert_eq!(
1202 store::mode(&std::fs::metadata(root.join("dist/nested")).unwrap()),
1203 0o500
1204 );
1205 assert_eq!(
1206 store::mode(&std::fs::metadata(root.join("bundle/readonly")).unwrap()),
1207 0o444
1208 );
1209 }
1210 let path = root.join("dist/nested/a.txt");
1211 let modified = std::fs::metadata(&path).unwrap().modified().unwrap();
1212 assert!(modified >= before);
1213 let kept = cache
1214 .restore(
1215 root,
1216 "app:build",
1217 &Default::default(),
1218 &key,
1219 &outputs,
1220 &qk_executor::Display::Hidden,
1221 qk_executor::Shown::LocalCache,
1222 )
1223 .unwrap();
1224 assert!(kept);
1225 assert_eq!(
1226 std::fs::metadata(&path).unwrap().modified().unwrap(),
1227 modified
1228 );
1229 #[cfg(unix)]
1230 {
1231 use std::os::unix::fs::PermissionsExt;
1232 std::fs::set_permissions(
1233 root.join("dist/nested"),
1234 std::fs::Permissions::from_mode(0o755),
1235 )
1236 .unwrap();
1237 }
1238 }
1239}