Skip to content

Commit 152fbfe

Browse files
authored
refactor(execute): single exit paths for pipe/wait and cache update
1 parent 1e9cb11 commit 152fbfe

4 files changed

Lines changed: 812 additions & 658 deletions

File tree

Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
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

Comments
 (0)