1mod evict;
4mod glob;
5mod hash;
6mod inputs;
7mod paths;
8pub mod profile;
9mod record;
10mod remote;
11mod store;
12pub mod warm;
13
14use std::collections::BTreeMap;
15use std::path::{Path, PathBuf};
16use std::sync::OnceLock;
17use std::sync::atomic::{AtomicBool, Ordering};
18
19use anyhow::{Context, Result};
20use qk_config::Workspace;
21use qk_executor::{Capture, Outcome, PreparedTask, execute, execute_captured};
22use qk_taskgraph::{Task, TaskGraph};
23use serde_json::{Value, json};
24
25pub use evict::{Pruned, max_size, parse_size, prune, prune_automatic};
26pub use glob::Pattern;
27pub use hash::{SourceIgnore, source_files};
28pub use inputs::{
29 InputChange, InputChanges, Resolution, Resolved, manifest_for_dependents, resolve_each,
30 resolve_tasks, without_resolution,
31};
32pub use paths::expand as expand_project_path;
34pub use paths::{
35 Outputs, cache_directory, cache_location, clear_worktree_state, resolved_outputs,
36 worktree_state,
37};
38
39pub struct Cache {
41 pub root: PathBuf,
42 snapshot: OnceLock<std::result::Result<hash::Snapshot, String>>,
43 remote: Option<std::sync::Arc<remote::Remote>>,
44 reuse: bool,
46 kept: std::sync::Mutex<std::collections::BTreeSet<String>>,
48 saves: std::sync::Mutex<Vec<std::thread::JoinHandle<()>>>,
50 cleanup: std::sync::Mutex<Option<store::Cleanup>>,
51}
52
53pub type Fingerprint = std::result::Result<String, String>;
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 fingerprint: Fingerprint,
77 pub cache: CacheStatus,
78 pub key: Option<(String, Value)>,
80 pub warm: Option<warm::WarmReport>,
82 pub execution: Option<Execution>,
83}
84
85impl TaskResult {
86 pub fn uncached(outcome: Outcome, reason: String) -> Self {
87 Self {
88 outcome,
89 fingerprint: Err(reason),
90 cache: CacheStatus::Uncached,
91 key: None,
92 warm: None,
93 execution: None,
94 }
95 }
96}
97
98impl Cache {
99 pub fn new(root: PathBuf) -> Self {
100 Self {
101 root,
102 snapshot: OnceLock::new(),
103 remote: None,
104 reuse: true,
105 kept: Default::default(),
106 saves: Default::default(),
107 cleanup: Default::default(),
108 }
109 }
110
111 pub fn for_observation(workspace: &Workspace) -> Self {
113 Self {
114 reuse: false,
115 ..Self::new(paths::cache_location(workspace))
116 }
117 }
118
119 pub fn for_workspace(
124 workspace: &Workspace,
125 environment: &BTreeMap<std::ffi::OsString, std::ffi::OsString>,
126 ) -> Self {
127 let root = paths::cache_location(workspace);
128 let remote = match remote::configure(workspace.config.extra.get("s3"), environment) {
129 Ok(remote) => remote.map(std::sync::Arc::new),
130 Err(error) => {
131 qk_executor::status!("qk: remote cache disabled: {error:#}");
132 None
133 }
134 };
135 if let Some(remote) = &remote {
136 remote.read_index(&root);
137 }
138 Self {
139 remote,
140 ..Self::new(root)
141 }
142 }
143
144 pub fn finish(&self, workspace: &Workspace) {
147 if !self.reuse {
148 return;
149 }
150 if let Some(Ok(snapshot)) = self.snapshot.get() {
151 snapshot.save_digests(&workspace.root);
152 }
153 for save in std::mem::take(&mut *self.saves.lock().unwrap()) {
155 let _ = save.join();
156 }
157 if let Some(remote) = &self.remote {
158 let failures = remote.finish();
159 for failure in &failures {
160 qk_executor::status!("qk: remote cache upload failed for {failure}");
161 }
162 }
163 let _profile = profile::span("cache", "restore_cleanup_wait");
164 self.cleanup.lock().unwrap().take();
165 }
166
167 fn snapshot(
168 &self,
169 workspace: &Workspace,
170 graph: &TaskGraph,
171 ) -> std::result::Result<&hash::Snapshot, String> {
172 self.snapshot
173 .get_or_init(|| {
174 hash::Snapshot::new(workspace, graph, &self.root)
175 .map_err(|error| format!("cannot snapshot the workspace: {error:#}"))
176 })
177 .as_ref()
178 .map_err(Clone::clone)
179 }
180
181 pub fn run(
182 &self,
183 workspace: &Workspace,
184 graph: &TaskGraph,
185 task: &Task,
186 prepared: &PreparedTask,
187 dependencies: &BTreeMap<String, Fingerprint>,
188 cancelled: &AtomicBool,
189 ) -> Result<TaskResult> {
190 let result = self.run_task(workspace, graph, task, prepared, dependencies, cancelled);
191 if let Some(Ok(snapshot)) = self.snapshot.get() {
192 snapshot.outputs_written(workspace, task);
193 }
194 result
195 }
196
197 fn run_task(
198 &self,
199 workspace: &Workspace,
200 graph: &TaskGraph,
201 task: &Task,
202 prepared: &PreparedTask,
203 dependencies: &BTreeMap<String, Fingerprint>,
204 cancelled: &AtomicBool,
205 ) -> Result<TaskResult> {
206 let cacheable = task.definition.cache == Some(true);
207 let _kept = self
209 .reuse
210 .then(|| warm::keep_across(workspace, graph, task));
211 let entries = if self.reuse {
212 warm::config(workspace, task).unwrap_or_else(|error| {
213 qk_executor::status!("qk: {}: warm state ignored ({error:#})", task.id);
214 Vec::new()
215 })
216 } else {
217 Vec::new()
218 };
219 let entry_label = |warm: &warm::Warm| match warm.group() {
221 Some(group) => format!(" (group {group})"),
222 None => String::new(),
223 };
224 let mut running = prepared.clone();
227 for warm in &entries {
228 for (name, value) in &warm.env {
229 running.execution.insert(name.into(), value.into());
230 }
231 }
232 let before = |running: &PreparedTask| -> (Vec<warm::Restored>, Vec<String>) {
234 let (mut restored, mut present) = (Vec::new(), Vec::new());
235 for warm in &entries {
236 match self
237 .initialize()
238 .and_then(|()| self.restore_warm(workspace, task, warm, running))
239 {
240 Ok((entry, groups)) => {
241 if !entry.groups.is_empty() {
242 restored.push(entry);
243 }
244 present.extend(groups);
245 }
246 Err(error) => qk_executor::status!(
247 "qk: {}: warm state{} not restored ({error:#})",
248 task.id,
249 entry_label(warm)
250 ),
251 }
252 }
253 (restored, present)
254 };
255 let after = |outcome: Outcome,
256 (restored, present): (Vec<warm::Restored>, Vec<String>),
257 running: &PreparedTask|
258 -> Option<warm::WarmReport> {
259 if entries.is_empty() {
260 return None;
261 }
262 let mut save_ms = None;
263 let mut background = false;
264 for warm in entries.iter().filter(|_| outcome == Outcome::Success) {
265 let started = std::time::Instant::now();
266 let saved = if warm.background {
267 self.save_warm_in_background(workspace, task, warm, running)
268 } else {
269 self.save_warm(workspace, task, warm, running)
270 };
271 match saved {
272 Ok(()) if warm.background => background = true,
273 Ok(()) => {
274 *save_ms.get_or_insert(0) += started.elapsed().as_millis() as u64;
275 }
276 Err(error) => qk_executor::status!(
277 "qk: {}: warm state{} not saved ({error:#})",
278 task.id,
279 entry_label(warm)
280 ),
281 }
282 }
283 Some(warm::WarmReport {
284 restored,
285 present,
286 save_ms,
287 background,
288 })
289 };
290 let fallback = |reason: String| {
291 execute(prepared, cancelled).map(|outcome| TaskResult::uncached(outcome, reason))
292 };
293 let bypass = |reason: String| {
295 if cacheable {
296 qk_executor::status!("qk: {}: cache bypassed ({reason})", task.id);
297 }
298 fallback(format!("{}: {reason}", task.id))
299 };
300 let dependencies = match dependencies
301 .iter()
302 .map(|(id, key)| key.clone().map(|key| (id.clone(), key)))
303 .collect::<std::result::Result<BTreeMap<_, _>, _>>()
304 {
305 Ok(dependencies) => dependencies,
306 Err(root) => {
307 if cacheable {
309 qk_executor::status!("qk: {}: cache bypassed (depends on {root})", task.id);
310 }
311 return fallback(root);
312 }
313 };
314 let snapshot = match self.snapshot(workspace, graph) {
315 Ok(snapshot) => snapshot,
316 Err(reason) => return bypass(reason),
317 };
318 let dependency_profile = profile::span(&task.id, "dependency_inputs");
319 let dependencies =
320 match hash::dependency_keys(snapshot, workspace, graph, task, &dependencies, cancelled)
321 {
322 Ok(dependencies) => dependencies,
323 Err(error) => return bypass(format!("{error:#}")),
324 };
325 drop(dependency_profile);
326 let input_profile = profile::span(&task.id, "inputs");
327 let (key, inputs) = match hash::inputs(
328 snapshot,
329 workspace,
330 task,
331 prepared,
332 &dependencies,
333 cancelled,
334 )
335 .and_then(|inputs| Ok((hash::key(&inputs)?, inputs)))
336 {
337 Ok(key) => key,
338 Err(error) => return bypass(format!("{error:#}")),
339 };
340 drop(input_profile);
341 let keyed = Some((key.clone(), inputs));
342 running
344 .execution
345 .insert("NX_TASK_HASH".into(), key.clone().into());
346 let running = &running;
347 let outputs = match paths::Outputs::new(workspace, task) {
348 Ok(outputs) => outputs,
349 Err(error) => return bypass(format!("{error:#}")),
350 };
351 let depended_on = graph
354 .tasks
355 .values()
356 .any(|other| other.dependencies.contains(&task.id));
357 let fingerprint_outputs = |key: &str| {
358 if depended_on {
359 outputs_fingerprint(task, &workspace.root, &outputs, key)
360 } else {
361 Err(format!("{}: no task depends on it", task.id))
362 }
363 };
364 if !cacheable {
365 let restored = before(running);
366 let outcome = execute(running, cancelled)?;
367 let warm_report = after(outcome, restored, running);
368 let fingerprint = if self.unchanged(
369 snapshot,
370 workspace,
371 graph,
372 task,
373 prepared,
374 &dependencies,
375 &key,
376 outcome,
377 cancelled,
378 ) {
379 fingerprint_outputs(&key)
380 } else {
381 Err(format!("{}: did not complete unchanged", task.id))
382 };
383 return Ok(TaskResult {
384 outcome,
385 fingerprint,
386 cache: CacheStatus::Uncached,
387 key: keyed,
388 warm: warm_report,
389 execution: None,
390 });
391 }
392 if !self.reuse {
393 let capture = Capture::new(None, prepared.display.clone()).retain_log();
394 let outcome = execute_captured(running, cancelled, Some(&capture))?;
395 let inputs_unchanged = self.inputs_match(
396 snapshot,
397 workspace,
398 graph,
399 task,
400 prepared,
401 &dependencies,
402 &key,
403 cancelled,
404 );
405 let fingerprint = if outcome == Outcome::Success && inputs_unchanged {
406 fingerprint_outputs(&key)
407 } else {
408 Err(format!("{}: did not complete unchanged", task.id))
409 };
410 return Ok(TaskResult {
411 outcome,
412 fingerprint,
413 cache: CacheStatus::Uncached,
414 key: keyed,
415 warm: None,
416 execution: capture.take_log().map(|log| Execution {
417 log,
418 inputs_unchanged,
419 }),
420 });
421 }
422 if let Err(error) = self.initialize() {
423 return bypass(format!("cache unavailable: {error}"));
424 }
425 let (_lock, waited) = match self.lock(&key, cancelled) {
426 Ok(Some(lock)) => lock,
427 Ok(None) => {
428 return Ok(TaskResult::uncached(
429 Outcome::Cancelled,
430 format!("{}: cancelled", task.id),
431 ));
432 }
433 Err(error) => return bypass(format!("cache lock unavailable: {error}")),
434 };
435 if waited
437 && hash::recheck_dependency_keys(
438 snapshot,
439 workspace,
440 graph,
441 task,
442 &dependencies,
443 cancelled,
444 )
445 .and_then(|dependencies| {
446 hash::fingerprint(
447 snapshot,
448 workspace,
449 task,
450 prepared,
451 &dependencies,
452 cancelled,
453 )
454 })
455 .ok()
456 .as_ref()
457 != Some(&key)
458 {
459 return bypass("inputs changed while waiting for another run".into());
460 }
461 let local_profile = profile::span(&task.id, "local_restore");
462 match self.restore(
463 &workspace.root,
464 &task.id,
465 &task.dependencies,
466 &key,
467 &outputs,
468 &prepared.display,
469 qk_executor::Shown::LocalCache,
470 ) {
471 Ok(Some(fingerprint)) => {
472 if let Some(remote) = &self.remote {
473 remote.held(&key);
474 }
475 qk_executor::report::event(qk_executor::report::Event::Cache {
476 id: task.id.clone(),
477 cached: qk_executor::report::Cached::Hit,
478 });
479 return Ok(TaskResult {
480 outcome: Outcome::Success,
481 fingerprint: Ok(fingerprint),
482 cache: CacheStatus::LocalHit,
483 key: keyed,
484 warm: None,
485 execution: None,
486 });
487 }
488 Ok(None) => {}
489 Err(error) => {
490 qk_executor::status!("qk: {}: ignoring unusable cache entry ({error})", task.id)
491 }
492 }
493 drop(local_profile);
494 if let Some(remote) = &self.remote {
495 let fetch_profile = profile::span(&task.id, "remote_fetch");
496 let fetched = remote.fetch(&self.root, &key);
497 drop(fetch_profile);
498 let fetched = fetched.and_then(|found| {
499 if !found {
500 return Ok(None);
501 }
502 self.restore(
503 &workspace.root,
504 &task.id,
505 &task.dependencies,
506 &key,
507 &outputs,
508 &prepared.display,
509 qk_executor::Shown::RemoteCache,
510 )
511 });
512 match fetched {
513 Ok(Some(fingerprint)) => {
514 qk_executor::report::event(qk_executor::report::Event::Cache {
515 id: task.id.clone(),
516 cached: qk_executor::report::Cached::RemoteHit,
517 });
518 return Ok(TaskResult {
519 outcome: Outcome::Success,
520 fingerprint: Ok(fingerprint),
521 cache: CacheStatus::RemoteHit,
522 key: keyed,
523 warm: None,
524 execution: None,
525 });
526 }
527 Ok(None) => {}
528 Err(error) => {
529 qk_executor::status!("qk: {}: remote cache unavailable ({error:#})", task.id)
530 }
531 }
532 }
533 qk_executor::report::event(qk_executor::report::Event::Cache {
534 id: task.id.clone(),
535 cached: qk_executor::report::Cached::Miss,
536 });
537 let log = match tempfile::NamedTempFile::new_in(self.root.join("tmp")) {
538 Ok(log) => log,
539 Err(error) => return bypass(format!("cache log unavailable: {error}")),
540 };
541 let capture =
542 Capture::new(Some(log.as_file().try_clone()?), prepared.display.clone()).retain_log();
543 let restored = before(running);
544 let outcome = execute_captured(running, cancelled, Some(&capture))?;
545 let warm_report = after(outcome, restored, running);
546 let inputs_unchanged = self.inputs_match(
547 snapshot,
548 workspace,
549 graph,
550 task,
551 prepared,
552 &dependencies,
553 &key,
554 cancelled,
555 );
556 let execution = capture.take_log().map(|log| Execution {
557 log,
558 inputs_unchanged,
559 });
560 let fingerprint = if outcome != Outcome::Success || !inputs_unchanged {
561 Err(format!("{}: did not complete unchanged", task.id))
562 } else if !capture.healthy() {
563 fingerprint_outputs(&key)
564 } else {
565 match self.publish(&workspace.root, &key, &outputs, log.path()) {
567 Ok(fingerprint) => {
568 store::record_outputs(&workspace.root, &task.id, &key, &outputs);
569 if let Some(remote) = &self.remote {
570 remote.upload(&self.root, &key);
571 }
572 Ok(fingerprint)
573 }
574 Err(error) => {
575 qk_executor::status!("qk: {}: could not save cache entry ({error})", task.id);
576 fingerprint_outputs(&key)
577 }
578 }
579 };
580 Ok(TaskResult {
581 outcome,
582 fingerprint,
583 cache: CacheStatus::Miss,
584 key: keyed,
585 warm: warm_report,
586 execution,
587 })
588 }
589
590 #[allow(clippy::too_many_arguments)]
592 fn unchanged(
593 &self,
594 snapshot: &hash::Snapshot,
595 workspace: &Workspace,
596 graph: &TaskGraph,
597 task: &Task,
598 prepared: &PreparedTask,
599 dependencies: &BTreeMap<String, String>,
600 before: &str,
601 outcome: Outcome,
602 cancelled: &AtomicBool,
603 ) -> bool {
604 if outcome != Outcome::Success || cancelled.load(Ordering::SeqCst) {
605 return false;
606 }
607 self.inputs_match(
608 snapshot,
609 workspace,
610 graph,
611 task,
612 prepared,
613 dependencies,
614 before,
615 cancelled,
616 )
617 }
618
619 #[allow(clippy::too_many_arguments)]
621 fn inputs_match(
622 &self,
623 snapshot: &hash::Snapshot,
624 workspace: &Workspace,
625 graph: &TaskGraph,
626 task: &Task,
627 prepared: &PreparedTask,
628 dependencies: &BTreeMap<String, String>,
629 before: &str,
630 cancelled: &AtomicBool,
631 ) -> bool {
632 if cancelled.load(Ordering::SeqCst) {
633 return false;
634 }
635 let after = hash::recheck_dependency_keys(
636 snapshot,
637 workspace,
638 graph,
639 task,
640 dependencies,
641 cancelled,
642 )
643 .and_then(|dependencies| {
644 hash::fingerprint(
645 snapshot,
646 workspace,
647 task,
648 prepared,
649 &dependencies,
650 cancelled,
651 )
652 });
653 if after.as_deref().ok() != Some(before) {
654 if task.definition.cache == Some(true) {
655 if self.reuse {
656 qk_executor::status!(
657 "qk: {}: not caching because inputs changed during execution",
658 task.id
659 );
660 } else {
661 qk_executor::status!(
662 "qk: {}: excluding execution from flaky results because inputs changed during execution",
663 task.id
664 );
665 }
666 }
667 return false;
668 }
669 true
670 }
671}
672
673pub(crate) fn file_output(content: &str, mode: u32) -> Value {
676 json!({"content": content, "mode": mode})
677}
678
679pub(crate) fn link_output(target: &str) -> Value {
680 json!({"link": target})
681}
682
683pub(crate) fn directory_output() -> Value {
684 json!("directory")
685}
686
687pub(crate) fn combine_outputs(
693 input: &str,
694 files: &BTreeMap<String, Value>,
695 declared: bool,
696) -> Result<String> {
697 let identity = if declared { "declared outputs" } else { input };
698 Ok(blake3::hash(&serde_json::to_vec(&(identity, files))?)
699 .to_hex()
700 .to_string())
701}
702
703fn outputs_fingerprint(
704 task: &Task,
705 root: &Path,
706 outputs: &paths::Outputs,
707 input: &str,
708) -> Fingerprint {
709 output_fingerprint(root, outputs, input)
710 .map_err(|error| format!("{}: cannot fingerprint outputs: {error:#}", task.id))
711}
712
713fn output_fingerprint(root: &Path, outputs: &paths::Outputs, input: &str) -> Result<String> {
714 let mut files = BTreeMap::new();
715 for entry in outputs.entries(root)? {
716 let (path, metadata) = entry?;
717 paths::safe_parents(root, &path)?;
718 let absolute = root.join(&path);
719 let value = if metadata.file_type().is_symlink() {
720 link_output(
721 std::fs::read_link(absolute)?
722 .to_str()
723 .context("symlink target must be UTF-8")?,
724 )
725 } else if metadata.is_file() {
726 file_output(&hash::digest_file(&absolute)?, store::mode(&metadata))
727 } else {
728 directory_output()
729 };
730 files.insert(path, value);
731 }
732 combine_outputs(input, &files, outputs.declared())
733}
734
735pub type Keyed = std::result::Result<(String, Value), String>;
737
738pub fn keys(
743 workspace: &Workspace,
744 graph: &TaskGraph,
745 prepared: &BTreeMap<String, PreparedTask>,
746 cancelled: &AtomicBool,
747) -> Result<BTreeMap<String, Keyed>> {
748 let snapshot = hash::Snapshot::new(workspace, graph, &paths::cache_location(workspace))?;
749 let mut keys = BTreeMap::new();
750 let mut fingerprints: BTreeMap<String, Fingerprint> = BTreeMap::new();
751 let mut pending: Vec<&Task> = graph.tasks.values().collect();
752 while !pending.is_empty() {
754 let waiting = pending.len();
755 pending.retain(|task| {
756 if !task
757 .dependencies
758 .iter()
759 .all(|id| fingerprints.contains_key(id))
760 {
761 return true;
762 }
763 let keyed = key_now(
764 &snapshot,
765 workspace,
766 graph,
767 task,
768 prepared,
769 &fingerprints,
770 cancelled,
771 );
772 let fingerprint = if task.definition.continuous == Some(true) {
773 Err(format!(
774 "{}: continuous tasks are not fingerprinted",
775 task.id
776 ))
777 } else if !prepared[&task.id].ready_when.is_empty() {
778 Err(format!(
779 "{}: tasks with readyWhen are not fingerprinted",
780 task.id
781 ))
782 } else {
783 keyed.clone().and_then(|(key, _)| {
784 let outputs = paths::Outputs::new(workspace, task)
785 .map_err(|error| format!("{}: {error:#}", task.id))?;
786 outputs_fingerprint(task, &workspace.root, &outputs, &key)
787 })
788 };
789 fingerprints.insert(task.id.clone(), fingerprint);
790 keys.insert(task.id.clone(), keyed);
791 false
792 });
793 anyhow::ensure!(pending.len() < waiting, "the task graph has a cycle");
794 }
795 Ok(keys)
796}
797
798fn key_now(
799 snapshot: &hash::Snapshot,
800 workspace: &Workspace,
801 graph: &TaskGraph,
802 task: &Task,
803 prepared: &BTreeMap<String, PreparedTask>,
804 fingerprints: &BTreeMap<String, Fingerprint>,
805 cancelled: &AtomicBool,
806) -> Keyed {
807 let dependencies = task
808 .dependencies
809 .iter()
810 .map(|id| fingerprints[id].clone().map(|key| (id.clone(), key)))
811 .collect::<std::result::Result<BTreeMap<_, _>, _>>()?;
812 let failed = |error: anyhow::Error| format!("{}: {error:#}", task.id);
813 let dependencies =
814 hash::dependency_keys(snapshot, workspace, graph, task, &dependencies, cancelled)
815 .map_err(failed)?;
816 let inputs = hash::inputs(
817 snapshot,
818 workspace,
819 task,
820 &prepared[&task.id],
821 &dependencies,
822 cancelled,
823 )
824 .map_err(failed)?;
825 Ok((hash::key(&inputs).map_err(failed)?, inputs))
826}
827
828#[doc(hidden)]
830pub fn run_upload_worker() -> anyhow::Result<()> {
831 remote::background::run()
832}
833
834#[cfg(test)]
835mod tests {
836 use super::*;
837
838 #[test]
839 fn concurrent_blob_writes_keep_results_ordered_and_repair_corruption() {
840 let workspace = tempfile::tempdir().unwrap();
841 let root = workspace.path();
842 let cache = Cache::new(root.join("cache"));
843 cache.initialize().unwrap();
844 let mut sources = Vec::new();
845 for index in 0..130 {
846 let source = root.join(format!("source-{index}"));
847 std::fs::write(&source, format!("content-{}", index % 65)).unwrap();
848 sources.push(source);
849 }
850 let digest = cache.put_blob(&sources[0]).unwrap();
851 std::fs::write(cache.root.join("blobs").join(&digest), "corrupt").unwrap();
852 std::fs::remove_file(&sources[31]).unwrap();
853 let results = cache.put_blobs(&sources);
854 assert_eq!(results.len(), sources.len());
855 for (index, result) in results.into_iter().enumerate() {
856 if index == 31 {
857 assert_eq!(
858 result
859 .unwrap_err()
860 .downcast_ref::<std::io::Error>()
861 .unwrap()
862 .kind(),
863 std::io::ErrorKind::NotFound
864 );
865 continue;
866 }
867 let digest = result.unwrap();
868 let held = cache.root.join("blobs").join(&digest);
869 assert_eq!(
870 std::fs::read(&held).unwrap(),
871 format!("content-{}", index % 65).as_bytes()
872 );
873 assert_eq!(hash::digest_file(&held).unwrap(), digest);
874 }
875 }
876
877 #[cfg(unix)]
879 #[test]
880 fn a_blob_the_store_holds_is_not_written_again() {
881 use std::os::unix::fs::MetadataExt;
882 let workspace = tempfile::tempdir().unwrap();
883 let root = workspace.path();
884 let cache = Cache::new(root.join(".qk/cache"));
885 cache.initialize().unwrap();
886 std::fs::write(root.join("first"), "same").unwrap();
887 std::fs::write(root.join("second"), "same").unwrap();
888 std::fs::write(root.join("other"), "different").unwrap();
889 let first = cache.put_blob(&root.join("first")).unwrap();
890 let blob = cache.root.join("blobs").join(&first);
891 let stored = std::fs::metadata(&blob).unwrap().ino();
892 assert_eq!(cache.put_blob(&root.join("second")).unwrap(), first);
893 assert_eq!(std::fs::metadata(&blob).unwrap().ino(), stored);
894 std::fs::write(&blob, "corrupt").unwrap();
896 assert_eq!(cache.put_blob(&root.join("second")).unwrap(), first);
897 assert_eq!(std::fs::read(&blob).unwrap(), b"same");
898 {
900 use std::os::unix::fs::PermissionsExt;
901 std::fs::set_permissions(&blob, std::fs::Permissions::from_mode(0o000)).unwrap();
902 }
903 assert_eq!(cache.put_blob(&root.join("second")).unwrap(), first);
904 assert_eq!(std::fs::read(&blob).unwrap(), b"same");
905 let other = cache.put_blob(&root.join("other")).unwrap();
906 assert_ne!(other, first);
907 assert_eq!(
908 std::fs::read(cache.root.join("blobs").join(other)).unwrap(),
909 b"different"
910 );
911 }
912
913 #[test]
916 fn manifest_and_disk_output_fingerprints_agree() {
917 let workspace = tempfile::tempdir().unwrap();
918 let root = workspace.path();
919 std::fs::create_dir_all(root.join("dist/nested/empty")).unwrap();
920 std::fs::write(root.join("dist/nested/a.txt"), "a").unwrap();
921 std::fs::write(root.join("dist/b.bin"), [0, 1, 2]).unwrap();
922 std::fs::create_dir_all(root.join("bundle/empty")).unwrap();
923 std::fs::write(root.join("bundle/readonly"), "readonly").unwrap();
924 #[cfg(unix)]
925 {
926 use std::os::unix::fs::PermissionsExt;
927 let script = root.join("dist/run.sh");
928 std::fs::write(&script, "#!/bin/sh\n").unwrap();
929 std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
930 std::os::unix::fs::symlink("nested/a.txt", root.join("dist/link")).unwrap();
931 std::fs::set_permissions(
932 root.join("dist/nested"),
933 std::fs::Permissions::from_mode(0o500),
934 )
935 .unwrap();
936 std::fs::set_permissions(
937 root.join("bundle/readonly"),
938 std::fs::Permissions::from_mode(0o444),
939 )
940 .unwrap();
941 }
942 let log = root.join("log");
943 std::fs::write(&log, "").unwrap();
944
945 let cache = Cache::new(root.join(".qk/cache"));
946 cache.initialize().unwrap();
947 let outputs = paths::Outputs::from_patterns(&["dist", "bundle"]);
948 let key = "0".repeat(64);
949 let saved = cache.publish(root, &key, &outputs, &log).unwrap();
950 assert_eq!(saved, output_fingerprint(root, &outputs, &key).unwrap());
951
952 #[cfg(unix)]
953 {
954 use std::os::unix::fs::PermissionsExt;
955 std::fs::set_permissions(
958 root.join("dist/nested"),
959 std::fs::Permissions::from_mode(0o755),
960 )
961 .unwrap();
962 }
963 std::fs::remove_dir_all(root.join("dist")).unwrap();
964 std::fs::remove_dir_all(root.join("bundle")).unwrap();
965 let before = std::time::SystemTime::now();
966 let restored = cache
967 .restore(
968 root,
969 "app:build",
970 &Default::default(),
971 &key,
972 &outputs,
973 &qk_executor::Display::Stream,
974 qk_executor::Shown::LocalCache,
975 )
976 .unwrap();
977 assert_eq!(restored, Some(saved.clone()));
978 assert_eq!(saved, output_fingerprint(root, &outputs, &key).unwrap());
979 assert!(root.join("dist/nested/empty").is_dir());
980 assert!(root.join("bundle/empty").is_dir());
981 assert_eq!(
982 std::fs::read_to_string(root.join("bundle/readonly")).unwrap(),
983 "readonly"
984 );
985 #[cfg(unix)]
986 {
987 assert_eq!(
988 std::fs::read_link(root.join("dist/link")).unwrap(),
989 Path::new("nested/a.txt")
990 );
991 assert_eq!(
992 store::mode(&std::fs::metadata(root.join("dist/run.sh")).unwrap()),
993 0o755
994 );
995 assert_eq!(
996 store::mode(&std::fs::metadata(root.join("dist/nested")).unwrap()),
997 0o500
998 );
999 assert_eq!(
1000 store::mode(&std::fs::metadata(root.join("bundle/readonly")).unwrap()),
1001 0o444
1002 );
1003 }
1004 let path = root.join("dist/nested/a.txt");
1005 let modified = std::fs::metadata(&path).unwrap().modified().unwrap();
1006 assert!(modified >= before);
1007 let kept = cache
1008 .restore(
1009 root,
1010 "app:build",
1011 &Default::default(),
1012 &key,
1013 &outputs,
1014 &qk_executor::Display::Hidden,
1015 qk_executor::Shown::LocalCache,
1016 )
1017 .unwrap();
1018 assert_eq!(kept, Some(saved));
1019 assert_eq!(
1020 std::fs::metadata(&path).unwrap().modified().unwrap(),
1021 modified
1022 );
1023 #[cfg(unix)]
1024 {
1025 use std::os::unix::fs::PermissionsExt;
1026 std::fs::set_permissions(
1027 root.join("dist/nested"),
1028 std::fs::Permissions::from_mode(0o755),
1029 )
1030 .unwrap();
1031 }
1032 }
1033}