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 let _profile = profile::span(&task.id, "execute");
292 execute(prepared, cancelled).map(|outcome| TaskResult::uncached(outcome, reason))
293 };
294 let bypass = |reason: String| {
296 if cacheable {
297 qk_executor::status!("qk: {}: cache bypassed ({reason})", task.id);
298 }
299 fallback(format!("{}: {reason}", task.id))
300 };
301 let dependencies = match dependencies
302 .iter()
303 .map(|(id, key)| key.clone().map(|key| (id.clone(), key)))
304 .collect::<std::result::Result<BTreeMap<_, _>, _>>()
305 {
306 Ok(dependencies) => dependencies,
307 Err(root) => {
308 if cacheable {
310 qk_executor::status!("qk: {}: cache bypassed (depends on {root})", task.id);
311 }
312 return fallback(root);
313 }
314 };
315 let snapshot = match self.snapshot(workspace, graph) {
316 Ok(snapshot) => snapshot,
317 Err(reason) => return bypass(reason),
318 };
319 let dependency_profile = profile::span(&task.id, "dependency_inputs");
320 let dependencies =
321 match hash::dependency_keys(snapshot, workspace, graph, task, &dependencies, cancelled)
322 {
323 Ok(dependencies) => dependencies,
324 Err(error) => return bypass(format!("{error:#}")),
325 };
326 drop(dependency_profile);
327 let input_profile = profile::span(&task.id, "inputs");
328 let (key, inputs) = match hash::inputs(
329 snapshot,
330 workspace,
331 task,
332 prepared,
333 &dependencies,
334 cancelled,
335 )
336 .and_then(|inputs| Ok((hash::key(&inputs)?, inputs)))
337 {
338 Ok(key) => key,
339 Err(error) => return bypass(format!("{error:#}")),
340 };
341 drop(input_profile);
342 let keyed = Some((key.clone(), inputs));
343 running
345 .execution
346 .insert("NX_TASK_HASH".into(), key.clone().into());
347 let running = &running;
348 let outputs = match paths::Outputs::new(workspace, task) {
349 Ok(outputs) => outputs,
350 Err(error) => return bypass(format!("{error:#}")),
351 };
352 let depended_on = graph
355 .tasks
356 .values()
357 .any(|other| other.dependencies.contains(&task.id));
358 let fingerprint_outputs = |key: &str| {
359 if depended_on {
360 outputs_fingerprint(task, &workspace.root, &outputs, key)
361 } else {
362 Err(format!("{}: no task depends on it", task.id))
363 }
364 };
365 if !cacheable {
366 let restored = before(running);
367 let executing = profile::span(&task.id, "execute");
368 let outcome = execute(running, cancelled)?;
369 drop(executing);
370 let warm_report = after(outcome, restored, running);
371 let fingerprint = if self.unchanged(
372 snapshot,
373 workspace,
374 graph,
375 task,
376 prepared,
377 &dependencies,
378 &key,
379 outcome,
380 cancelled,
381 ) {
382 fingerprint_outputs(&key)
383 } else {
384 Err(format!("{}: did not complete unchanged", task.id))
385 };
386 return Ok(TaskResult {
387 outcome,
388 fingerprint,
389 cache: CacheStatus::Uncached,
390 key: keyed,
391 warm: warm_report,
392 execution: None,
393 });
394 }
395 if !self.reuse {
396 let capture = Capture::new(None, prepared.display.clone()).retain_log();
397 let executing = profile::span(&task.id, "execute");
398 let outcome = execute_captured(running, cancelled, Some(&capture))?;
399 drop(executing);
400 let inputs_unchanged = self.inputs_match(
401 snapshot,
402 workspace,
403 graph,
404 task,
405 prepared,
406 &dependencies,
407 &key,
408 cancelled,
409 );
410 let fingerprint = if outcome == Outcome::Success && inputs_unchanged {
411 fingerprint_outputs(&key)
412 } else {
413 Err(format!("{}: did not complete unchanged", task.id))
414 };
415 return Ok(TaskResult {
416 outcome,
417 fingerprint,
418 cache: CacheStatus::Uncached,
419 key: keyed,
420 warm: None,
421 execution: capture.take_log().map(|log| Execution {
422 log,
423 inputs_unchanged,
424 }),
425 });
426 }
427 if let Err(error) = self.initialize() {
428 return bypass(format!("cache unavailable: {error}"));
429 }
430 let (_lock, waited) = match self.lock(&key, cancelled) {
431 Ok(Some(lock)) => lock,
432 Ok(None) => {
433 return Ok(TaskResult::uncached(
434 Outcome::Cancelled,
435 format!("{}: cancelled", task.id),
436 ));
437 }
438 Err(error) => return bypass(format!("cache lock unavailable: {error}")),
439 };
440 if waited
442 && hash::recheck_dependency_keys(
443 snapshot,
444 workspace,
445 graph,
446 task,
447 &dependencies,
448 cancelled,
449 )
450 .and_then(|dependencies| {
451 hash::fingerprint(
452 snapshot,
453 workspace,
454 task,
455 prepared,
456 &dependencies,
457 cancelled,
458 )
459 })
460 .ok()
461 .as_ref()
462 != Some(&key)
463 {
464 return bypass("inputs changed while waiting for another run".into());
465 }
466 let local_profile = profile::span(&task.id, "local_restore");
467 match self.restore(
468 &workspace.root,
469 &task.id,
470 &task.dependencies,
471 &key,
472 &outputs,
473 &prepared.display,
474 qk_executor::Shown::LocalCache,
475 ) {
476 Ok(Some(fingerprint)) => {
477 if let Some(remote) = &self.remote {
478 remote.held(&key);
479 }
480 qk_executor::report::event(qk_executor::report::Event::Cache {
481 id: task.id.clone(),
482 cached: qk_executor::report::Cached::Hit,
483 });
484 return Ok(TaskResult {
485 outcome: Outcome::Success,
486 fingerprint: Ok(fingerprint),
487 cache: CacheStatus::LocalHit,
488 key: keyed,
489 warm: None,
490 execution: None,
491 });
492 }
493 Ok(None) => {}
494 Err(error) => {
495 qk_executor::status!("qk: {}: ignoring unusable cache entry ({error})", task.id)
496 }
497 }
498 drop(local_profile);
499 if let Some(remote) = &self.remote {
500 let fetch_profile = profile::span(&task.id, "remote_fetch");
501 let fetched = remote.fetch(&self.root, &key);
502 drop(fetch_profile);
503 let fetched = fetched.and_then(|found| {
504 if !found {
505 return Ok(None);
506 }
507 self.restore(
508 &workspace.root,
509 &task.id,
510 &task.dependencies,
511 &key,
512 &outputs,
513 &prepared.display,
514 qk_executor::Shown::RemoteCache,
515 )
516 });
517 match fetched {
518 Ok(Some(fingerprint)) => {
519 qk_executor::report::event(qk_executor::report::Event::Cache {
520 id: task.id.clone(),
521 cached: qk_executor::report::Cached::RemoteHit,
522 });
523 return Ok(TaskResult {
524 outcome: Outcome::Success,
525 fingerprint: Ok(fingerprint),
526 cache: CacheStatus::RemoteHit,
527 key: keyed,
528 warm: None,
529 execution: None,
530 });
531 }
532 Ok(None) => {}
533 Err(error) => {
534 qk_executor::status!("qk: {}: remote cache unavailable ({error:#})", task.id)
535 }
536 }
537 }
538 qk_executor::report::event(qk_executor::report::Event::Cache {
539 id: task.id.clone(),
540 cached: qk_executor::report::Cached::Miss,
541 });
542 let log = match tempfile::NamedTempFile::new_in(self.root.join("tmp")) {
543 Ok(log) => log,
544 Err(error) => return bypass(format!("cache log unavailable: {error}")),
545 };
546 let capture =
547 Capture::new(Some(log.as_file().try_clone()?), prepared.display.clone()).retain_log();
548 let restored = before(running);
549 let executing = profile::span(&task.id, "execute");
550 let outcome = execute_captured(running, cancelled, Some(&capture))?;
551 drop(executing);
552 let warm_report = after(outcome, restored, running);
553 let inputs_unchanged = self.inputs_match(
554 snapshot,
555 workspace,
556 graph,
557 task,
558 prepared,
559 &dependencies,
560 &key,
561 cancelled,
562 );
563 let execution = capture.take_log().map(|log| Execution {
564 log,
565 inputs_unchanged,
566 });
567 let fingerprint = if outcome != Outcome::Success || !inputs_unchanged {
568 Err(format!("{}: did not complete unchanged", task.id))
569 } else if !capture.healthy() {
570 fingerprint_outputs(&key)
571 } else {
572 let publishing = profile::span(&task.id, "publish");
574 let published = self.publish(&workspace.root, &key, &outputs, log.path());
575 drop(publishing);
576 match published {
577 Ok(fingerprint) => {
578 store::record_outputs(&workspace.root, &task.id, &key, &outputs);
579 if let Some(remote) = &self.remote {
580 remote.upload(&self.root, &key);
581 }
582 Ok(fingerprint)
583 }
584 Err(error) => {
585 qk_executor::status!("qk: {}: could not save cache entry ({error})", task.id);
586 fingerprint_outputs(&key)
587 }
588 }
589 };
590 Ok(TaskResult {
591 outcome,
592 fingerprint,
593 cache: CacheStatus::Miss,
594 key: keyed,
595 warm: warm_report,
596 execution,
597 })
598 }
599
600 #[allow(clippy::too_many_arguments)]
602 fn unchanged(
603 &self,
604 snapshot: &hash::Snapshot,
605 workspace: &Workspace,
606 graph: &TaskGraph,
607 task: &Task,
608 prepared: &PreparedTask,
609 dependencies: &BTreeMap<String, String>,
610 before: &str,
611 outcome: Outcome,
612 cancelled: &AtomicBool,
613 ) -> bool {
614 if outcome != Outcome::Success || cancelled.load(Ordering::SeqCst) {
615 return false;
616 }
617 self.inputs_match(
618 snapshot,
619 workspace,
620 graph,
621 task,
622 prepared,
623 dependencies,
624 before,
625 cancelled,
626 )
627 }
628
629 #[allow(clippy::too_many_arguments)]
631 fn inputs_match(
632 &self,
633 snapshot: &hash::Snapshot,
634 workspace: &Workspace,
635 graph: &TaskGraph,
636 task: &Task,
637 prepared: &PreparedTask,
638 dependencies: &BTreeMap<String, String>,
639 before: &str,
640 cancelled: &AtomicBool,
641 ) -> bool {
642 if cancelled.load(Ordering::SeqCst) {
643 return false;
644 }
645 let _profile = profile::span(&task.id, "inputs_recheck");
646 let after = hash::recheck_dependency_keys(
647 snapshot,
648 workspace,
649 graph,
650 task,
651 dependencies,
652 cancelled,
653 )
654 .and_then(|dependencies| {
655 hash::fingerprint(
656 snapshot,
657 workspace,
658 task,
659 prepared,
660 &dependencies,
661 cancelled,
662 )
663 });
664 if after.as_deref().ok() != Some(before) {
665 if task.definition.cache == Some(true) {
666 if self.reuse {
667 qk_executor::status!(
668 "qk: {}: not caching because inputs changed during execution",
669 task.id
670 );
671 } else {
672 qk_executor::status!(
673 "qk: {}: excluding execution from flaky results because inputs changed during execution",
674 task.id
675 );
676 }
677 }
678 return false;
679 }
680 true
681 }
682}
683
684pub(crate) fn file_output(content: &str, mode: u32) -> Value {
687 json!({"content": content, "mode": mode})
688}
689
690pub(crate) fn link_output(target: &str) -> Value {
691 json!({"link": target})
692}
693
694pub(crate) fn directory_output() -> Value {
695 json!("directory")
696}
697
698pub(crate) fn combine_outputs(
704 input: &str,
705 files: &BTreeMap<String, Value>,
706 declared: bool,
707) -> Result<String> {
708 let identity = if declared { "declared outputs" } else { input };
709 Ok(blake3::hash(&serde_json::to_vec(&(identity, files))?)
710 .to_hex()
711 .to_string())
712}
713
714fn outputs_fingerprint(
715 task: &Task,
716 root: &Path,
717 outputs: &paths::Outputs,
718 input: &str,
719) -> Fingerprint {
720 output_fingerprint(root, outputs, input)
721 .map_err(|error| format!("{}: cannot fingerprint outputs: {error:#}", task.id))
722}
723
724fn output_fingerprint(root: &Path, outputs: &paths::Outputs, input: &str) -> Result<String> {
725 let mut files = BTreeMap::new();
726 for entry in outputs.entries(root)? {
727 let (path, metadata) = entry?;
728 paths::safe_parents(root, &path)?;
729 let absolute = root.join(&path);
730 let value = if metadata.file_type().is_symlink() {
731 link_output(
732 std::fs::read_link(absolute)?
733 .to_str()
734 .context("symlink target must be UTF-8")?,
735 )
736 } else if metadata.is_file() {
737 file_output(&hash::digest_file(&absolute)?, store::mode(&metadata))
738 } else {
739 directory_output()
740 };
741 files.insert(path, value);
742 }
743 combine_outputs(input, &files, outputs.declared())
744}
745
746pub type Keyed = std::result::Result<(String, Value), String>;
748
749pub fn keys(
754 workspace: &Workspace,
755 graph: &TaskGraph,
756 prepared: &BTreeMap<String, PreparedTask>,
757 cancelled: &AtomicBool,
758) -> Result<BTreeMap<String, Keyed>> {
759 let snapshot = hash::Snapshot::new(workspace, graph, &paths::cache_location(workspace))?;
760 let mut keys = BTreeMap::new();
761 let mut fingerprints: BTreeMap<String, Fingerprint> = BTreeMap::new();
762 let mut pending: Vec<&Task> = graph.tasks.values().collect();
763 while !pending.is_empty() {
765 let waiting = pending.len();
766 pending.retain(|task| {
767 if !task
768 .dependencies
769 .iter()
770 .all(|id| fingerprints.contains_key(id))
771 {
772 return true;
773 }
774 let keyed = key_now(
775 &snapshot,
776 workspace,
777 graph,
778 task,
779 prepared,
780 &fingerprints,
781 cancelled,
782 );
783 let fingerprint = if task.definition.continuous == Some(true) {
784 Err(format!(
785 "{}: continuous tasks are not fingerprinted",
786 task.id
787 ))
788 } else if !prepared[&task.id].ready_when.is_empty() {
789 Err(format!(
790 "{}: tasks with readyWhen are not fingerprinted",
791 task.id
792 ))
793 } else {
794 keyed.clone().and_then(|(key, _)| {
795 let outputs = paths::Outputs::new(workspace, task)
796 .map_err(|error| format!("{}: {error:#}", task.id))?;
797 outputs_fingerprint(task, &workspace.root, &outputs, &key)
798 })
799 };
800 fingerprints.insert(task.id.clone(), fingerprint);
801 keys.insert(task.id.clone(), keyed);
802 false
803 });
804 anyhow::ensure!(pending.len() < waiting, "the task graph has a cycle");
805 }
806 Ok(keys)
807}
808
809fn key_now(
810 snapshot: &hash::Snapshot,
811 workspace: &Workspace,
812 graph: &TaskGraph,
813 task: &Task,
814 prepared: &BTreeMap<String, PreparedTask>,
815 fingerprints: &BTreeMap<String, Fingerprint>,
816 cancelled: &AtomicBool,
817) -> Keyed {
818 let dependencies = task
819 .dependencies
820 .iter()
821 .map(|id| fingerprints[id].clone().map(|key| (id.clone(), key)))
822 .collect::<std::result::Result<BTreeMap<_, _>, _>>()?;
823 let failed = |error: anyhow::Error| format!("{}: {error:#}", task.id);
824 let dependencies =
825 hash::dependency_keys(snapshot, workspace, graph, task, &dependencies, cancelled)
826 .map_err(failed)?;
827 let inputs = hash::inputs(
828 snapshot,
829 workspace,
830 task,
831 &prepared[&task.id],
832 &dependencies,
833 cancelled,
834 )
835 .map_err(failed)?;
836 Ok((hash::key(&inputs).map_err(failed)?, inputs))
837}
838
839#[doc(hidden)]
841pub fn run_upload_worker() -> anyhow::Result<()> {
842 remote::background::run()
843}
844
845#[cfg(test)]
846mod tests {
847 use super::*;
848
849 #[test]
850 fn concurrent_blob_writes_keep_results_ordered_and_repair_corruption() {
851 let workspace = tempfile::tempdir().unwrap();
852 let root = workspace.path();
853 let cache = Cache::new(root.join("cache"));
854 cache.initialize().unwrap();
855 let mut sources = Vec::new();
856 for index in 0..130 {
857 let source = root.join(format!("source-{index}"));
858 std::fs::write(&source, format!("content-{}", index % 65)).unwrap();
859 sources.push(source);
860 }
861 let digest = cache.put_blob(&sources[0]).unwrap();
862 std::fs::write(cache.root.join("blobs").join(&digest), "corrupt").unwrap();
863 std::fs::remove_file(&sources[31]).unwrap();
864 let results = cache.put_blobs(&sources);
865 assert_eq!(results.len(), sources.len());
866 for (index, result) in results.into_iter().enumerate() {
867 if index == 31 {
868 assert_eq!(
869 result
870 .unwrap_err()
871 .downcast_ref::<std::io::Error>()
872 .unwrap()
873 .kind(),
874 std::io::ErrorKind::NotFound
875 );
876 continue;
877 }
878 let digest = result.unwrap();
879 let held = cache.root.join("blobs").join(&digest);
880 assert_eq!(
881 std::fs::read(&held).unwrap(),
882 format!("content-{}", index % 65).as_bytes()
883 );
884 assert_eq!(hash::digest_file(&held).unwrap(), digest);
885 }
886 }
887
888 #[cfg(unix)]
890 #[test]
891 fn a_blob_the_store_holds_is_not_written_again() {
892 use std::os::unix::fs::MetadataExt;
893 let workspace = tempfile::tempdir().unwrap();
894 let root = workspace.path();
895 let cache = Cache::new(root.join(".qk/cache"));
896 cache.initialize().unwrap();
897 std::fs::write(root.join("first"), "same").unwrap();
898 std::fs::write(root.join("second"), "same").unwrap();
899 std::fs::write(root.join("other"), "different").unwrap();
900 let first = cache.put_blob(&root.join("first")).unwrap();
901 let blob = cache.root.join("blobs").join(&first);
902 let stored = std::fs::metadata(&blob).unwrap().ino();
903 assert_eq!(cache.put_blob(&root.join("second")).unwrap(), first);
904 assert_eq!(std::fs::metadata(&blob).unwrap().ino(), stored);
905 std::fs::write(&blob, "corrupt").unwrap();
907 assert_eq!(cache.put_blob(&root.join("second")).unwrap(), first);
908 assert_eq!(std::fs::read(&blob).unwrap(), b"same");
909 {
911 use std::os::unix::fs::PermissionsExt;
912 std::fs::set_permissions(&blob, std::fs::Permissions::from_mode(0o000)).unwrap();
913 }
914 assert_eq!(cache.put_blob(&root.join("second")).unwrap(), first);
915 assert_eq!(std::fs::read(&blob).unwrap(), b"same");
916 let other = cache.put_blob(&root.join("other")).unwrap();
917 assert_ne!(other, first);
918 assert_eq!(
919 std::fs::read(cache.root.join("blobs").join(other)).unwrap(),
920 b"different"
921 );
922 }
923
924 #[test]
927 fn manifest_and_disk_output_fingerprints_agree() {
928 let workspace = tempfile::tempdir().unwrap();
929 let root = workspace.path();
930 std::fs::create_dir_all(root.join("dist/nested/empty")).unwrap();
931 std::fs::write(root.join("dist/nested/a.txt"), "a").unwrap();
932 std::fs::write(root.join("dist/b.bin"), [0, 1, 2]).unwrap();
933 std::fs::create_dir_all(root.join("bundle/empty")).unwrap();
934 std::fs::write(root.join("bundle/readonly"), "readonly").unwrap();
935 #[cfg(unix)]
936 {
937 use std::os::unix::fs::PermissionsExt;
938 let script = root.join("dist/run.sh");
939 std::fs::write(&script, "#!/bin/sh\n").unwrap();
940 std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
941 std::os::unix::fs::symlink("nested/a.txt", root.join("dist/link")).unwrap();
942 std::fs::set_permissions(
943 root.join("dist/nested"),
944 std::fs::Permissions::from_mode(0o500),
945 )
946 .unwrap();
947 std::fs::set_permissions(
948 root.join("bundle/readonly"),
949 std::fs::Permissions::from_mode(0o444),
950 )
951 .unwrap();
952 }
953 let log = root.join("log");
954 std::fs::write(&log, "").unwrap();
955
956 let cache = Cache::new(root.join(".qk/cache"));
957 cache.initialize().unwrap();
958 let outputs = paths::Outputs::from_patterns(&["dist", "bundle"]);
959 let key = "0".repeat(64);
960 let saved = cache.publish(root, &key, &outputs, &log).unwrap();
961 assert_eq!(saved, output_fingerprint(root, &outputs, &key).unwrap());
962
963 #[cfg(unix)]
964 {
965 use std::os::unix::fs::PermissionsExt;
966 std::fs::set_permissions(
969 root.join("dist/nested"),
970 std::fs::Permissions::from_mode(0o755),
971 )
972 .unwrap();
973 }
974 std::fs::remove_dir_all(root.join("dist")).unwrap();
975 std::fs::remove_dir_all(root.join("bundle")).unwrap();
976 let before = std::time::SystemTime::now();
977 let restored = cache
978 .restore(
979 root,
980 "app:build",
981 &Default::default(),
982 &key,
983 &outputs,
984 &qk_executor::Display::Stream,
985 qk_executor::Shown::LocalCache,
986 )
987 .unwrap();
988 assert_eq!(restored, Some(saved.clone()));
989 assert_eq!(saved, output_fingerprint(root, &outputs, &key).unwrap());
990 assert!(root.join("dist/nested/empty").is_dir());
991 assert!(root.join("bundle/empty").is_dir());
992 assert_eq!(
993 std::fs::read_to_string(root.join("bundle/readonly")).unwrap(),
994 "readonly"
995 );
996 #[cfg(unix)]
997 {
998 assert_eq!(
999 std::fs::read_link(root.join("dist/link")).unwrap(),
1000 Path::new("nested/a.txt")
1001 );
1002 assert_eq!(
1003 store::mode(&std::fs::metadata(root.join("dist/run.sh")).unwrap()),
1004 0o755
1005 );
1006 assert_eq!(
1007 store::mode(&std::fs::metadata(root.join("dist/nested")).unwrap()),
1008 0o500
1009 );
1010 assert_eq!(
1011 store::mode(&std::fs::metadata(root.join("bundle/readonly")).unwrap()),
1012 0o444
1013 );
1014 }
1015 let path = root.join("dist/nested/a.txt");
1016 let modified = std::fs::metadata(&path).unwrap().modified().unwrap();
1017 assert!(modified >= before);
1018 let kept = cache
1019 .restore(
1020 root,
1021 "app:build",
1022 &Default::default(),
1023 &key,
1024 &outputs,
1025 &qk_executor::Display::Hidden,
1026 qk_executor::Shown::LocalCache,
1027 )
1028 .unwrap();
1029 assert_eq!(kept, Some(saved));
1030 assert_eq!(
1031 std::fs::metadata(&path).unwrap().modified().unwrap(),
1032 modified
1033 );
1034 #[cfg(unix)]
1035 {
1036 use std::os::unix::fs::PermissionsExt;
1037 std::fs::set_permissions(
1038 root.join("dist/nested"),
1039 std::fs::Permissions::from_mode(0o755),
1040 )
1041 .unwrap();
1042 }
1043 }
1044}