Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 25 additions & 15 deletions crates/vite_task/src/session/cache/display.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ use serde::{Deserialize, Serialize};
use vite_str::Str;
use vite_task_plan::cache_metadata::SpawnFingerprint;

use super::{CacheMiss, FingerprintMismatch};
use super::{CacheMiss, FingerprintMismatch, InputChangeKind, split_path};
use crate::session::event::CacheStatus;

/// Describes a single atomic change between two spawn fingerprints.
Expand Down Expand Up @@ -174,24 +174,34 @@ pub fn format_cache_status_inline(cache_status: &CacheStatus) -> Option<Str> {
}
}
FingerprintMismatch::InputConfig => "inputs configuration changed",
FingerprintMismatch::GlobbedInput { path } => {
return Some(vite_str::format!(
"✗ cache miss: content of input '{path}' changed, executing"
));
}
FingerprintMismatch::PostRunFingerprint(diff) => {
use crate::session::execute::fingerprint::PostRunFingerprintMismatch;
match diff {
PostRunFingerprintMismatch::InputContentChanged { path } => {
return Some(vite_str::format!(
"✗ cache miss: content of input '{path}' changed, executing"
));
}
}
FingerprintMismatch::InputChanged { kind, path } => {
let desc = format_input_change_str(*kind, path.as_str());
return Some(vite_str::format!("✗ cache miss: {desc}, executing"));
}
};
Some(vite_str::format!("✗ cache miss: {reason}, executing"))
}
CacheStatus::Disabled(_) => Some(Str::from("⊘ cache disabled")),
}
}

/// Format an input change as a [`Str`] for inline display.
pub fn format_input_change_str(kind: InputChangeKind, path: &str) -> Str {
match kind {
InputChangeKind::ContentModified => vite_str::format!("'{path}' modified"),
InputChangeKind::Added => {
let (dir, filename) = split_path(path);
dir.map_or_else(
|| vite_str::format!("'{filename}' added in workspace root"),
|dir| vite_str::format!("'{filename}' added in '{dir}'"),
)
}
InputChangeKind::Removed => {
let (dir, filename) = split_path(path);
dir.map_or_else(
|| vite_str::format!("'{filename}' removed from workspace root"),
|dir| vite_str::format!("'{filename}' removed from '{dir}'"),
)
}
}
}
77 changes: 56 additions & 21 deletions crates/vite_task/src/session/cache/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,18 +7,18 @@ use std::{collections::BTreeMap, fmt::Display, fs::File, io::Write, sync::Arc, t
use bincode::{Decode, Encode, decode_from_slice, encode_to_vec};
// Re-export display functions for convenience
pub use display::format_cache_status_inline;
pub use display::{SpawnFingerprintChange, detect_spawn_fingerprint_changes, format_spawn_change};
pub use display::{
SpawnFingerprintChange, detect_spawn_fingerprint_changes, format_input_change_str,
format_spawn_change,
};
use rusqlite::{Connection, OptionalExtension as _, config::DbConfig};
use serde::{Deserialize, Serialize};
use tokio::sync::Mutex;
use vite_path::{AbsolutePath, RelativePathBuf};
use vite_task_graph::config::ResolvedInputConfig;
use vite_task_plan::cache_metadata::{CacheMetadata, ExecutionCacheKey, SpawnFingerprint};

use super::execute::{
fingerprint::{PostRunFingerprint, PostRunFingerprintMismatch},
spawn::StdOutput,
};
use super::execute::{fingerprint::PostRunFingerprint, spawn::StdOutput};

/// Cache lookup key identifying a task's execution configuration.
///
Expand Down Expand Up @@ -83,6 +83,16 @@ pub enum CacheMiss {
FingerprintMismatch(FingerprintMismatch),
}

#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub enum InputChangeKind {
/// File content changed but path is the same
ContentModified,
/// New file or folder added
Added,
/// Existing file or folder removed
Removed,
}

#[derive(Debug, Serialize, Deserialize)]
pub enum FingerprintMismatch {
/// Found a previous cache entry key for the same task, but the spawn fingerprint differs.
Expand All @@ -95,10 +105,11 @@ pub enum FingerprintMismatch {
},
/// Found a previous cache entry key for the same task, but `input_config` differs.
InputConfig,
/// Found the cache entry with the same spawn fingerprint, but an explicit globbed input changed
GlobbedInput { path: RelativePathBuf },
/// Found the cache entry with the same spawn fingerprint, but the post-run fingerprint mismatches
PostRunFingerprint(PostRunFingerprintMismatch),

InputChanged {
kind: InputChangeKind,
path: RelativePathBuf,
},
}

