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