diff --git a/CHANGELOG.md b/CHANGELOG.md index d9b6665fe..6fbf58d2d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ # Changelog +- **Added** `vp run -vv` also lists what each cached run depends on, changed or not, and the output files a hit restores: in the detailed summary, under `Unchanged:` and `Outputs:`, and with `--dry-run --json`, as each step's `baseline.fingerprint` and `baseline.outputs`. It shows, for an unexpected hit, whether an edited file is an input at all. `-v` is now a counted flag, so `-vv` is the same as `--verbose --verbose` ([#969](https://github.com/voidzero-dev/vite-task/pull/969)). - **Added** `vp run --dry-run --json` prints what a run would do as one JSON document for scripts, CI and coding agents: each task the run would run, the tasks it waits for, and each step's program, arguments and directory, with whether it would hit the cache and, if not, every change found. Like `vp run --json`, it's indented in a terminal and on one line when captured, and its `$schema` field points to a JSON Schema that describes each field. A task that runs `vp run` shows that run's own plan as its step. A step that waits on one that would write files first, by running or by restoring outputs, is `unknown`, since its files may change before it starts. `--json` with a task requires `--dry-run` ([#966](https://github.com/voidzero-dev/vite-task/pull/966), [#967](https://github.com/voidzero-dev/vite-task/pull/967), [#968](https://github.com/voidzero-dev/vite-task/pull/968)). - **Changed** A cache miss now lists every change to the command, the task's cache settings and the envs it depends on, instead of the first: when a task's `input` and `output` settings changed together, or a tracked env var and a file changed together, `vp run --verbose` and `vp run --last-details` show each change on its own line. Without `-v`, files are checked only up to the first changed one; with `-v`, every input of a miss is checked, and every changed file is listed in path order. When the command, a setting or a `cache.env` var changed, only those changes are listed, since there's no cached run with the new ones to compare files with. A summary saved by an earlier version can't be shown by `vp run --last-details` ([#961](https://github.com/voidzero-dev/vite-task/pull/961), [#963](https://github.com/voidzero-dev/vite-task/pull/963)). - **Added** `vp run --dry-run` shows what a run would do without running anything: each command's line with its cache status, and the summary, as a real run prints them. The cache is checked as in a real run, the remote cache included, and nothing is written: no cache entry, no downloaded or restored outputs, and no change to what `vp run --last-details` shows. With `-v`, the detailed summary shows why each command would miss. Like other `vp run` flags, `--dry-run` goes before the task name; after it, it's passed to the task ([#960](https://github.com/voidzero-dev/vite-task/pull/960)). diff --git a/crates/vt/schemas/run-plan.json b/crates/vt/schemas/run-plan.json index dd5089e93..df7ce14aa 100644 --- a/crates/vt/schemas/run-plan.json +++ b/crates/vt/schemas/run-plan.json @@ -224,12 +224,144 @@ "type": "integer", "format": "uint64", "minimum": 0 + }, + "fingerprint": { + "description": "With `-vv`: everything the cached run depends on, changed or not: the env names and inputs the cache records for this step. Absent when it depends on none.", + "allOf": [ + { + "$ref": "#/definitions/Fingerprint" + } + ] + }, + "outputs": { + "description": "With `-vv`: the files a hit restores, by path. Directories don't get entries of their own. Absent when there are none. `\"notDownloaded\"` for a cached run from the remote cache that has outputs: a dry run doesn't download its archive, so its files aren't known.", + "allOf": [ + { + "$ref": "#/definitions/Outputs" + } + ] } }, "required": [ "durationMs" ] }, + "Fingerprint": { + "description": "Everything a cached run depends on.", + "type": "object", + "properties": { + "env": { + "description": "Env names, sorted. Absent when there are none.", + "type": "array", + "items": { + "type": "string" + } + }, + "inputs": { + "description": "The cached run's inputs, by path, with what is checked: the files it matched or read, the directories it opened, and the paths it found missing. Absent when there are none.", + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/PathFingerprint" + } + } + } + }, + "PathFingerprint": { + "description": "What a cached run recorded of a path it depends on.", + "oneOf": [ + { + "description": "A file, matched by `cache.input` or read by the task. Its content is checked.", + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "file" + }, + "hash": { + "description": "A hash of the content the cached run recorded. Compare it only with hashes from the same version of `vp run`: the algorithm can change.", + "type": "string" + }, + "mayBeMissing": { + "description": "The task rewrites this file, so it may be missing, as on a fresh checkout. When it is there, its content is still checked.", + "type": "boolean", + "const": true + } + }, + "required": [ + "type", + "hash" + ] + }, + { + "description": "A directory the task opened.", + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "dir" + }, + "entries": { + "description": "The names of the directory's entries, when the task listed it. Adding or removing an entry is a change. An empty array means the directory was empty. Absent when the task opened the directory without listing it: then only that it exists is checked.", + "type": "array", + "items": { + "type": "string" + } + }, + "mayBeMissing": { + "description": "The task creates this directory, as a folder or a link, so it may be missing, as on a fresh checkout. When it is there, it is still checked.", + "type": "boolean", + "const": true + } + }, + "required": [ + "type" + ] + }, + { + "description": "A path the task looked for and didn't find. Creating it is a change.", + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "missing" + } + }, + "required": [ + "type" + ] + } + ] + }, + "Outputs": { + "description": "The files a hit restores, or why they aren't listed.", + "anyOf": [ + { + "description": "The files, by path.", + "type": "array", + "items": { + "type": "string" + } + }, + { + "description": "Why the files aren't listed.", + "allOf": [ + { + "$ref": "#/definitions/Unlisted" + } + ] + } + ] + }, + "Unlisted": { + "description": "Why a hit's output files aren't listed.", + "oneOf": [ + { + "description": "The cached run is in the remote cache, and a dry run doesn't download its archive.", + "type": "string", + "const": "notDownloaded" + } + ] + }, "Hit": { "description": "A step that matches its cached run.", "type": "object", diff --git a/crates/vt/src/cli/mod.rs b/crates/vt/src/cli/mod.rs index 24fef6d3c..9f892cde0 100644 --- a/crates/vt/src/cli/mod.rs +++ b/crates/vt/src/cli/mod.rs @@ -58,9 +58,10 @@ pub struct RunFlags { pub ignore_depends_on: bool, /// Show full detailed summary after execution. A cache miss checks every - /// input, so the summary lists every change. - #[clap(default_value = "false", short = 'v', long)] - pub verbose: bool, + /// input, so the summary lists every change. Twice (`-vv`), it also lists + /// everything each cached run depends on, and its outputs. + #[clap(short = 'v', long, action = clap::ArgAction::Count)] + pub verbose: u8, /// Force caching on for all tasks and scripts. #[clap(long, conflicts_with = "no_cache")] diff --git a/crates/vt/src/session/cache/archive.rs b/crates/vt/src/session/cache/archive.rs index 480647299..6b59ed25d 100644 --- a/crates/vt/src/session/cache/archive.rs +++ b/crates/vt/src/session/cache/archive.rs @@ -6,6 +6,7 @@ use std::{ }; use vt_path::{AbsolutePath, RelativePathBuf}; +use vt_str::Str; /// Create a tar.zst archive from workspace-relative output paths and output /// folder paths. @@ -135,16 +136,22 @@ fn unpack_with_directory_links( Ok(()) } -/// Read a tar.zst archive from `reader` to the end without writing any files, -/// to check that it decodes. +/// Read a tar.zst output archive from `reader` to the end without writing any +/// files, which checks that it decodes, calling `on_file` with the path of +/// each file and symbolic link in it. Folders, which are stored so that +/// extracting recreates them, aren't passed. /// /// # Errors /// /// Returns an error if reading fails or the archive doesn't decode. -pub fn check_output_archive(reader: impl Read) -> io::Result<()> { +pub fn read_output_archive(reader: impl Read, mut on_file: impl FnMut(&str)) -> io::Result<()> { let mut archive = tar::Archive::new(zstd::Decoder::new(reader)?); for entry in archive.entries()? { - io::copy(&mut entry?, &mut io::sink())?; + let mut entry = entry?; + if !entry.header().entry_type().is_dir() { + on_file(&entry.path()?.to_string_lossy()); + } + io::copy(&mut entry, &mut io::sink())?; } // Decode the rest of the stream too, so a truncated zstd frame after the // end of the tar data is caught. @@ -152,6 +159,19 @@ pub fn check_output_archive(reader: impl Read) -> io::Result<()> { Ok(()) } +/// The files and symbolic links in the output archive at `archive_path`, +/// sorted by path: see [`read_output_archive`]. +/// +/// # Errors +/// +/// Returns an error if reading the archive fails or it doesn't decode. +pub fn list_output_files(archive_path: &AbsolutePath) -> io::Result> { + let mut paths = Vec::new(); + read_output_archive(File::open(archive_path.as_path())?, |path| paths.push(Str::from(path)))?; + paths.sort_unstable(); + Ok(paths) +} + #[cfg(test)] mod tests { use std::fs; @@ -160,6 +180,27 @@ mod tests { use super::*; + #[test] + fn listing_names_files_but_not_folders() { + let tmp = tempfile::TempDir::new().unwrap(); + let root = AbsolutePathBuf::new(tmp.path().to_path_buf()).unwrap(); + fs::create_dir_all(root.join("dist/empty").as_path()).unwrap(); + fs::write(root.join("dist/b.js").as_path(), "b").unwrap(); + fs::write(root.join("dist/a.js").as_path(), "a").unwrap(); + let rel = |path: &str| RelativePathBuf::new(path).unwrap(); + let archive = root.join("out.tar.zst"); + create_output_archive( + &root, + &[rel("dist/b.js"), rel("dist/a.js")], + &[rel("dist"), rel("dist/empty")], + &archive, + ) + .unwrap(); + + let files = list_output_files(&archive).unwrap(); + assert_eq!(files, [Str::from("dist/a.js"), Str::from("dist/b.js")]); + } + #[test] fn check_accepts_only_complete_archives() { let tmp = tempfile::TempDir::new().unwrap(); @@ -169,10 +210,10 @@ mod tests { let archive_path = dir.join("output.tar.zst"); create_output_archive(&dir, &[output], &[], &archive_path).unwrap(); let archive = std::fs::read(archive_path.as_path()).unwrap(); - check_output_archive(archive.as_slice()).unwrap(); + read_output_archive(archive.as_slice(), |_| {}).unwrap(); for corrupt in [&archive[..archive.len() - 1], b"corrupt"] { - assert!(check_output_archive(corrupt).is_err()); + assert!(read_output_archive(corrupt, |_| {}).is_err()); } } diff --git a/crates/vt/src/session/cache/display.rs b/crates/vt/src/session/cache/display.rs index bf1e170a7..de9da25be 100644 --- a/crates/vt/src/session/cache/display.rs +++ b/crates/vt/src/session/cache/display.rs @@ -197,6 +197,7 @@ mod tests { replayed_duration: std::time::Duration::ZERO, source, restores_outputs: false, + baseline: None, }; assert_eq!( diff --git a/crates/vt/src/session/cache/mod.rs b/crates/vt/src/session/cache/mod.rs index 8f80f8105..d3cd3f2bf 100644 --- a/crates/vt/src/session/cache/mod.rs +++ b/crates/vt/src/session/cache/mod.rs @@ -26,7 +26,7 @@ use tokio_util::sync::CancellationToken; use vt_casefold::EnvName; use vt_glob::path::PathGlobSet; use vt_graph::config::ResolvedGlobConfig; -use vt_path::{AbsolutePath, RelativePathBuf}; +use vt_path::{AbsolutePath, AbsolutePathBuf, RelativePathBuf}; use vt_plan::{ cache_metadata::{CacheMetadata, ExecutionCacheKey, SpawnFingerprint}, remote_cache::{RemoteCacheAccess, ResolvedRemoteCacheConfig}, @@ -42,7 +42,10 @@ use wincode::{ use self::remote::{ReadError, RemoteClients, RemoteUploads, Restore, UploadError}; use super::{ event::RunKind, - execute::{fingerprint::PostRunFingerprint, pipe::StdOutput}, + execute::{ + fingerprint::{PathFingerprint, PostRunFingerprint}, + pipe::StdOutput, + }, }; const TASK_CACHE_PREALLOCATION_SIZE_LIMIT: usize = 256 * 1024 * 1024; @@ -120,15 +123,65 @@ pub struct CacheEntryValue { #[derive(Debug)] pub struct ExecutionCache { conn: Mutex, + /// The folder that holds the database and the output archives. + dir: AbsolutePathBuf, remote_clients: RemoteClients, uploads: RemoteUploads, } +/// What `vp run -vv` shows of a cached run: everything it depends on, changed +/// or not, and its outputs. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct BaselineDetails { + /// The env names it depends on, sorted: its `cache.env`, and those that + /// runner-aware tools read or matched with a tracked query. + pub envs: Vec, + /// Its inputs, by path, with what is checked: the files it matched or + /// read, the folders it opened, and the paths it found missing. + pub inputs: BTreeMap, + /// The output files a hit restores. `None` when its archive couldn't be + /// read. + pub outputs: Option, +} + +/// The output files of a cached run, which a hit restores. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum BaselineOutputs { + /// The files, sorted. Empty when the run has no outputs. + Listed(Vec), + /// The run has outputs, but in an archive in the remote cache that wasn't + /// downloaded, as in a dry run, so they can't be listed. + NotDownloaded, +} + +/// The files in the output archive at `path`, or `None` if it can't be read. +fn list_outputs(path: &AbsolutePath) -> Option { + archive::list_output_files(path) + .inspect_err(|err| tracing::debug!(?err, "failed to list the outputs")) + .ok() + .map(BaselineOutputs::Listed) +} + +/// What a cached run recorded of a path it depends on. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum BaselinePath { + /// A file, with a hash of its content. The task rewrites a file that may + /// be missing. + File { hash: u64, may_be_missing: bool }, + /// A folder, with its entries, sorted, if the task listed it. The task + /// creates a folder that may be missing. + Dir { entries: Option>, may_be_missing: bool }, + /// A path the task looked for and didn't find. + Missing, +} + /// A cache hit: the entry to replay, and the cache it came from. #[derive(Debug)] pub struct CacheHit { pub value: CacheEntryValue, pub source: CacheHitSource, + /// With `vp run -vv`, what the entry depends on and its outputs. + pub baseline: Option>, } /// The cache a hit came from. @@ -169,6 +222,10 @@ pub struct Comparison { /// changed file or couldn't check some of them, or, after a command, /// config or env change, didn't run. pub changes_partial: bool, + /// With `vp run -vv`, what the cached run depends on and its outputs. + /// `None` after a command, config or env change, when only the previous + /// run's key is compared. + pub baseline: Option>, } impl Comparison { @@ -181,7 +238,7 @@ impl Comparison { ) -> Self { changes.sort_by(|a, b| a.order_key().cmp(&b.order_key())); changes.dedup_by(|a, b| a.order_key() == b.order_key()); - Self { changes, baseline_duration, changes_partial } + Self { changes, baseline_duration, changes_partial, baseline: None } } /// The miss for a cached run, `value`, that differs from the execution @@ -196,6 +253,11 @@ impl Comparison { fn of_key(old_key: &CacheEntryKey, current_key: &CacheEntryKey) -> Self { Self::new(old_key.changes_to(current_key), None, true) } + + /// The comparison, with `baseline` describing the cached run. + fn with_baseline(self, baseline: Option>) -> Self { + Self { baseline, ..self } + } } /// What comparing a cached run with the execution found. @@ -283,6 +345,21 @@ pub enum FileCheck { First, /// Check every file, so the miss lists every change (`vp run -v`). All, + /// Check every file, and describe the cached run: everything it depends + /// on, changed or not, and its outputs (`vp run -vv`). + AllAndBaseline, +} + +impl FileCheck { + /// The check that `-v` given `verbose` times asks for. + #[must_use] + pub const fn from_verbosity(verbose: u8) -> Self { + match verbose { + 0 => Self::First, + 1 => Self::All, + _ => Self::AllAndBaseline, + } + } } /// Where the cache records an env var. @@ -519,6 +596,7 @@ impl ExecutionCache { // Lock is released when lock_file is dropped Ok(Self { conn: Mutex::new(conn), + dir: path.to_absolute_path_buf(), remote_clients: RemoteClients::default(), uploads: RemoteUploads::default(), }) @@ -591,7 +669,7 @@ impl ExecutionCache { "looked up the local cache" ); let local_miss = match local_lookup { - Ok(value) => return Ok(Some(Ok(CacheHit { value, source: CacheHitSource::Local }))), + Ok(hit) => return Ok(Some(Ok(hit))), Err(miss) => miss, }; #[expect( @@ -630,7 +708,7 @@ impl ExecutionCache { "looked up the remote cache" ); let remote_miss = match remote_lookup { - Ok(value) => return Ok(Some(Ok(CacheHit { value, source: CacheHitSource::Remote }))), + Ok(hit) => return Ok(Some(Ok(hit))), Err(miss) => miss, }; Ok(Some(Err(match local_miss { @@ -649,34 +727,43 @@ impl ExecutionCache { validation: &Validation, run_kind: RunKind, cancel_token: &CancellationToken, - ) -> anyhow::Result>> { + ) -> anyhow::Result>> { let execution_cache_key = &cache_metadata.execution_cache_key; // Try to find the cache entry by key (spawn fingerprint + input config) if let Some(value_blob) = self.get_cache_entry_blob(cache_key).await? { - let validation = validation.clone(); + let (validation, key, dir) = (validation.clone(), cache_key.clone(), self.dir.clone()); let Some(checked) = run_off_thread(cancel_token, move || { let cache_value: CacheEntryValue = deserialize_cache(&value_blob)?; let checked = validation.validate(&cache_value)?; - anyhow::Ok((cache_value, checked)) + let baseline = Self::describe( + validation.file_check, + &key, + &cache_value, + CacheHitSource::Local, + &dir, + ); + anyhow::Ok((cache_value, checked, baseline)) }) .await else { return Ok(None); }; - let (cache_value, checked) = checked?; + let (cache_value, checked, baseline) = checked?; if !checked.changes.is_empty() { - return Ok(Some(Err(CacheMiss::Changed(Comparison::of_value( - &cache_value, - checked, - ))))); + let comparison = Comparison::of_value(&cache_value, checked); + return Ok(Some(Err(CacheMiss::Changed(comparison.with_baseline(baseline))))); } // Associate the execution key to the cache entry key if not already, // so that next time we can find it and report what changed if run_kind == RunKind::Real { self.upsert_task_fingerprint(execution_cache_key, cache_key).await?; } - return Ok(Some(Ok(cache_value))); + return Ok(Some(Ok(CacheHit { + value: cache_value, + source: CacheHitSource::Local, + baseline, + }))); } // No cache found with the current cache entry key, @@ -712,7 +799,7 @@ impl ExecutionCache { cache_dir: &AbsolutePath, run_kind: RunKind, cancel_token: &CancellationToken, - ) -> anyhow::Result> { + ) -> anyhow::Result> { let start = Instant::now(); let fetched = self .remote_clients @@ -731,12 +818,26 @@ impl ExecutionCache { elapsed = ?start.elapsed(), "fetched from the remote cache" ); - let (cache_key, validation) = (cache_key.clone(), validation.clone()); + let (cache_key, validation, dir) = + (cache_key.clone(), validation.clone(), self.dir.clone()); let check = move || { - remote::resolve(fetched, &cache_key, |cache_value| validation.validate(cache_value)) + remote::resolve( + fetched, + &cache_key, + |cache_value| validation.validate(cache_value), + |cache_value| { + Self::describe( + validation.file_check, + &cache_key, + cache_value, + CacheHitSource::Remote, + &dir, + ) + }, + ) }; let resolved = run_off_thread(cancel_token, check).await; - let Restore { value: cache_value, blob_id } = match resolved { + let Restore { value: cache_value, blob_id, baseline } = match resolved { Some(Ok(restore)) => restore, Some(Err(miss)) => return Ok(Err(miss)), None => return Ok(Err(ReadError::Cancelled.into_miss())), @@ -745,7 +846,11 @@ impl ExecutionCache { // blob to download, so the hit a dry run shows says whether it restores // outputs. if run_kind == RunKind::Dry { - return Ok(Ok(cache_value)); + return Ok(Ok(CacheHit { + value: cache_value, + source: CacheHitSource::Remote, + baseline, + })); } let output_archive = match blob_id { @@ -761,7 +866,80 @@ impl ExecutionCache { } None => None, }; - Ok(Ok(CacheEntryValue { output_archive, ..cache_value })) + // Now that the archive is here, `-vv` lists its files, as for a local + // hit. If the run is cancelled meanwhile, it isn't reported. + let baseline = match (baseline, &output_archive) { + (Some(details), Some(archive)) => { + let archive_path = cache_dir.join(archive.as_str()); + let outputs = run_off_thread(cancel_token, move || list_outputs(&archive_path)) + .await + .flatten(); + Some(Arc::new(BaselineDetails { outputs, ..BaselineDetails::clone(&details) })) + } + (baseline, _) => baseline, + }; + let value = CacheEntryValue { output_archive, ..cache_value }; + Ok(Ok(CacheHit { value, source: CacheHitSource::Remote, baseline })) + } + + /// With [`FileCheck::AllAndBaseline`], what `vp run -vv` shows of the + /// cached run `value`, stored under `key` in the cache `source`. A local + /// run's outputs are listed from its archive in `dir`, the local cache's + /// folder. A remote run's archive isn't downloaded yet, so its outputs are + /// [`BaselineOutputs::NotDownloaded`] until a real run downloads it. It + /// reads the archive, so lookups call it off the scheduler's thread. + fn describe( + file_check: FileCheck, + key: &CacheEntryKey, + value: &CacheEntryValue, + source: CacheHitSource, + dir: &AbsolutePath, + ) -> Option> { + if file_check != FileCheck::AllAndBaseline { + return None; + } + let fingerprint = &value.post_run_fingerprint; + // Env names compare by the platform's rules, so on Windows `FOO` in + // `cache.env` and a tool's `getEnv('foo')` are one name. + let mut envs: Vec = key + .spawn_fingerprint + .env_fingerprints() + .fingerprinted_envs + .keys() + .chain(fingerprint.tracked_envs.keys()) + .chain(fingerprint.tracked_env_queries.values().flat_map(BTreeMap::keys)) + .cloned() + .collect(); + envs.sort_by(|a, b| EnvName::new(a.as_str()).cmp(&EnvName::new(b.as_str()))); + envs.dedup_by(|a, b| EnvName::new(a.as_str()) == EnvName::new(b.as_str())); + let mut inputs: BTreeMap = value + .globbed_inputs + .iter() + .map(|(path, hash)| { + (path.clone(), BaselinePath::File { hash: *hash, may_be_missing: false }) + }) + .collect(); + for (path, input) in &fingerprint.inferred_inputs { + let recorded = match &input.fingerprint { + PathFingerprint::FileContentHash(hash) => { + BaselinePath::File { hash: *hash, may_be_missing: input.may_be_missing } + } + PathFingerprint::Folder(listing) => BaselinePath::Dir { + entries: listing + .as_ref() + .map(|listing| listing.entries.keys().cloned().collect()), + may_be_missing: input.may_be_missing, + }, + PathFingerprint::NotFound => BaselinePath::Missing, + }; + inputs.insert(path.clone(), recorded); + } + let outputs = match (source, &value.output_archive) { + (_, None) => Some(BaselineOutputs::Listed(Vec::new())), + (CacheHitSource::Remote, Some(_)) => Some(BaselineOutputs::NotDownloaded), + (CacheHitSource::Local, Some(archive)) => list_outputs(&dir.join(archive.as_str())), + }; + Some(Arc::new(BaselineDetails { envs, inputs, outputs })) } /// Record an entry locally. diff --git a/crates/vt/src/session/cache/remote.rs b/crates/vt/src/session/cache/remote.rs index 74a9a9e42..44c933b86 100644 --- a/crates/vt/src/session/cache/remote.rs +++ b/crates/vt/src/session/cache/remote.rs @@ -41,8 +41,8 @@ use wincode::{ }; use super::{ - CACHE_SCHEMA_VERSION, CacheEntryKey, CacheEntryValue, CacheMiss, Checked, Comparison, - TaskCacheConfig, archive, deserialize_cache, serialize_cache, + BaselineDetails, CACHE_SCHEMA_VERSION, CacheEntryKey, CacheEntryValue, CacheMiss, Checked, + Comparison, TaskCacheConfig, archive, deserialize_cache, serialize_cache, }; /// Why an entry wasn't uploaded. @@ -116,20 +116,23 @@ pub enum DecodeValueError { pub(super) struct Restore { pub value: CacheEntryValue, pub blob_id: Option, + /// With `vp run -vv`, what the entry depends on. + pub baseline: Option>, } /// Turn the result of a fetch into an entry to restore or a miss. An exact /// match's entries are checked newest first, and the first that passes is -/// restored. `validate` checks an entry against the current execution. If no -/// entry passes, the miss is the newest entry's: the changes it found, or a -/// read failure if it doesn't decode, its blob doesn't match its value, or it -/// can't be validated. A fallback's miss reason compares its key with -/// `cache_key`, and no match is a miss without an entry. A failed fetch is a -/// read failure. +/// restored. `validate` checks an entry against the current execution, and +/// `describe` describes it for `vp run -vv`. If no entry passes, the miss is +/// the newest entry's: the changes it found, or a read failure if it doesn't +/// decode, its blob doesn't match its value, or it can't be validated. A +/// fallback's miss reason compares its key with `cache_key`, and no match is +/// a miss without an entry. A failed fetch is a read failure. pub(super) fn resolve( fetched: Result, ReadError>, cache_key: &CacheEntryKey, mut validate: impl FnMut(&CacheEntryValue) -> anyhow::Result, + mut describe: impl FnMut(&CacheEntryValue) -> Option>, ) -> Result { let entries = match fetched.map_err(ReadError::into_miss)? { Some(Fetched::Exact { entries }) => entries, @@ -143,7 +146,7 @@ pub(super) fn resolve( for (index, entry) in entries.into_iter().enumerate() { let start = Instant::now(); let value_bytes = entry.value.len(); - let checked = check_entry(entry, &mut validate); + let checked = check_entry(entry, &mut validate, &mut describe); tracing::debug!( index, value_bytes, @@ -166,14 +169,21 @@ pub(super) fn resolve( fn check_entry( Entry { value, blob_id }: Entry, validate: &mut impl FnMut(&CacheEntryValue) -> anyhow::Result, + describe: &mut impl FnMut(&CacheEntryValue) -> Option>, ) -> Result { let value = decode_value(&value).map_err(|err| ReadError::CorruptValue(err).into_miss())?; if value.output_archive.is_some() != blob_id.is_some() { return Err(ReadError::MismatchedBlob.into_miss()); } match validate(&value) { - Ok(checked) if checked.changes.is_empty() => Ok(Restore { value, blob_id }), - Ok(checked) => Err(CacheMiss::Changed(Comparison::of_value(&value, checked))), + Ok(checked) if checked.changes.is_empty() => { + let baseline = describe(&value); + Ok(Restore { value, blob_id, baseline }) + } + Ok(checked) => { + let baseline = describe(&value); + Err(CacheMiss::Changed(Comparison::of_value(&value, checked).with_baseline(baseline))) + } Err(err) => Err(ReadError::Validate(err).into_miss()), } } @@ -405,7 +415,7 @@ async fn download_checked( let (sender, receiver) = mpsc::channel(CHECK_BUFFER_CHUNKS); let check = tokio::task::spawn_blocking(move || { let mut reader = DownloadReader { receiver, chunk: Bytes::new(), file, write_error: None }; - let checked = archive::check_output_archive(&mut reader); + let checked = archive::read_output_archive(&mut reader, |_| {}); if let Some(err) = reader.write_error { return Err(ReadError::WriteArchive(err)); } @@ -661,6 +671,10 @@ mod tests { } } + const fn no_baseline(_: &CacheEntryValue) -> Option> { + None + } + fn not_validated(_: &CacheEntryValue) -> anyhow::Result { panic!("only exact entries that decode and match their blob are validated") } @@ -677,7 +691,7 @@ mod tests { fn corrupt_value(value: Vec) -> Arc { let key = cache_key(ResolvedGlobConfig::default_auto()); let fetched = Ok(Some(Fetched::Exact { entries: vec![Entry { value, blob_id: None }] })); - match resolve(fetched, &key, not_validated).unwrap_err() { + match resolve(fetched, &key, not_validated, no_baseline).unwrap_err() { CacheMiss::RemoteReadFailed(error) => { assert_eq!(vt_str::format!("{error}"), "remote cache value is corrupt"); error @@ -689,9 +703,13 @@ mod tests { #[test] fn exact_entry_that_validates_is_restored() { let key = cache_key(ResolvedGlobConfig::default_auto()); - let restore = - resolve(Ok(Some(exact(&cache_value()))), &key, validate_against(BTreeMap::new())) - .unwrap(); + let restore = resolve( + Ok(Some(exact(&cache_value()))), + &key, + validate_against(BTreeMap::new()), + no_baseline, + ) + .unwrap(); assert_eq!(restore.blob_id.as_deref(), Some("1")); assert_eq!(restore.value.std_outputs[0].content, b"built\n"); assert_eq!(restore.value.duration, Duration::from_millis(5)); @@ -704,6 +722,7 @@ mod tests { Ok(Some(exact(&cache_value()))), &cache_key(ResolvedGlobConfig::default_auto()), validate_against(current_inputs), + no_baseline, ) .unwrap_err(); let CacheMiss::Changed(comparison) = &miss else { panic!("{miss:?}") }; @@ -720,9 +739,13 @@ mod tests { fn newest_entry_that_validates_is_restored() { let key = cache_key(ResolvedGlobConfig::default_auto()); let entries = vec![entry(&cache_value(), "2"), entry(&cache_value(), "1")]; - let restore = - resolve(Ok(Some(Fetched::Exact { entries })), &key, validate_against(BTreeMap::new())) - .unwrap(); + let restore = resolve( + Ok(Some(Fetched::Exact { entries })), + &key, + validate_against(BTreeMap::new()), + no_baseline, + ) + .unwrap(); assert_eq!(restore.blob_id.as_deref(), Some("2")); } @@ -737,10 +760,15 @@ mod tests { ]; let mut validated = 0; let mut validate = validate_against(inputs(&["src/a.txt"])); - let restore = resolve(Ok(Some(Fetched::Exact { entries })), &key, |value| { - validated += 1; - validate(value) - }) + let restore = resolve( + Ok(Some(Fetched::Exact { entries })), + &key, + |value| { + validated += 1; + validate(value) + }, + no_baseline, + ) .unwrap(); assert_eq!(restore.blob_id.as_deref(), Some("2")); // The corrupt entry isn't validated, and the entry after the restored @@ -756,6 +784,7 @@ mod tests { Ok(Some(Fetched::Exact { entries })), &key, validate_against(inputs(&["src/a.txt"])), + no_baseline, ) .unwrap_err(); assert_eq!(added_input(&miss), "src/a.txt"); @@ -768,6 +797,7 @@ mod tests { Ok(Some(Fetched::Exact { entries })), &key, validate_against(inputs(&["src/a.txt"])), + no_baseline, ) .unwrap_err(); assert_eq!(read_failure(miss), "remote cache value is corrupt"); @@ -785,6 +815,7 @@ mod tests { Ok(Some(exact(&value))), &cache_key(ResolvedGlobConfig::default_auto()), validate_against(BTreeMap::new()), + no_baseline, ) .unwrap_err(); assert_eq!(read_failure(miss), "remote cache entry couldn't be validated"); @@ -799,6 +830,7 @@ mod tests { Ok(Some(Fetched::Fallback { key: stored_key })), &cache_key(input_config), not_validated, + no_baseline, ) .unwrap_err(); // Only the keys are compared, so the files may have changed too, and @@ -817,7 +849,7 @@ mod tests { #[test] fn no_match_is_a_miss_without_an_entry() { let key = cache_key(ResolvedGlobConfig::default_auto()); - let miss = resolve(Ok(None), &key, not_validated).unwrap_err(); + let miss = resolve(Ok(None), &key, not_validated, no_baseline).unwrap_err(); assert!(matches!(miss, CacheMiss::NotFound), "{miss:?}"); } @@ -825,7 +857,7 @@ mod tests { fn failed_fetch_is_a_read_failure() { let key = cache_key(ResolvedGlobConfig::default_auto()); let fetched = Err(ReadError::Fetch(vt_remote_cache::Error::InvalidEndpoint(None))); - let miss = resolve(fetched, &key, not_validated).unwrap_err(); + let miss = resolve(fetched, &key, not_validated, no_baseline).unwrap_err(); assert_eq!(read_failure(miss), "remote cache fetch failed"); } @@ -892,7 +924,7 @@ mod tests { for (value, blob_id) in [(cache_value(), None), (without_archive, Some(Str::from("1")))] { let value = encode_value(&value).unwrap(); let fetched = Ok(Some(Fetched::Exact { entries: vec![Entry { value, blob_id }] })); - let miss = resolve(fetched, &key, not_validated).unwrap_err(); + let miss = resolve(fetched, &key, not_validated, no_baseline).unwrap_err(); assert_eq!(read_failure(miss), "remote cache entry's blob doesn't match its value"); } } @@ -917,7 +949,7 @@ mod tests { garbage.extend(b"not a cache key"); for stored_key in [b"not a cache key".to_vec(), other_schema, other_format, garbage] { let fetched = Ok(Some(Fetched::Fallback { key: stored_key })); - let miss = resolve(fetched, &key, not_validated).unwrap_err(); + let miss = resolve(fetched, &key, not_validated, no_baseline).unwrap_err(); assert_eq!(read_failure(miss), "remote cache key is corrupt"); } } diff --git a/crates/vt/src/session/cache/validation.rs b/crates/vt/src/session/cache/validation.rs index 307172008..93a67c377 100644 --- a/crates/vt/src/session/cache/validation.rs +++ b/crates/vt/src/session/cache/validation.rs @@ -19,8 +19,8 @@ impl CacheEntryValue { /// `unfiltered_envs`, the execution's `CacheMetadata::unfiltered_envs`. /// `input_negative_globs`, compiled from the entry key's input config, /// leave entries out of listings. Returns every env change and, with - /// [`FileCheck::First`], the first changed file found, or with - /// [`FileCheck::All`], every changed file. Nothing when the entry is valid. + /// [`FileCheck::First`], the first changed file found, or otherwise every + /// changed file. Nothing when the entry is valid. /// The changes are partial when the file check stopped early or couldn't /// check some files. /// @@ -41,7 +41,7 @@ impl CacheEntryValue { let limit = if file_check == FileCheck::First { 1 } else { usize::MAX }; let mut changes = globbed_input_changes(&self.globbed_inputs, globbed_inputs, limit); let mut partial = false; - if changes.is_empty() || file_check == FileCheck::All { + if changes.is_empty() || file_check != FileCheck::First { let input_changes = self.post_run_fingerprint.input_changes( workspace_root, input_negative_globs, diff --git a/crates/vt/src/session/event.rs b/crates/vt/src/session/event.rs index 60f69c76d..014209d47 100644 --- a/crates/vt/src/session/event.rs +++ b/crates/vt/src/session/event.rs @@ -7,7 +7,7 @@ use std::{ use vt_server::Error as IpcServerError; use vt_str::Str; -use super::cache::{CacheHitSource, CacheMiss, remote::UploadError}; +use super::cache::{BaselineDetails, CacheHitSource, CacheMiss, remote::UploadError}; /// The cache operation that failed. #[derive(Debug)] @@ -158,6 +158,8 @@ pub enum CacheStatus { source: CacheHitSource, /// The hit has output files, which a real run restores. restores_outputs: bool, + /// With `vp run -vv`, what the cached run depends on and its outputs. + baseline: Option>, }, } diff --git a/crates/vt/src/session/execute/fingerprint.rs b/crates/vt/src/session/execute/fingerprint.rs index 663412819..5ffc35745 100644 --- a/crates/vt/src/session/execute/fingerprint.rs +++ b/crates/vt/src/session/execute/fingerprint.rs @@ -280,7 +280,7 @@ impl PostRunFingerprint { .find_map_any(|input| check_input(input, 1).pop()) .into_iter() .collect(), - FileCheck::All => self + FileCheck::All | FileCheck::AllAndBaseline => self .inferred_inputs .par_iter() .flat_map_iter(|input| check_input(input, usize::MAX)) diff --git a/crates/vt/src/session/execute/mod.rs b/crates/vt/src/session/execute/mod.rs index efcf10a22..86cc5115e 100644 --- a/crates/vt/src/session/execute/mod.rs +++ b/crates/vt/src/session/execute/mod.rs @@ -455,6 +455,7 @@ async fn run( replayed_duration: hit.value.duration, source: hit.source, restores_outputs: hit.value.output_archive.is_some(), + baseline: hit.baseline.clone(), }); if run_kind == RunKind::Dry { return Ok(Report::CacheHit); diff --git a/crates/vt/src/session/mod.rs b/crates/vt/src/session/mod.rs index acc955d58..4700b9c3e 100644 --- a/crates/vt/src/session/mod.rs +++ b/crates/vt/src/session/mod.rs @@ -389,8 +389,7 @@ impl<'a> Session<'a> { })?; // `-v` lists every change, so the file check checks every file. - let file_check = - if run_command.flags.verbose { FileCheck::All } else { FileCheck::First }; + let file_check = FileCheck::from_verbosity(run_command.flags.verbose); self.execute_graph( graph, builder, @@ -867,7 +866,7 @@ impl<'a> Session<'a> { inner, workspace_path, Box::new(std::io::stdout()), - run_command.flags.verbose, + run_command.flags.verbose > 0, (run_kind == RunKind::Real).then(|| self.make_summary_writer()), self.program_name.clone(), run_kind, diff --git a/crates/vt/src/session/reporter/json/document.rs b/crates/vt/src/session/reporter/json/document.rs index cdec29a0a..3b4205bb4 100644 --- a/crates/vt/src/session/reporter/json/document.rs +++ b/crates/vt/src/session/reporter/json/document.rs @@ -197,6 +197,95 @@ pub struct Changed { pub struct Baseline { /// How long the cached run took, in milliseconds. On a hit, the time a replay saves. pub duration_ms: u64, + + /// With `-vv`: everything the cached run depends on, changed or not: the env names and + /// inputs the cache records for this step. Absent when it depends on none. + #[serde(skip_serializing_if = "Option::is_none")] + #[cfg_attr(test, schemars(with = "Fingerprint"))] + pub fingerprint: Option, + + /// With `-vv`: the files a hit restores, by path. Directories don't get entries of their + /// own. Absent when there are none. `"notDownloaded"` for a cached run from the remote + /// cache that has outputs: a dry run doesn't download its archive, so its files aren't + /// known. + #[serde(skip_serializing_if = "Option::is_none")] + #[cfg_attr(test, schemars(with = "Outputs"))] + pub outputs: Option, +} + +/// The files a hit restores, or why they aren't listed. +#[derive(Debug, Serialize)] +#[cfg_attr(test, derive(schemars::JsonSchema))] +#[serde(untagged)] +pub enum Outputs { + /// The files, by path. + Files(Vec), + /// Why the files aren't listed. + Unlisted(Unlisted), +} + +/// Why a hit's output files aren't listed. +#[derive(Debug, Serialize)] +#[cfg_attr(test, derive(schemars::JsonSchema))] +#[serde(rename_all = "camelCase")] +pub enum Unlisted { + /// The cached run is in the remote cache, and a dry run doesn't download its archive. + NotDownloaded, +} + +/// Everything a cached run depends on. +#[derive(Debug, Serialize)] +#[cfg_attr(test, derive(schemars::JsonSchema))] +#[serde(rename_all = "camelCase")] +pub struct Fingerprint { + /// Env names, sorted. Absent when there are none. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub env: Vec, + + /// The cached run's inputs, by path, with what is checked: the files it matched or read, + /// the directories it opened, and the paths it found missing. Absent when there are none. + #[serde(skip_serializing_if = "BTreeMap::is_empty")] + pub inputs: BTreeMap, +} + +/// What a cached run recorded of a path it depends on. +#[derive(Debug, Serialize)] +#[cfg_attr(test, derive(schemars::JsonSchema))] +#[serde(tag = "type", rename_all = "camelCase")] +pub enum PathFingerprint { + /// A file, matched by `cache.input` or read by the task. Its content is checked. + #[serde(rename_all = "camelCase")] + File { + /// A hash of the content the cached run recorded. Compare it only with hashes from the + /// same version of `vp run`: the algorithm can change. + hash: Str, + + /// The task rewrites this file, so it may be missing, as on a fresh checkout. When it + /// is there, its content is still checked. + #[serde(skip_serializing_if = "is_false")] + #[cfg_attr(test, schemars(with = "AlwaysTrue"))] + may_be_missing: bool, + }, + + /// A directory the task opened. + #[serde(rename_all = "camelCase")] + Dir { + /// The names of the directory's entries, when the task listed it. Adding or removing + /// an entry is a change. An empty array means the directory was empty. Absent when the + /// task opened the directory without listing it: then only that it exists is checked. + #[serde(skip_serializing_if = "Option::is_none")] + #[cfg_attr(test, schemars(with = "Vec"))] + entries: Option>, + + /// The task creates this directory, as a folder or a link, so it may be missing, as on a + /// fresh checkout. When it is there, it is still checked. + #[serde(skip_serializing_if = "is_false")] + #[cfg_attr(test, schemars(with = "AlwaysTrue"))] + may_be_missing: bool, + }, + + /// A path the task looked for and didn't find. Creating it is a change. + Missing, } /// One difference between a step and its cached run. diff --git a/crates/vt/src/session/reporter/json/mod.rs b/crates/vt/src/session/reporter/json/mod.rs index a2b86d45c..047e001b3 100644 --- a/crates/vt/src/session/reporter/json/mod.rs +++ b/crates/vt/src/session/reporter/json/mod.rs @@ -12,8 +12,8 @@ use std::{ }; use document::{ - Baseline, Cache, Change, Changed, CommandStep, Hit, HowChanged, NestedStep, PlanTask, RunPlan, - Setting, Step, + Baseline, Cache, Change, Changed, CommandStep, Fingerprint, Hit, HowChanged, NestedStep, + Outputs, PathFingerprint, PlanTask, RunPlan, Setting, Step, Unlisted, }; use rustc_hash::{FxHashMap, FxHashSet}; use vt_materialized_artifact::{Artifact, artifact}; @@ -29,7 +29,10 @@ use super::{ LeafPath, PipeWriters, StdioConfig, StdioSuggestion, }; use crate::session::{ - cache::{self, CacheMiss, Comparison, InputChangeKind}, + cache::{ + self, BaselineDetails, BaselineOutputs, BaselinePath, CacheMiss, Comparison, + InputChangeKind, + }, event::{CacheDisabledReason, CacheStatus, CacheUpdateStatus, ExecutionError}, json::{self, Version, path_key, task_id}, }; @@ -383,10 +386,12 @@ fn mark_step_unknown(cache: &mut Cache) { /// How the plan shows a cache status. `None` for a built-in, which isn't cached. fn cache_of(status: &CacheStatus) -> Option { Some(match status { - CacheStatus::Hit { replayed_duration, restores_outputs, .. } => Cache::Hit(Hit { - baseline: baseline(*replayed_duration), - restores_outputs: *restores_outputs, - }), + CacheStatus::Hit { replayed_duration, restores_outputs, baseline: details, .. } => { + Cache::Hit(Hit { + baseline: baseline(*replayed_duration, details.as_deref()), + restores_outputs: *restores_outputs, + }) + } CacheStatus::Miss(CacheMiss::NotFound) => Cache::NoBaseline, CacheStatus::Miss(CacheMiss::Changed(comparison)) => changed(comparison), CacheStatus::Miss(CacheMiss::RemoteReadFailed(error)) => { @@ -405,20 +410,64 @@ fn changed(comparison: &Comparison) -> Cache { // Untracked env names are one `untrackedEnv` setting. changes.dedup(); Cache::Changed(Changed { - baseline: comparison.baseline_duration.map(baseline), + baseline: comparison + .baseline_duration + .map(|duration| baseline(duration, comparison.baseline.as_deref())), changes, changes_partial: comparison.changes_partial, }) } -fn baseline(duration: Duration) -> Baseline { - Baseline { duration_ms: u64::try_from(duration.as_millis()).unwrap_or(u64::MAX) } +/// The cached run a step is compared with, described with `-vv`. As +/// everywhere in the document, an empty fingerprint or output list is left +/// out. +fn baseline(duration: Duration, details: Option<&BaselineDetails>) -> Baseline { + let duration_ms = u64::try_from(duration.as_millis()).unwrap_or(u64::MAX); + let Some(details) = details else { + return Baseline { duration_ms, fingerprint: None, outputs: None }; + }; + let inputs = details + .inputs + .iter() + .map(|(path, recorded)| { + let recorded = match recorded { + BaselinePath::File { hash, may_be_missing } => PathFingerprint::File { + hash: vt_str::format!("{hash:016x}"), + may_be_missing: *may_be_missing, + }, + BaselinePath::Dir { entries, may_be_missing } => PathFingerprint::Dir { + entries: entries.clone(), + may_be_missing: *may_be_missing, + }, + BaselinePath::Missing => PathFingerprint::Missing, + }; + (relative_key(path), recorded) + }) + .collect(); + let fingerprint = Fingerprint { env: details.envs.clone(), inputs }; + let has_fingerprint = !fingerprint.env.is_empty() || !fingerprint.inputs.is_empty(); + Baseline { + duration_ms, + fingerprint: has_fingerprint.then_some(fingerprint), + outputs: match &details.outputs { + Some(BaselineOutputs::Listed(files)) if !files.is_empty() => { + Some(Outputs::Files(files.clone())) + } + Some(BaselineOutputs::NotDownloaded) => { + Some(Outputs::Unlisted(Unlisted::NotDownloaded)) + } + Some(BaselineOutputs::Listed(_)) | None => None, + }, + } +} + +/// A path relative to the workspace root, with `"."` for the root itself. +fn relative_key(path: &vt_path::RelativePathBuf) -> Str { + if path.as_str().is_empty() { Str::from(".") } else { Str::from(path.as_str()) } } fn change_of(change: &cache::Change) -> Change { - let relative = |path: &vt_path::RelativePathBuf| { - if path.as_str().is_empty() { Str::from(".") } else { Str::from(path.as_str()) } - }; + let relative = relative_key; match change { cache::Change::Program { from, to } => { Change::Program { from: from.clone(), to: to.clone() } @@ -514,6 +563,7 @@ mod tests { ], baseline_duration: Some(Duration::from_millis(1830)), changes_partial: false, + baseline: None, }; let json = serde_json::to_value(changed(&comparison)).unwrap(); assert_eq!( @@ -542,7 +592,7 @@ mod tests { } fn hit(restores_outputs: bool) -> Cache { - Cache::Hit(Hit { baseline: baseline(Duration::ZERO), restores_outputs }) + Cache::Hit(Hit { baseline: baseline(Duration::ZERO, None), restores_outputs }) } fn task(depends_on: &[&str], steps: Vec) -> PlanTask { @@ -613,6 +663,61 @@ mod tests { ); } + /// A cached run that depends on nothing and has no outputs has neither + /// in its baseline: the document leaves out empty values. + #[test] + fn empty_fingerprint_and_outputs_are_left_out() { + let details = BaselineDetails { + envs: Vec::new(), + inputs: BTreeMap::new(), + outputs: Some(BaselineOutputs::Listed(Vec::new())), + }; + assert_eq!( + serde_json::to_value(baseline(Duration::ZERO, Some(&details))).unwrap(), + serde_json::json!({ "durationMs": 0 }) + ); + } + + /// A remote cached run's outputs, whose archive a dry run doesn't + /// download, are named as such rather than left out, which would read as + /// none. + #[test] + fn outputs_not_downloaded_are_named() { + let details = BaselineDetails { + envs: Vec::new(), + inputs: BTreeMap::new(), + outputs: Some(BaselineOutputs::NotDownloaded), + }; + assert_eq!( + serde_json::to_value(baseline(Duration::ZERO, Some(&details))).unwrap(), + serde_json::json!({ "durationMs": 0, "outputs": "notDownloaded" }) + ); + } + + /// An input's path is relative to the workspace root, and the + /// root itself is `"."`. A folder the task creates may be missing. + #[test] + fn input_paths_name_the_root_dot() { + let details = BaselineDetails { + envs: Vec::new(), + inputs: BTreeMap::from([( + vt_path::RelativePathBuf::default(), + BaselinePath::Dir { entries: Some(Vec::new()), may_be_missing: true }, + )]), + outputs: None, + }; + let json = serde_json::to_value(baseline(Duration::ZERO, Some(&details))).unwrap(); + assert_eq!( + json, + serde_json::json!({ + "durationMs": 0, + "fingerprint": { + "inputs": { ".": { "type": "dir", "entries": [], "mayBeMissing": true } }, + }, + }) + ); + } + /// After a config change, only the previous run's key is compared: the /// step is changed, with no `baseline`, and other files may have changed. #[test] @@ -621,6 +726,7 @@ mod tests { changes: vec![cache::Change::InputConfig], baseline_duration: None, changes_partial: true, + baseline: None, }; assert_eq!( serde_json::to_value(changed(&comparison)).unwrap(), diff --git a/crates/vt/src/session/reporter/summary.rs b/crates/vt/src/session/reporter/summary.rs index a1598e04a..fc1db4cc1 100644 --- a/crates/vt/src/session/reporter/summary.rs +++ b/crates/vt/src/session/reporter/summary.rs @@ -15,13 +15,18 @@ use std::{ }; use owo_colors::Style; +use rustc_hash::FxHashSet; use serde::{Deserialize, Serialize}; -use vt_path::AbsolutePath; +use vt_casefold::EnvName; +use vt_path::{AbsolutePath, RelativePath}; use vt_str::Str; use super::{CACHE_MISS_STYLE, COMMAND_STYLE, ColorizeExt}; use crate::session::{ - cache::{CacheHitSource, CacheMiss, Change, format_change}, + cache::{ + BaselineDetails, BaselineOutputs, BaselinePath, CacheHitSource, CacheMiss, Change, + InputChangeKind, format_change, + }, event::{CacheDisabledReason, CacheNotUpdatedReason, CacheStatus, CacheUpdateStatus, RunKind}, }; @@ -67,6 +72,9 @@ pub enum TaskResult { /// Summaries saved before the source was recorded read as local hits. #[serde(default)] source: CacheHitSource, + /// With `vp run -vv`, what the cached run depends on and its outputs. + #[serde(default, skip_serializing_if = "Option::is_none")] + baseline: Option, }, /// Cache hit whose output files couldn't be restored. Always a failure. @@ -145,6 +153,12 @@ pub enum SavedCacheMissReason { Changed { /// Each change, in the order they're listed. changes: Vec, + /// Some files or envs couldn't be checked, so `changes` may miss some. + #[serde(default, skip_serializing_if = "is_false")] + changes_partial: bool, + /// With `vp run -vv`, what the cached run depends on and its outputs. + #[serde(default, skip_serializing_if = "Option::is_none")] + baseline: Option, }, /// Reading the remote cache failed, and the local cache had no entry. RemoteReadFailed(SavedError), @@ -197,7 +211,7 @@ impl SummaryStats { for task in tasks { match &task.result { - TaskResult::CacheHit { saved_duration_ms, source } => { + TaskResult::CacheHit { saved_duration_ms, source, .. } => { stats.cache_hits += 1; if *source == CacheHitSource::Remote { stats.remote_cache_hits += 1; @@ -258,7 +272,11 @@ impl SavedCacheMissReason { fn from_cache_miss(cache_miss: &CacheMiss) -> Self { match cache_miss { CacheMiss::NotFound => Self::NotFound, - CacheMiss::Changed(comparison) => Self::Changed { changes: comparison.changes.clone() }, + CacheMiss::Changed(comparison) => Self::Changed { + changes: comparison.changes.clone(), + changes_partial: comparison.changes_partial, + baseline: comparison.baseline.as_deref().cloned(), + }, CacheMiss::RemoteReadFailed(error) => { Self::RemoteReadFailed(SavedError::new(error.as_ref())) } @@ -300,13 +318,15 @@ impl TaskResult { match cache_status { // The only error a cache hit can have is a failed restore. - CacheStatus::Hit { replayed_duration, source, .. } => saved_error.map_or_else( - || Self::CacheHit { - saved_duration_ms: duration_to_ms(*replayed_duration), - source: *source, - }, - |error| Self::RestoreFailed { source: *source, error: error.clone() }, - ), + CacheStatus::Hit { replayed_duration, source, baseline, .. } => saved_error + .map_or_else( + || Self::CacheHit { + saved_duration_ms: duration_to_ms(*replayed_duration), + source: *source, + baseline: baseline.as_deref().cloned(), + }, + |error| Self::RestoreFailed { source: *source, error: error.clone() }, + ), CacheStatus::Disabled(CacheDisabledReason::InProcessExecution) => Self::InProcess, CacheStatus::Disabled(CacheDisabledReason::NoCacheMetadata) => Self::Spawned { cache_status: SpawnedCacheStatus::Disabled, @@ -545,7 +565,7 @@ impl TaskResult { } let detail = match self { - Self::CacheHit { saved_duration_ms, source } => { + Self::CacheHit { saved_duration_ms, source, .. } => { let hit = format_hit(*source); match run_kind { RunKind::Real => { @@ -566,7 +586,7 @@ impl TaskResult { SavedCacheMissReason::NotFound => { Str::from("→ Cache miss: no previous cache entry found") } - SavedCacheMissReason::Changed { changes } => { + SavedCacheMissReason::Changed { changes, .. } => { let mut lines = changes.iter().map(format_change); let first = lines.next().unwrap_or_else(|| Str::from("configuration changed")); @@ -585,6 +605,25 @@ impl TaskResult { (detail, Vec::new(), &[]) } + /// With `vp run -vv`, the cached run the task was compared with, and the + /// changes found, or `None` for them when some files or envs couldn't be + /// checked, so what's unchanged isn't known. + fn baseline(&self) -> Option<(&BaselineDetails, Option<&[Change]>)> { + match self { + Self::CacheHit { baseline: Some(baseline), .. } => Some((baseline, Some(&[]))), + Self::Spawned { + cache_status: + SpawnedCacheStatus::Miss(SavedCacheMissReason::Changed { + changes, + changes_partial, + baseline: Some(baseline), + }), + .. + } => Some((baseline, (!changes_partial).then_some(changes.as_slice()))), + _ => None, + } + } + /// The [`Style`] for the cache detail line. const fn cache_detail_style(&self) -> Style { match self { @@ -815,6 +854,9 @@ pub fn format_full_summary(summary: &LastRunSummary, run_kind: RunKind) -> Vec Vec bool { + !*value +} + +/// Write what `vp run -vv` shows of a cached run: what it depends on that +/// didn't change, under `Unchanged:`, and its output files, under `Outputs:`, +/// each by path, aligned under the first, or `(not downloaded)` for a remote +/// run's archive that a dry run doesn't download. Without the complete +/// `changes`, what's unchanged isn't known, and only the outputs are written. +fn write_baseline(buf: &mut Vec, baseline: &BaselineDetails, changes: Option<&[Change]>) { + let style = Style::new().bright_black(); + let unchanged = changes.map_or_else(Vec::new, |changes| unchanged_lines(baseline, changes)); + let outputs: Vec = match &baseline.outputs { + Some(BaselineOutputs::Listed(files)) => { + files.iter().map(|path| vt_str::format!("'{path}'")).collect() + } + Some(BaselineOutputs::NotDownloaded) => vec![Str::from("(not downloaded)")], + None => Vec::new(), + }; + for (label, lines) in [("Unchanged:", unchanged), ("Outputs:", outputs)] { + for (index, line) in lines.iter().enumerate() { + let label = if index == 0 { label } else { "" }; + let _ = writeln!(buf, " {}", vt_str::format!("{label:<11}{line}").style(style)); + } + } +} + +/// The lines of what a cached run depends on that none of `changes` changed: +/// its inputs, then its env names. +fn unchanged_lines(baseline: &BaselineDetails, changes: &[Change]) -> Vec { + let mut changed_paths: FxHashSet<&RelativePath> = FxHashSet::default(); + // The folders whose listing changed: an entry was added or removed, not + // just changed. + let mut changed_listings: FxHashSet<&RelativePath> = FxHashSet::default(); + // Env names compare by the platform's rules, as the changes do. + let mut changed_envs: FxHashSet> = FxHashSet::default(); + for change in changes { + match change { + Change::File { path, kind } => { + changed_paths.insert(path.as_relative_path()); + if matches!(kind, InputChangeKind::Added | InputChangeKind::Removed) + && let Some(parent) = path.as_relative_path().parent() + { + changed_listings.insert(parent); + } + } + Change::Env { mismatch, .. } => { + changed_envs.insert(EnvName::new(mismatch.name().as_str())); + } + _ => {} + } + } + let mut unchanged: Vec = baseline + .inputs + .iter() + .filter(|(path, recorded)| { + let path = path.as_relative_path(); + let listed = matches!(recorded, BaselinePath::Dir { entries: Some(_), .. }); + let differs = + changed_paths.contains(path) || (listed && changed_listings.contains(path)); + !differs + }) + .map(|(path, recorded)| { + let path = quoted_path(path); + match recorded { + BaselinePath::File { .. } => path, + BaselinePath::Dir { entries: Some(_), .. } => vt_str::format!("{path} (listing)"), + BaselinePath::Dir { entries: None, .. } => vt_str::format!("{path} (folder)"), + BaselinePath::Missing => vt_str::format!("{path} (missing)"), + } + }) + .collect(); + unchanged.extend( + baseline + .envs + .iter() + .filter(|name| !changed_envs.contains(&EnvName::new(name.as_str()))) + .map(|name| vt_str::format!("env '{name}'")), + ); + unchanged +} + +/// `path` in quotes, or `workspace root` for the root itself, as the miss +/// reasons name it. +fn quoted_path(path: &RelativePath) -> Str { + if path.as_str().is_empty() { + Str::from("workspace root") + } else { + vt_str::format!("'{}'", path.as_str()) + } +} + /// Write a task detail line with `label` and `error`'s message, then each of /// its causes on its own line below. fn write_error_lines(buf: &mut Vec, label: impl Display, error: &SavedError, style: Style) { @@ -1052,6 +1190,10 @@ pub fn format_uploads_pending(count: NonZeroUsize) -> Vec { #[cfg(test)] mod tests { + use std::collections::BTreeMap; + + use vt_path::RelativePathBuf; + use super::*; use crate::session::event::ExecutionError; @@ -1092,7 +1234,7 @@ mod tests { task_name: Str::from(task_name), command: Str::from("build"), cwd: Str::default(), - result: TaskResult::CacheHit { saved_duration_ms: 1000, source }, + result: TaskResult::CacheHit { saved_duration_ms: 1000, source, baseline: None }, } } @@ -1138,6 +1280,8 @@ mod tests { if let TaskResult::Spawned { cache_status, .. } = &mut task.result { *cache_status = SpawnedCacheStatus::Miss(SavedCacheMissReason::Changed { changes: vec![Change::InputConfig, Change::OutputConfig], + changes_partial: false, + baseline: None, }); } let summary = full_summary(vec![task]); @@ -1152,6 +1296,97 @@ mod tests { ); } + fn baseline_details() -> BaselineDetails { + let rel = |path| RelativePathBuf::new(path).unwrap(); + BaselineDetails { + envs: vec![Str::from("NODE_ENV")], + inputs: BTreeMap::from([ + ( + rel("src"), + BaselinePath::Dir { + entries: Some(vec![Str::from("a.txt")]), + may_be_missing: false, + }, + ), + (rel("src/a.txt"), BaselinePath::File { hash: 1, may_be_missing: false }), + ]), + outputs: Some(BaselineOutputs::Listed(vec![Str::from("dist/out.txt")])), + } + } + + fn baseline_text(changes: Option<&[Change]>) -> Str { + let mut buf = Vec::new(); + write_baseline(&mut buf, &baseline_details(), changes); + strip(&buf) + } + + /// A changed file's content leaves the folder listing it unchanged, and + /// an added entry changes it. + #[test] + fn unchanged_leaves_out_only_what_changed() { + let file = |kind, path| Change::File { kind, path: RelativePathBuf::new(path).unwrap() }; + assert_eq!( + baseline_text(Some(&[file(InputChangeKind::ContentModified, "src/a.txt")])).as_str(), + " Unchanged: 'src' (listing)\n env 'NODE_ENV'\n \ + Outputs: 'dist/out.txt'\n" + ); + assert_eq!( + baseline_text(Some(&[file(InputChangeKind::Added, "src/b.txt")])).as_str(), + " Unchanged: 'src/a.txt'\n env 'NODE_ENV'\n \ + Outputs: 'dist/out.txt'\n" + ); + } + + /// The workspace root reads as in the miss reasons, not as `''`. + #[test] + fn workspace_root_is_named() { + let root = RelativePathBuf::new("").unwrap(); + let baseline = BaselineDetails { + envs: Vec::new(), + inputs: BTreeMap::from([( + root, + BaselinePath::Dir { + entries: Some(vec![Str::from("a.txt")]), + may_be_missing: false, + }, + )]), + outputs: Some(BaselineOutputs::Listed(Vec::new())), + }; + let mut buf = Vec::new(); + write_baseline(&mut buf, &baseline, Some(&[])); + assert_eq!(strip(&buf).as_str(), " Unchanged: workspace root (listing)\n"); + } + + /// A changed env isn't unchanged under another spelling that the + /// platform treats as the same name: on Windows, `node_env` is `NODE_ENV`. + #[test] + fn changed_envs_compare_by_platform_rules() { + let change = Change::Env { + mismatch: crate::session::cache::EnvMismatch::Changed { name: Str::from("node_env") }, + source: crate::session::cache::EnvSource::Tracked, + }; + let text = baseline_text(Some(&[change])); + assert_eq!(text.as_str().contains("env 'NODE_ENV'"), !cfg!(windows), "{text}"); + } + + /// When some files or envs couldn't be checked, what's unchanged isn't + /// known, so only the outputs are listed. + #[test] + fn partial_changes_list_only_the_outputs() { + assert_eq!(baseline_text(None).as_str(), " Outputs: 'dist/out.txt'\n"); + } + + /// A remote run's outputs that a dry run doesn't download say so, + /// instead of reading as none. + #[test] + fn outputs_not_downloaded_say_so() { + let baseline = + BaselineDetails { outputs: Some(BaselineOutputs::NotDownloaded), ..baseline_details() }; + let mut buf = Vec::new(); + write_baseline(&mut buf, &baseline, None); + assert_eq!(strip(&buf).as_str(), " Outputs: (not downloaded)\n"); + } + /// A dry run whose tasks all have caching disabled still gets its /// summary line. The other dry-run summaries are in the `dry_run` e2e /// snapshots. @@ -1253,7 +1488,20 @@ mod tests { .unwrap(); assert!(matches!( summary.tasks[0].result, - TaskResult::CacheHit { saved_duration_ms: 5, source: CacheHitSource::Local } + TaskResult::CacheHit { saved_duration_ms: 5, source: CacheHitSource::Local, .. } + )); + } + + /// A miss saved before `-vv` existed, without `changes_partial` or + /// `baseline`, still reads. + #[test] + fn saved_miss_without_baseline_reads() { + let reason: SavedCacheMissReason = + serde_json::from_str(r#"{"Changed":{"changes":["InputConfig"]}}"#).unwrap(); + assert!(matches!( + reason, + SavedCacheMissReason::Changed { ref changes, changes_partial: false, baseline: None } + if changes.as_slice() == [Change::InputConfig] )); } diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache/snapshots.toml b/crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache/snapshots.toml index c43b596dc..5f5e3f4b6 100644 --- a/crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache/snapshots.toml +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache/snapshots.toml @@ -968,3 +968,103 @@ steps = [ ], ], comment = "Nothing can listen on port 0. The failed fetch is a miss, as in a real run." }, ] + +[[e2e]] +name = "very_verbose_remote_hit" +cfg = 'not(target_env = "musl")' +ignore = true +comment = """ +With `-vv`, a remote hit's outputs are listed once its archive is downloaded. A dry run doesn't download it, so it says they aren't, and a real run lists them. A remote hit without outputs has none to list either way. +""" +steps = [ + [ + "remote-cache-server", + "start", + ], + { argv = [ + "remote-cache-server", + "run", + "--github-actions", + "vt", + "run", + "build", + ], envs = [ + [ + "VP_REMOTE_CACHE", + "read-write", + ], + [ + "VP_RUN_INTERNAL_HIDE_PENDING_UPLOADS", + "1", + ], + ] }, + { argv = [ + "remote-cache-server", + "run", + "--github-actions", + "vt", + "run", + "check", + ], envs = [ + [ + "VP_REMOTE_CACHE", + "read-write", + ], + [ + "VP_RUN_INTERNAL_HIDE_PENDING_UPLOADS", + "1", + ], + ] }, + [ + "vt", + "cache", + "clean", + ], + { argv = [ + "remote-cache-server", + "run", + "vt", + "run", + "--dry-run", + "-vv", + "build", + ], comment = "A dry run doesn't download the archive, so the outputs aren't listed." }, + { argv = [ + "remote-cache-server", + "run", + "vtt", + "pipe-stdout", + "--", + "vt", + "run", + "--dry-run", + "--json", + "-vv", + "build", + ], comment = "In the JSON, they're `notDownloaded`. Hashes depend on file contents, so they're redacted.", redact = { '"\$schema":"[^"]*"' = '"$$schema":""', '"program":"[^"]*"' = '"program":""', '"durationMs":\d+' = '"durationMs":', '"hash":"[0-9a-f]{16}"' = '"hash":""' } }, + { argv = [ + "remote-cache-server", + "run", + "vtt", + "pipe-stdout", + "--", + "vt", + "run", + "--dry-run", + "--json", + "-vv", + "check", + ], comment = "`check` has no outputs, which the entry says without its archive, so there's no `outputs`.", redact = { '"\$schema":"[^"]*"' = '"$$schema":""', '"program":"[^"]*"' = '"program":""', '"durationMs":\d+' = '"durationMs":', '"hash":"[0-9a-f]{16}"' = '"hash":""' } }, + { argv = [ + "remote-cache-server", + "run", + "vt", + "run", + "-vv", + "build", + ], comment = "A real run downloads the archive, so it lists the outputs it restores." }, + [ + "remote-cache-server", + "stop", + ], +] diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache/snapshots/very_verbose_remote_hit.md b/crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache/snapshots/very_verbose_remote_hit.md new file mode 100644 index 000000000..a0711cf7d --- /dev/null +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache/snapshots/very_verbose_remote_hit.md @@ -0,0 +1,107 @@ +# very_verbose_remote_hit + +With `-vv`, a remote hit's outputs are listed once its archive is downloaded. A dry run doesn't download it, so it says they aren't, and a real run lists them. A remote hit without outputs has none to list either way. + +## `remote-cache-server start` + +``` +``` + +## `VP_REMOTE_CACHE=read-write VP_RUN_INTERNAL_HIDE_PENDING_UPLOADS=1 remote-cache-server run --github-actions vt run build` + +``` +$ vtt write-file dist/output.txt built + +[remote-cache] POST /fetch 404 +[remote-cache] POST /store 200 +``` + +## `VP_REMOTE_CACHE=read-write VP_RUN_INTERNAL_HIDE_PENDING_UPLOADS=1 remote-cache-server run --github-actions vt run check` + +``` +$ vtt print checked +checked + +[remote-cache] POST /fetch 404 +[remote-cache] POST /store 200 +``` + +## `vt cache clean` + +``` +``` + +## `remote-cache-server run vt run --dry-run -vv build` + +A dry run doesn't download the archive, so the outputs aren't listed. + +``` +$ vtt write-file dist/output.txt built ◉ remote cache hit + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + Vite+ Task Runner • Execution Summary +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +Statistics: 1 task • 1 cache hit (1 remote) • 0 cache misses +Performance: 100% cache hit rate + +Task Details: +──────────────────────────────────────────────── + [1] remote-cache#build: $ vtt write-file dist/output.txt built + → Remote cache hit + Unchanged: 'src/a.txt' + Outputs: (not downloaded) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +[remote-cache] POST /fetch 200 exact [1] +``` + +## `remote-cache-server run vtt pipe-stdout -- vt run --dry-run --json -vv build` + +In the JSON, they're `notDownloaded`. Hashes depend on file contents, so they're redacted. + +``` +--- stdout --- +{"$schema":"","version":1,"workspaceRoot":"","plan":{"build":{"package":".","name":"build","steps":[{"program":"","args":["write-file","dist/output.txt","built"],"cache":{"status":"hit","baseline":{"durationMs":,"fingerprint":{"inputs":{"src/a.txt":{"type":"file","hash":""}}},"outputs":"notDownloaded"}}}]}}} +[remote-cache] POST /fetch 200 exact [1] +``` + +## `remote-cache-server run vtt pipe-stdout -- vt run --dry-run --json -vv check` + +`check` has no outputs, which the entry says without its archive, so there's no `outputs`. + +``` +--- stdout --- +{"$schema":"","version":1,"workspaceRoot":"","plan":{"check":{"package":".","name":"check","steps":[{"program":"","args":["print","checked"],"cache":{"status":"hit","baseline":{"durationMs":,"fingerprint":{"inputs":{"src/a.txt":{"type":"file","hash":""}}}}}}]}}} +[remote-cache] POST /fetch 200 exact [-] +``` + +## `remote-cache-server run vt run -vv build` + +A real run downloads the archive, so it lists the outputs it restores. + +``` +$ vtt write-file dist/output.txt built ◉ remote cache hit, replaying + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + Vite+ Task Runner • Execution Summary +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +Statistics: 1 task • 1 cache hit (1 remote) • 0 cache misses +Performance: 100% cache hit rate + +Task Details: +──────────────────────────────────────────────── + [1] remote-cache#build: $ vtt write-file dist/output.txt built ✓ + → Remote cache hit - output replayed - + Unchanged: 'src/a.txt' + Outputs: 'dist/output.txt' +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +[remote-cache] POST /fetch 200 exact [1] +[remote-cache] GET /blob/1 200 +``` + +## `remote-cache-server stop` + +``` +``` diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/very_verbose/package.json b/crates/vt_bin/tests/e2e_snapshots/fixtures/very_verbose/package.json new file mode 100644 index 000000000..384c8b3c9 --- /dev/null +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/very_verbose/package.json @@ -0,0 +1,4 @@ +{ + "name": "very-verbose", + "private": true +} diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/very_verbose/snapshots.toml b/crates/vt_bin/tests/e2e_snapshots/fixtures/very_verbose/snapshots.toml new file mode 100644 index 000000000..4bf73008a --- /dev/null +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/very_verbose/snapshots.toml @@ -0,0 +1,54 @@ +[[e2e]] +name = "lists_what_cached_runs_depend_on" +comment = """ +With `-vv`, the detailed summary also lists what each cached run depends on that didn't change, and the files it restores. The steps list a folder, read a file, look for a missing one, and write an output. +""" +steps = [ + [ + "vt", + "run", + "vv", + ], + { argv = [ + "vt", + "run", + "--dry-run", + "-vv", + "vv", + ], comment = "Every step hits." }, + [ + "vtt", + "write-file", + "src/b.txt", + "new", + ], + { argv = [ + "vt", + "run", + "--dry-run", + "-vv", + "vv", + ], comment = "The listed folder changed, so it isn't among what's unchanged." }, + { argv = [ + "vtt", + "pipe-stdout", + "--", + "vt", + "run", + "--dry-run", + "--json", + "-vv", + "vv", + ], comment = "The JSON has each baseline's fingerprint and outputs. Hashes depend on file contents, so they're redacted.", redact = { '"\$schema":"[^"]*"' = '"$$schema":""', '"program":"[^"]*"' = '"program":""', '"durationMs":\d+' = '"durationMs":', '"hash":"[0-9a-f]{16}"' = '"hash":""' } }, + { argv = [ + "vt", + "run", + "-vv", + "vv", + ], comment = "A real run lists the same." }, + { argv = [ + "vt", + "run", + "--last-details", + ], comment = "So does the saved summary." }, +] diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/very_verbose/snapshots/lists_what_cached_runs_depend_on.md b/crates/vt_bin/tests/e2e_snapshots/fixtures/very_verbose/snapshots/lists_what_cached_runs_depend_on.md new file mode 100644 index 000000000..ab922ee1b --- /dev/null +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/very_verbose/snapshots/lists_what_cached_runs_depend_on.md @@ -0,0 +1,184 @@ +# lists_what_cached_runs_depend_on + +With `-vv`, the detailed summary also lists what each cached run depends on that didn't change, and the files it restores. The steps list a folder, read a file, look for a missing one, and write an output. + +## `vt run vv` + +``` +$ vtt list-dir src +a.txt + +$ vtt print-file src/a.txt +a + +$ vtt stat-file missing.txt +missing.txt: missing + +$ vtt write-file dist/out.txt built + +--- +vt run: 0/4 cache hit (0%), 4/4 successful. (Run `vt run --last-details` for full details) +``` + +## `vt run --dry-run -vv vv` + +Every step hits. + +``` +$ vtt list-dir src ◉ cache hit +$ vtt print-file src/a.txt ◉ cache hit +$ vtt stat-file missing.txt ◉ cache hit +$ vtt write-file dist/out.txt built ◉ cache hit + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + Vite+ Task Runner • Execution Summary +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +Statistics: 4 tasks • 4 cache hits • 0 cache misses +Performance: 100% cache hit rate + +Task Details: +──────────────────────────────────────────────── + [1] very-verbose#vv: $ vtt list-dir src + → Cache hit + Unchanged: 'src' (listing) + ······················································· + [2] very-verbose#vv: $ vtt print-file src/a.txt + → Cache hit + Unchanged: 'src/a.txt' + ······················································· + [3] very-verbose#vv: $ vtt stat-file missing.txt + → Cache hit + Unchanged: 'missing.txt' (missing) + ······················································· + [4] very-verbose#vv: $ vtt write-file dist/out.txt built + → Cache hit + Outputs: 'dist/out.txt' +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +``` + +## `vtt write-file src/b.txt new` + +``` +``` + +## `vt run --dry-run -vv vv` + +The listed folder changed, so it isn't among what's unchanged. + +``` +$ vtt list-dir src ○ cache miss: 'b.txt' added in 'src' +$ vtt print-file src/a.txt ◉ cache hit +$ vtt stat-file missing.txt ◉ cache hit +$ vtt write-file dist/out.txt built ◉ cache hit + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + Vite+ Task Runner • Execution Summary +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +Statistics: 4 tasks • 3 cache hits • 1 cache miss +Performance: 75% cache hit rate + +Task Details: +──────────────────────────────────────────────── + [1] very-verbose#vv: $ vtt list-dir src + → Cache miss: 'b.txt' added in 'src' + ······················································· + [2] very-verbose#vv: $ vtt print-file src/a.txt + → Cache hit + Unchanged: 'src/a.txt' + ······················································· + [3] very-verbose#vv: $ vtt stat-file missing.txt + → Cache hit + Unchanged: 'missing.txt' (missing) + ······················································· + [4] very-verbose#vv: $ vtt write-file dist/out.txt built + → Cache hit + Outputs: 'dist/out.txt' +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +``` + +## `vtt pipe-stdout -- vt run --dry-run --json -vv vv` + +The JSON has each baseline's fingerprint and outputs. Hashes depend on file contents, so they're redacted. + +``` +--- stdout --- +{"$schema":"","version":1,"workspaceRoot":"","plan":{"vv":{"package":".","name":"vv","steps":[{"program":"","args":["list-dir","src"],"cache":{"status":"changed","baseline":{"durationMs":,"fingerprint":{"inputs":{"src":{"type":"dir","entries":["a.txt"]}}}},"changes":[{"kind":"file","path":"src/b.txt","change":"added"}]}},{"program":"","args":["print-file","src/a.txt"],"cache":{"status":"unknown","baseline":{"durationMs":,"fingerprint":{"inputs":{"src/a.txt":{"type":"file","hash":""}}}}}},{"program":"","args":["stat-file","missing.txt"],"cache":{"status":"unknown","baseline":{"durationMs":,"fingerprint":{"inputs":{"missing.txt":{"type":"missing"}}}}}},{"program":"","args":["write-file","dist/out.txt","built"],"cache":{"status":"unknown","baseline":{"durationMs":,"outputs":["dist/out.txt"]}}}]}}} +``` + +## `vt run -vv vv` + +A real run lists the same. + +``` +$ vtt list-dir src ○ cache miss: 'b.txt' added in 'src', executing +a.txt +b.txt + +$ vtt print-file src/a.txt ◉ cache hit, replaying +a + +$ vtt stat-file missing.txt ◉ cache hit, replaying +missing.txt: missing + +$ vtt write-file dist/out.txt built ◉ cache hit, replaying + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + Vite+ Task Runner • Execution Summary +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +Statistics: 4 tasks • 3 cache hits • 1 cache miss +Performance: 75% cache hit rate + +Task Details: +──────────────────────────────────────────────── + [1] very-verbose#vv: $ vtt list-dir src ✓ + → Cache miss: 'b.txt' added in 'src' + ······················································· + [2] very-verbose#vv: $ vtt print-file src/a.txt ✓ + → Cache hit - output replayed - + Unchanged: 'src/a.txt' + ······················································· + [3] very-verbose#vv: $ vtt stat-file missing.txt ✓ + → Cache hit - output replayed - + Unchanged: 'missing.txt' (missing) + ······················································· + [4] very-verbose#vv: $ vtt write-file dist/out.txt built ✓ + → Cache hit - output replayed - + Outputs: 'dist/out.txt' +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +``` + +## `vt run --last-details` + +So does the saved summary. + +``` + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + Vite+ Task Runner • Execution Summary +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +Statistics: 4 tasks • 3 cache hits • 1 cache miss +Performance: 75% cache hit rate + +Task Details: +──────────────────────────────────────────────── + [1] very-verbose#vv: $ vtt list-dir src ✓ + → Cache miss: 'b.txt' added in 'src' + ······················································· + [2] very-verbose#vv: $ vtt print-file src/a.txt ✓ + → Cache hit - output replayed - + Unchanged: 'src/a.txt' + ······················································· + [3] very-verbose#vv: $ vtt stat-file missing.txt ✓ + → Cache hit - output replayed - + Unchanged: 'missing.txt' (missing) + ······················································· + [4] very-verbose#vv: $ vtt write-file dist/out.txt built ✓ + → Cache hit - output replayed - + Outputs: 'dist/out.txt' +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +``` diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/very_verbose/src/a.txt b/crates/vt_bin/tests/e2e_snapshots/fixtures/very_verbose/src/a.txt new file mode 100644 index 000000000..789819226 --- /dev/null +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/very_verbose/src/a.txt @@ -0,0 +1 @@ +a diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/very_verbose/vite-task.json b/crates/vt_bin/tests/e2e_snapshots/fixtures/very_verbose/vite-task.json new file mode 100644 index 000000000..bdc3e7423 --- /dev/null +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/very_verbose/vite-task.json @@ -0,0 +1,8 @@ +{ + "tasks": { + "vv": { + "command": "vtt list-dir src && vtt print-file src/a.txt && vtt stat-file missing.txt && vtt write-file dist/out.txt built", + "cache": true + } + } +} diff --git a/docs/rfcs/dry-run.md b/docs/rfcs/dry-run.md index c2595a2f4..169899c1e 100644 --- a/docs/rfcs/dry-run.md +++ b/docs/rfcs/dry-run.md @@ -102,15 +102,17 @@ Task Details: [1] @acme/ui#build: ~/packages/ui$ tsdown → Cache miss: 'packages/ui/src/button.tsx' modified 'packages/ui/src/theme.css' modified - Unchanged: 'packages/ui/package.json' + Unchanged: 'packages/ui/.browserslistrc' (missing) + 'packages/ui/package.json' 'packages/ui/src' (listing) 'packages/ui/src/index.ts' 'packages/ui/tsdown.config.ts' - 'packages/ui/.browserslistrc' (missing) Outputs: 'packages/ui/dist/index.d.ts' 'packages/ui/dist/index.js' ``` +Both lists are in path order. A folder the task listed reads `(listing)`, one it only opened `(folder)`, and a path it looked for and didn't find `(missing)`; the env names it depends on follow, as `env 'NODE_ENV'`. A dry run doesn't download the archive of a cached run from the remote cache, so its outputs read `(not downloaded)`; a real run downloads it and lists them. A miss after a command, config or env change lists neither, since only the key of the step's most recent cached run is compared, not the run itself. + ### `--json` `--json` prints the same answer as a document. With `--dry-run`, that is the run plan: @@ -196,7 +198,7 @@ vp run --dry-run --json -r build "baseline": { "durationMs": 1830, "fingerprint": { - "paths": { + "inputs": { "packages/ui/.browserslistrc": { "type": "missing" }, "packages/ui/package.json": { "type": "file", "hash": "8f2c41d09a7be356" }, "packages/ui/src": { "type": "dir", "entries": ["button.tsx", "index.ts", "theme.css"] }, @@ -350,14 +352,17 @@ type RunPlan = { /** * With `-vv`: everything the cached run depends on, changed or not: the env - * names and paths the cache records for this step. + * names and inputs the cache records for this step. Absent when it depends on none. */ fingerprint?: { /** Env names. */ env?: string[]; - /** Each path the cached run depends on, with what is checked. */ - paths?: { + /** + * The cached run's inputs, by path, with what is checked: the files it matched or + * read, the directories it opened, and the paths it found missing. + */ + inputs?: { [path: string]: | { /** A file, matched by `cache.input` or read by the task. Its content is checked. */ @@ -386,6 +391,12 @@ type RunPlan = { * task opened the directory without listing it: then only that it exists is checked. */ entries?: string[]; + + /** + * The task creates this directory, as a folder or a link, so it may be missing, as + * on a fresh checkout. When it is there, it is still checked. + */ + mayBeMissing?: true; } | { /** A path the task looked for and didn't find. Creating it is a change. */ @@ -397,9 +408,11 @@ type RunPlan = { /** * With `-vv`: the files the cached run archived as its outputs, which a hit restores. * Only files are listed: `packages/ui/dist/index.js` appears, but its directory - * `packages/ui/dist` doesn't get an entry of its own. + * `packages/ui/dist` doesn't get an entry of its own. Absent when there are none. + * `'notDownloaded'` for a cached run from the remote cache that has outputs: a dry run + * doesn't download its archive, so its files aren't known. */ - outputs?: string[]; + outputs?: string[] | 'notDownloaded'; }; /**