impl Display for FingerprintMismatch {
Expand All @@ -110,14 +121,22 @@ impl Display for FingerprintMismatch {
Self::InputConfig => {
write!(f, "inputs configuration changed")
}
Self::GlobbedInput { path } => {
write!(f, "content of input '{path}' changed")
Self::InputChanged { kind, path } => {
write!(f, "{}", display::format_input_change_str(*kind, path.as_str()))
}
Self::PostRunFingerprint(diff) => Display::fmt(diff, f),
}
}
}

/// Split a relative path into `(parent_dir, filename)`.
/// Returns `None` for the parent if the path has no `/` separator.
pub fn split_path(path: &str) -> (Option<&str>, &str) {
match path.rsplit_once('/') {
Some((parent, filename)) => (Some(parent), filename),
None => (None, path),
}
}

impl ExecutionCache {
#[tracing::instrument(level = "debug", skip_all)]
pub fn load_from_path(path: &AbsolutePath) -> anyhow::Result<Self> {
Expand Down Expand Up @@ -197,11 +216,9 @@ impl ExecutionCache {
}

// Validate post-run fingerprint (inferred inputs from fspy)
if let Some(post_run_fingerprint_mismatch) =
cache_value.post_run_fingerprint.validate(workspace_root)?
{
if let Some((kind, path)) = cache_value.post_run_fingerprint.validate(workspace_root)? {
return Ok(Err(CacheMiss::FingerprintMismatch(
FingerprintMismatch::PostRunFingerprint(post_run_fingerprint_mismatch),
FingerprintMismatch::InputChanged { kind, path },
)));
}
// Associate the execution key to the cache entry key if not already,
Expand Down Expand Up @@ -264,22 +281,40 @@ fn detect_globbed_input_change(
loop {
match (s, c) {
(None, None) => return None,
(Some((path, _)), None) | (None, Some((path, _))) => {
return Some(FingerprintMismatch::GlobbedInput { path: path.clone() });
(Some((sp, _)), None) => {
return Some(FingerprintMismatch::InputChanged {
kind: InputChangeKind::Removed,
path: sp.clone(),
});
}
(None, Some((cp, _))) => {
return Some(FingerprintMismatch::InputChanged {
kind: InputChangeKind::Added,
path: cp.clone(),
});
}
(Some((sp, sh)), Some((cp, ch))) => match sp.cmp(cp) {
std::cmp::Ordering::Equal => {
if sh != ch {
return Some(FingerprintMismatch::GlobbedInput { path: sp.clone() });
return Some(FingerprintMismatch::InputChanged {
kind: InputChangeKind::ContentModified,
path: sp.clone(),
});
}
s = stored_iter.next();
c = current_iter.next();
}
std::cmp::Ordering::Less => {
return Some(FingerprintMismatch::GlobbedInput { path: sp.clone() });
return Some(FingerprintMismatch::InputChanged {
kind: InputChangeKind::Removed,
path: sp.clone(),
});
}
std::cmp::Ordering::Greater => {
return Some(FingerprintMismatch::GlobbedInput { path: cp.clone() });
return Some(FingerprintMismatch::InputChanged {
kind: InputChangeKind::Added,
path: cp.clone(),
});
}
},
}
Expand Down
106 changes: 80 additions & 26 deletions crates/vite_task/src/session/execute/fingerprint.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
//! fingerprints of file system state after task execution.

use std::{
collections::BTreeMap,
fs::File,
hash::Hasher as _,
io::{self, BufRead, Read},
Expand All @@ -17,7 +18,7 @@ use vite_path::{AbsolutePath, RelativePathBuf};
use vite_str::Str;

use super::spawn::PathRead;
use crate::collections::HashMap;
use crate::{collections::HashMap, session::cache::InputChangeKind};

/// Post-run fingerprint capturing file state after execution.
/// Used to validate whether cached outputs are still valid.
Expand All @@ -38,8 +39,8 @@ pub enum PathFingerprint {
/// Directory with optional entry listing.
/// `Folder(None)` means the directory was opened but entries were not read
/// (e.g., for `openat` calls).
/// `Folder(Some(_))` contains the directory entries.
Folder(Option<HashMap<Str, DirEntryKind>>),
/// `Folder(Some(_))` contains the directory entries sorted by name.
Folder(Option<BTreeMap<Str, DirEntryKind>>),
}

/// Kind of directory entry
Expand All @@ -50,22 +51,6 @@ pub enum DirEntryKind {
Symlink,
}

/// Describes why the post-run fingerprint validation failed
#[derive(Debug, Serialize, Deserialize, Clone)]
pub enum PostRunFingerprintMismatch {
InputContentChanged { path: RelativePathBuf },
}

impl std::fmt::Display for PostRunFingerprintMismatch {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::InputContentChanged { path } => {
write!(f, "{path} content changed")
}
}
}
}

impl PostRunFingerprint {
/// Creates a new fingerprint from path accesses after task execution.
///
Expand Down Expand Up @@ -94,12 +79,12 @@ impl PostRunFingerprint {
}

/// Validates the fingerprint against current filesystem state.
/// Returns `Some(mismatch)` if validation fails, `None` if valid.
/// Returns `Some((kind, path))` if an input changed, `None` if all valid.
#[tracing::instrument(level = "debug", skip_all, name = "validate_post_run_fingerprint")]
pub fn validate(
&self,
base_dir: &AbsolutePath,
) -> anyhow::Result<Option<PostRunFingerprintMismatch>> {
) -> anyhow::Result<Option<(InputChangeKind, RelativePathBuf)>> {
let input_mismatch = self.inferred_inputs.par_iter().find_map_any(
|(input_relative_path, path_fingerprint)| {
let input_full_path = Arc::<AbsolutePath>::from(base_dir.join(input_relative_path));
Expand All @@ -113,16 +98,85 @@ impl PostRunFingerprint {
if path_fingerprint == &current_path_fingerprint {
None
} else {
Some(Ok(PostRunFingerprintMismatch::InputContentChanged {
path: input_relative_path.clone(),
}))
let (kind, entry_name) =
determine_change_kind(path_fingerprint, &current_path_fingerprint);
let path = if let Some(name) = entry_name {
// For folder changes, build `dir/entry` path
let entry = match RelativePathBuf::new(name.as_str()) {
Ok(p) => p,
Err(e) => return Some(Err(e.into())),
};
input_relative_path.as_relative_path().join(entry)
} else {
input_relative_path.clone()
};
Some(Ok((kind, path)))
}
},
);
input_mismatch.transpose()
}
}

/// Determine the kind of change between two differing path fingerprints.
/// Caller guarantees `stored != current`.
///
/// Returns `(kind, entry_name)` where `entry_name` is `Some` for folder changes
/// when a specific added/removed entry can be identified.
fn determine_change_kind<'a>(
stored: &'a PathFingerprint,
current: &'a PathFingerprint,
) -> (InputChangeKind, Option<&'a Str>) {
match (stored, current) {
(PathFingerprint::NotFound, _) => (InputChangeKind::Added, None),
(_, PathFingerprint::NotFound) => (InputChangeKind::Removed, None),
(PathFingerprint::FileContentHash(_), PathFingerprint::FileContentHash(_)) => {
(InputChangeKind::ContentModified, None)
}
(PathFingerprint::Folder(old), PathFingerprint::Folder(new)) => {
determine_folder_change_kind(old.as_ref(), new.as_ref())
}
// Type changed (file ↔ folder)
_ => (InputChangeKind::Added, None),
}
}

/// Determine whether a folder change is an addition or removal by comparing entries.
/// Both maps are `BTreeMap` so we iterate them in sorted lockstep.
/// Returns the specific entry name that was added or removed, if identifiable.
fn determine_folder_change_kind<'a>(
old: Option<&'a BTreeMap<Str, DirEntryKind>>,
new: Option<&'a BTreeMap<Str, DirEntryKind>>,
) -> (InputChangeKind, Option<&'a Str>) {
let (Some(old_entries), Some(new_entries)) = (old, new) else {
return (InputChangeKind::Added, None);
};

let mut old_iter = old_entries.iter();
let mut new_iter = new_entries.iter();
let mut o = old_iter.next();
let mut n = new_iter.next();

loop {
match (o, n) {
(None, None) => return (InputChangeKind::Added, None),
(Some((name, _)), None) => return (InputChangeKind::Removed, Some(name)),
(None, Some((name, _))) => return (InputChangeKind::Added, Some(name)),
(Some((ok, ov)), Some((nk, nv))) => match ok.cmp(nk) {
std::cmp::Ordering::Equal => {
if ov != nv {
return (InputChangeKind::Added, Some(ok));
}
o = old_iter.next();
n = new_iter.next();
}
std::cmp::Ordering::Less => return (InputChangeKind::Removed, Some(ok)),
std::cmp::Ordering::Greater => return (InputChangeKind::Added, Some(nk)),
},
}
}
}

/// Hash file content using `xxHash3_64`
fn hash_content(mut stream: impl Read) -> io::Result<u64> {
let mut hasher = twox_hash::XxHash3_64::default();
Expand Down Expand Up @@ -206,7 +260,7 @@ fn process_directory(
return Ok(PathFingerprint::Folder(None));
}

let mut entries = HashMap::new();
let mut entries = BTreeMap::new();
for entry in std::fs::read_dir(path)? {
let entry = entry?;
let name = entry.file_name();
Expand Down Expand Up @@ -244,7 +298,7 @@ fn process_directory_unix(file: &File, path_read: PathRead) -> anyhow::Result<Pa
let fd = file.as_fd();
let mut dir = nix::dir::Dir::from_fd(fd.try_clone_to_owned()?)?;

let mut entries = HashMap::new();
let mut entries = BTreeMap::new();
for entry in dir.iter() {
let entry = entry?;
let name = entry.file_name().to_bytes();
Expand Down
Loading