|
| 1 | +//! Post-run cache update: decide whether a finished spawn may be cached and, |
| 2 | +//! if so, store its fingerprint, captured output, and output archive. |
| 3 | +
|
| 4 | +use std::{sync::Arc, time::Duration}; |
| 5 | + |
| 6 | +use vite_path::{AbsolutePath, RelativePathBuf}; |
| 7 | +use vite_str::Str; |
| 8 | +use vite_task_plan::cache_metadata::CacheMetadata; |
| 9 | + |
| 10 | +use super::{ |
| 11 | + CacheState, |
| 12 | + fingerprint::{PathRead, PostRunFingerprint}, |
| 13 | + glob, |
| 14 | + spawn::ChildOutcome, |
| 15 | +}; |
| 16 | +use crate::{ |
| 17 | + collections::HashMap, |
| 18 | + session::{ |
| 19 | + cache::{CacheEntryValue, ExecutionCache, archive}, |
| 20 | + event::{CacheErrorKind, CacheNotUpdatedReason, CacheUpdateStatus, ExecutionError}, |
| 21 | + }, |
| 22 | +}; |
| 23 | + |
| 24 | +/// Post-execution summary of what fspy observed for a single task. Fields are |
| 25 | +/// cfg-agnostic so the decision logic below doesn't need `cfg(fspy)` — the |
| 26 | +/// value is only ever `Some` when tracking happened (see [`observe_fspy`]). |
| 27 | +struct TrackingOutcome { |
| 28 | + path_reads: HashMap<RelativePathBuf, PathRead>, |
| 29 | + /// First path that was both read and written during execution, if any. |
| 30 | + /// A non-empty value means caching this task is unsound. |
| 31 | + read_write_overlap: Option<RelativePathBuf>, |
| 32 | +} |
| 33 | + |
| 34 | +/// Decide whether the finished run may be cached, and store it if so. |
| 35 | +/// |
| 36 | +/// Every outcome returns a `(status, error)` pair for the caller's single |
| 37 | +/// `finish()` call; this function never reports by itself. The guard clauses |
| 38 | +/// run in priority order — each names the reason the run is *not* cached, and |
| 39 | +/// only a run that passes them all is stored. |
| 40 | +pub(super) async fn update_cache( |
| 41 | + cache: &ExecutionCache, |
| 42 | + workspace_root: &Arc<AbsolutePath>, |
| 43 | + cache_dir: &AbsolutePath, |
| 44 | + state: CacheState<'_>, |
| 45 | + outcome: &ChildOutcome, |
| 46 | + duration: Duration, |
| 47 | + cancelled: bool, |
| 48 | +) -> (CacheUpdateStatus, Option<ExecutionError>) { |
| 49 | + let CacheState { metadata, globbed_inputs, std_outputs, fspy_negatives } = state; |
| 50 | + |
| 51 | + if cancelled { |
| 52 | + // Cancelled (Ctrl-C or sibling failure) — result is untrustworthy. |
| 53 | + return (CacheUpdateStatus::NotUpdated(CacheNotUpdatedReason::Cancelled), None); |
| 54 | + } |
| 55 | + |
| 56 | + if !outcome.exit_status.success() { |
| 57 | + // Execution failed with non-zero exit status — don't update cache. |
| 58 | + return (CacheUpdateStatus::NotUpdated(CacheNotUpdatedReason::NonZeroExitStatus), None); |
| 59 | + } |
| 60 | + |
| 61 | + let fspy_outcome = observe_fspy(outcome, fspy_negatives.as_deref(), workspace_root); |
| 62 | + |
| 63 | + if let Some(TrackingOutcome { read_write_overlap: Some(path), .. }) = &fspy_outcome { |
| 64 | + // fspy-inferred read-write overlap: the task wrote to a file it also |
| 65 | + // read, so the prerun input hashes are stale and caching is unsound. |
| 66 | + // (We only check fspy-inferred reads, not globbed_inputs. A task that |
| 67 | + // writes to a glob-matched file without reading it produces perpetual |
| 68 | + // cache misses but not a correctness bug.) |
| 69 | + return ( |
| 70 | + CacheUpdateStatus::NotUpdated(CacheNotUpdatedReason::InputModified { |
| 71 | + path: path.clone(), |
| 72 | + }), |
| 73 | + None, |
| 74 | + ); |
| 75 | + } |
| 76 | + |
| 77 | + if fspy_outcome.is_none() && fspy_negatives.is_some() { |
| 78 | + // Task requested fspy auto-inference but this binary was built without |
| 79 | + // `cfg(fspy)`. Task ran, but we can't compute a valid cache entry |
| 80 | + // without tracked path accesses. |
| 81 | + return (CacheUpdateStatus::NotUpdated(CacheNotUpdatedReason::FspyUnsupported), None); |
| 82 | + } |
| 83 | + |
| 84 | + // Paths already in globbed_inputs are skipped: the overlap check above |
| 85 | + // guarantees no input modification, so the prerun hash is the correct |
| 86 | + // post-exec hash. |
| 87 | + let empty_path_reads = HashMap::default(); |
| 88 | + let path_reads = fspy_outcome.as_ref().map_or(&empty_path_reads, |o| &o.path_reads); |
| 89 | + let post_run_fingerprint = |
| 90 | + match PostRunFingerprint::create(path_reads, workspace_root, &globbed_inputs) { |
| 91 | + Ok(fingerprint) => fingerprint, |
| 92 | + Err(err) => { |
| 93 | + return ( |
| 94 | + CacheUpdateStatus::NotUpdated(CacheNotUpdatedReason::CacheDisabled), |
| 95 | + Some(ExecutionError::PostRunFingerprint(err)), |
| 96 | + ); |
| 97 | + } |
| 98 | + }; |
| 99 | + |
| 100 | + let output_archive = match collect_and_archive_outputs(metadata, workspace_root, cache_dir) { |
| 101 | + Ok(archive) => archive, |
| 102 | + Err(err) => { |
| 103 | + return ( |
| 104 | + CacheUpdateStatus::NotUpdated(CacheNotUpdatedReason::CacheDisabled), |
| 105 | + Some(ExecutionError::Cache { kind: CacheErrorKind::Update, source: err }), |
| 106 | + ); |
| 107 | + } |
| 108 | + }; |
| 109 | + |
| 110 | + let new_cache_value = CacheEntryValue { |
| 111 | + post_run_fingerprint, |
| 112 | + std_outputs: std_outputs.into(), |
| 113 | + duration, |
| 114 | + globbed_inputs, |
| 115 | + output_archive, |
| 116 | + }; |
| 117 | + match cache.update(metadata, new_cache_value, cache_dir).await { |
| 118 | + Ok(()) => (CacheUpdateStatus::Updated, None), |
| 119 | + Err(err) => ( |
| 120 | + CacheUpdateStatus::NotUpdated(CacheNotUpdatedReason::CacheDisabled), |
| 121 | + Some(ExecutionError::Cache { kind: CacheErrorKind::Update, source: err }), |
| 122 | + ), |
| 123 | + } |
| 124 | +} |
| 125 | + |
| 126 | +/// Summarize the run's fspy observations. `Some` iff tracking was both |
| 127 | +/// requested (`fspy_negatives.is_some()`) and compiled in (`cfg(fspy)`). On a |
| 128 | +/// `cfg(not(fspy))` build this is always `None`, and [`update_cache`] |
| 129 | +/// short-circuits to `FspyUnsupported` when tracking was needed. |
| 130 | +fn observe_fspy( |
| 131 | + outcome: &ChildOutcome, |
| 132 | + fspy_negatives: Option<&[wax::Glob<'static>]>, |
| 133 | + workspace_root: &AbsolutePath, |
| 134 | +) -> Option<TrackingOutcome> { |
| 135 | + #[cfg(fspy)] |
| 136 | + { |
| 137 | + use super::tracked_accesses::TrackedPathAccesses; |
| 138 | + |
| 139 | + outcome.path_accesses.as_ref().zip(fspy_negatives).map(|(raw, negs)| { |
| 140 | + let tracked = TrackedPathAccesses::from_raw(raw, workspace_root, negs); |
| 141 | + let read_write_overlap = |
| 142 | + tracked.path_reads.keys().find(|p| tracked.path_writes.contains(*p)).cloned(); |
| 143 | + TrackingOutcome { path_reads: tracked.path_reads, read_write_overlap } |
| 144 | + }) |
| 145 | + } |
| 146 | + #[cfg(not(fspy))] |
| 147 | + { |
| 148 | + let _ = (outcome, fspy_negatives, workspace_root); |
| 149 | + None |
| 150 | + } |
| 151 | +} |
| 152 | + |
| 153 | +/// Collect output files matching the configured globs and create a tar.zst |
| 154 | +/// archive in the cache directory. |
| 155 | +/// |
| 156 | +/// Returns `Some(archive_filename)` if files were archived, `None` if the |
| 157 | +/// output config has no positive globs or no files matched. |
| 158 | +fn collect_and_archive_outputs( |
| 159 | + cache_metadata: &CacheMetadata, |
| 160 | + workspace_root: &AbsolutePath, |
| 161 | + cache_dir: &AbsolutePath, |
| 162 | +) -> anyhow::Result<Option<Str>> { |
| 163 | + let output_config = &cache_metadata.output_config; |
| 164 | + |
| 165 | + if output_config.positive_globs.is_empty() { |
| 166 | + return Ok(None); |
| 167 | + } |
| 168 | + |
| 169 | + let output_files = glob::collect_glob_paths( |
| 170 | + workspace_root, |
| 171 | + &output_config.positive_globs, |
| 172 | + &output_config.negative_globs, |
| 173 | + )?; |
| 174 | + |
| 175 | + if output_files.is_empty() { |
| 176 | + return Ok(None); |
| 177 | + } |
| 178 | + |
| 179 | + let archive_name: Str = vite_str::format!("{}.tar.zst", uuid::Uuid::new_v4()); |
| 180 | + let archive_path = cache_dir.join(archive_name.as_str()); |
| 181 | + |
| 182 | + archive::create_output_archive(workspace_root, &output_files, &archive_path)?; |
| 183 | + |
| 184 | + Ok(Some(archive_name)) |
| 185 | +} |
0 commit comments