diff --git a/CHANGELOG.md b/CHANGELOG.md index 5aa6da994..5a035665e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ - **Changed** The run summary now says a task that wrote a file it also read was `not cached because it modified its inputs`, and the statistics in `vp run --verbose` and `vp run --last-details` use the singular for a count of one, e.g. `1 task • 1 cache miss` ([#783](https://github.com/voidzero-dev/vite-task/pull/783)). - **Fixed** An invalid glob in `--filter` no longer shows its error message twice ([#763](https://github.com/voidzero-dev/vite-task/pull/763)). - **Changed** The detailed summary from `vp run --verbose` and `vp run --last-details` now shows each underlying cause of an error on its own line ([#761](https://github.com/voidzero-dev/vite-task/pull/761)). -- **Added** Remote caching. Configure an endpoint with the workspace's `cache: { remote: { url } }` or `VP_REMOTE_CACHE_URL`, and choose access with `--remote-cache=off|read|read-write` or `VP_REMOTE_CACHE`. The default is `read` with an endpoint and `off` without one. After a local cache miss, `vp run` looks the task up in the remote cache and, on a hit, restores its outputs and caches it locally. The task output and the run summary show which hits came from the remote cache. A failed read is just a cache miss, with the failure as its reason. In `read-write` mode, `vp run` also uploads the results of successful, cacheable tasks after caching them locally. A failed upload doesn't fail the task; the run summary shows a warning instead. Ctrl-C, or a failing task, stops remote cache requests right away, and a task still being looked up doesn't start. Tasks can opt out with `cache: { remote: false }`. Requests use the proxy environment variables or, on macOS and Windows, the system proxy settings ([#727](https://github.com/voidzero-dev/vite-task/pull/727), [#755](https://github.com/voidzero-dev/vite-task/pull/755), [#756](https://github.com/voidzero-dev/vite-task/pull/756), [#757](https://github.com/voidzero-dev/vite-task/pull/757), [#764](https://github.com/voidzero-dev/vite-task/pull/764), [#771](https://github.com/voidzero-dev/vite-task/pull/771), [#772](https://github.com/voidzero-dev/vite-task/pull/772), [#786](https://github.com/voidzero-dev/vite-task/pull/786)). +- **Added** Remote caching. Configure an endpoint with the workspace's `cache: { remote: { url } }` or `VP_REMOTE_CACHE_URL`, and choose access with `--remote-cache=off|read|read-write` or `VP_REMOTE_CACHE`. The default is `read` with an endpoint and `off` without one. After a local cache miss, `vp run` looks the task up in the remote cache and, on a hit, restores its outputs and caches it locally. The task output and the run summary show which hits came from the remote cache. A failed read is just a cache miss, with the failure as its reason. In `read-write` mode, `vp run` also uploads the results of successful, cacheable tasks after caching them locally. A failed upload doesn't fail the task; the run summary shows a warning instead. Ctrl-C, or a failing task, stops remote cache requests right away, and a task still being looked up doesn't start. Tasks can opt out with `cache: { remote: false }`. Requests use the proxy environment variables or, on macOS and Windows, the system proxy settings ([#727](https://github.com/voidzero-dev/vite-task/pull/727), [#755](https://github.com/voidzero-dev/vite-task/pull/755), [#756](https://github.com/voidzero-dev/vite-task/pull/756), [#757](https://github.com/voidzero-dev/vite-task/pull/757), [#764](https://github.com/voidzero-dev/vite-task/pull/764), [#770](https://github.com/voidzero-dev/vite-task/pull/770), [#771](https://github.com/voidzero-dev/vite-task/pull/771), [#772](https://github.com/voidzero-dev/vite-task/pull/772), [#786](https://github.com/voidzero-dev/vite-task/pull/786)). - **Fixed** On Windows, environment variable names used by `vp run` now match regardless of ASCII letter case. Assignments in task commands override earlier assignments and inherited variables spelled differently, and `FORCE_COLOR`, `VP_RUN_CONCURRENCY_LIMIT`, and variables requested through `@voidzero-dev/vite-task-client` are found under any spelling ([#747](https://github.com/voidzero-dev/vite-task/pull/747)). - **Changed** A task's cache settings now go inside `cache`, e.g. `cache: { env: ["NODE_ENV"], input: ["src/**"] }`; `cache: true` is the same as `cache: {}`. `env`, `untrackedEnv`, `input`, and `output` are no longer supported at the top level of a task ([#749](https://github.com/voidzero-dev/vite-task/pull/749)). - **Fixed** Cached tasks on macOS no longer intermittently fail with exit 2 and `oils I/O error (main): No such process` when a fast command finishes before the shell gets scheduled. The bundled shell that runs task commands is updated to Oils 0.38.0, which fixes this race ([#702](https://github.com/voidzero-dev/vite-task/issues/702), [#703](https://github.com/voidzero-dev/vite-task/pull/703)). diff --git a/crates/vt/src/session/cache/mod.rs b/crates/vt/src/session/cache/mod.rs index fe3fcfdd4..c93d22235 100644 --- a/crates/vt/src/session/cache/mod.rs +++ b/crates/vt/src/session/cache/mod.rs @@ -127,7 +127,8 @@ pub enum CacheHitSource { /// The local cache. #[default] Local, - /// The remote cache. The entry has since been recorded locally. + /// The remote cache. The entry is recorded locally once its outputs are + /// restored. Remote, } @@ -315,10 +316,11 @@ impl ExecutionCache { /// Returns `Ok(Ok(cache_hit))` on cache hit, `Ok(Err(cache_miss))` on miss. /// /// After a local miss, the remote cache is queried if the task has one. A - /// remote hit is recorded locally, with its output archive downloaded into - /// `cache_dir`, and is never uploaded. If the local cache has an entry for - /// the task, its miss reason is kept. Otherwise the reason comes from the - /// remote cache. Remote requests stop when `cancel_token` is cancelled. + /// remote hit has its output archive downloaded into `cache_dir`, is + /// recorded locally by [`Self::restore`], and is never uploaded. If the + /// local cache has an entry for the task, its miss reason is kept. + /// Otherwise the reason comes from the remote cache. Remote requests stop + /// when `cancel_token` is cancelled. #[tracing::instrument(level = "debug", skip_all)] pub async fn try_hit( &self, @@ -408,10 +410,10 @@ impl ExecutionCache { } /// Fetch the entry from the remote cache at `endpoint`. An exact entry - /// that passes validation is a hit once its output archive is downloaded - /// and the entry is recorded locally. A fallback entry, a failed - /// validation, or a failed read is a miss. An error while validating - /// counts as a failed read, so the remote entry never fails the task. + /// that passes validation is a hit once its output archive is downloaded. + /// A fallback entry, a failed validation, or a failed read is a miss. An + /// error while validating counts as a failed read, so the remote entry + /// never fails the task. #[expect(clippy::too_many_arguments, reason = "forwarded from `try_hit`")] async fn try_hit_remote( &self, @@ -449,10 +451,7 @@ impl ExecutionCache { } None => None, }; - let cache_value = CacheEntryValue { output_archive, ..cache_value }; - self.record(cache_key, &cache_metadata.execution_cache_key, &cache_value, cache_dir) - .await?; - Ok(Ok(cache_value)) + Ok(Ok(CacheEntryValue { output_archive, ..cache_value })) } /// Record an entry locally. @@ -519,6 +518,44 @@ impl ExecutionCache { } Ok(upload) } + + /// Restore the output files of `hit` into `workspace_root`. + /// + /// A remote hit is recorded locally once its outputs are restored. If + /// they can't be, its downloaded archive is removed instead, so the next + /// run fetches it again. Returns an error if the outputs can't be + /// restored. + pub async fn restore( + &self, + cache_metadata: &CacheMetadata, + hit: &CacheHit, + workspace_root: &AbsolutePath, + cache_dir: &AbsolutePath, + ) -> anyhow::Result<()> { + if let Some(archive_name) = &hit.value.output_archive { + let archive_path = cache_dir.join(archive_name.as_str()); + if let Err(err) = archive::extract_output_archive(workspace_root, &archive_path) { + if hit.source == CacheHitSource::Remote { + // Best-effort: the file may already be missing. + let _ = std::fs::remove_file(archive_path.as_path()); + } + return Err(err.context("failed to extract the output archive")); + } + } + + if hit.source == CacheHitSource::Remote { + let cache_key = CacheEntryKey::from_metadata(cache_metadata); + if let Err(err) = self + .record(&cache_key, &cache_metadata.execution_cache_key, &hit.value, cache_dir) + .await + { + // The outputs are restored, so the task still succeeds. The + // next run fetches the entry from the remote cache again. + tracing::warn!(?err, "failed to record a remote cache hit locally"); + } + } + Ok(()) + } } // Basic database operations diff --git a/crates/vt/src/session/event.rs b/crates/vt/src/session/event.rs index 7459e90b4..c52300569 100644 --- a/crates/vt/src/session/event.rs +++ b/crates/vt/src/session/event.rs @@ -2,6 +2,7 @@ use std::{process::ExitStatus, time::Duration}; use vt_path::RelativePathBuf; use vt_server::Error as IpcServerError; +use vt_str::Str; use super::cache::{CacheHitSource, CacheMiss, remote::UploadError}; @@ -10,6 +11,9 @@ use super::cache::{CacheHitSource, CacheMiss, remote::UploadError}; pub enum CacheErrorKind { /// Cache lookup (`try_hit`) failed. Lookup, + /// Restoring the output files of a remote cache hit failed. A local hit + /// fails with [`ExecutionError::LocalCacheRestore`] instead. + Restore, /// Writing the cache entry failed after successful execution. Update, } @@ -18,6 +22,7 @@ impl std::fmt::Display for CacheErrorKind { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::Lookup => f.write_str("lookup"), + Self::Restore => f.write_str("restore"), Self::Update => f.write_str("update"), } } @@ -37,6 +42,16 @@ pub enum ExecutionError { source: anyhow::Error, }, + /// Restoring the output files of a local cache hit failed. The entry + /// stays in the cache, so later runs fail the same way until the cache is + /// cleared. + #[error("Cache restore failed. Run `{program_name} cache clean` to clear the cache")] + LocalCacheRestore { + program_name: Str, + #[source] + source: anyhow::Error, + }, + /// The OS failed to spawn the child process (e.g., command not found). #[error("Failed to spawn process")] Spawn(#[source] anyhow::Error), diff --git a/crates/vt/src/session/execute/mod.rs b/crates/vt/src/session/execute/mod.rs index bb8bad7f8..0426f060f 100644 --- a/crates/vt/src/session/execute/mod.rs +++ b/crates/vt/src/session/execute/mod.rs @@ -31,7 +31,7 @@ use self::{ spawn::{ChildHandle, ChildOutcome, SpawnStdio, spawn}, }; use super::{ - cache::{CacheEntryValue, CacheHit, CacheMiss, ExecutionCache, archive}, + cache::{CacheHit, CacheHitSource, CacheMiss, ExecutionCache}, event::{ CacheDisabledReason, CacheErrorKind, CacheNotUpdatedReason, CacheStatus, CacheUpdateStatus, ExecutionError, @@ -410,16 +410,21 @@ async fn run( // runs exactly once on every arm) and either replay the hit — no need // to execute the command — or carry the globbed inputs into the run. let (stdio_config, globbed_inputs) = match lookup { - CacheLookup::Hit(CacheHit { value: cached, source }) => { - let mut stdio_config = - reporter.start(CacheStatus::Hit { replayed_duration: cached.duration, source }); + CacheLookup::Hit { hit, metadata } => { + let mut stdio_config = reporter.start(CacheStatus::Hit { + replayed_duration: hit.value.duration, + source: hit.source, + }); return Ok(replay_cache_hit( &mut stdio_config, - &cached, + &hit, + cache, + metadata, workspace_root, cache_dir, program_name, - )); + ) + .await); } CacheLookup::Miss { miss, globbed_inputs } => { (reporter.start(CacheStatus::Miss(miss)), globbed_inputs) @@ -525,9 +530,10 @@ async fn run( /// outcome provides: a hit owns the cached entry to replay, a miss keeps the /// reason plus the globbed inputs (reused by the cache-update phase after the /// run), and disabled has neither. -enum CacheLookup { - /// Cache hit — the cached entry to replay, and where it came from. - Hit(CacheHit), +enum CacheLookup<'a> { + /// Cache hit — the cached entry to replay, where it came from, and the + /// metadata that looked it up. + Hit { hit: CacheHit, metadata: &'a CacheMetadata }, /// Cache miss — the detailed reason (`NotFound` or `FingerprintMismatch`). Miss { miss: CacheMiss, globbed_inputs: BTreeMap }, /// Caching is disabled for this task (no cache metadata). @@ -537,13 +543,13 @@ enum CacheLookup { /// Phase 1: compute the globbed inputs and try to hit the cache. A remote hit /// downloads its output archive into `cache_dir`. Remote requests stop when /// `cancel_token` is cancelled. -async fn lookup_cache( - cache_metadata: Option<&CacheMetadata>, +async fn lookup_cache<'a>( + cache_metadata: Option<&'a CacheMetadata>, cache: &ExecutionCache, workspace_root: &Arc, cache_dir: &AbsolutePath, cancel_token: &CancellationToken, -) -> Result { +) -> Result, Report> { let Some(cache_metadata) = cache_metadata else { return Ok(CacheLookup::Disabled); }; @@ -563,7 +569,7 @@ async fn lookup_cache( .try_hit(cache_metadata, &globbed_inputs, workspace_root, cache_dir, cancel_token) .await { - Ok(Ok(cached)) => Ok(CacheLookup::Hit(cached)), + Ok(Ok(hit)) => Ok(CacheLookup::Hit { hit, metadata: cache_metadata }), Ok(Err(miss)) => Ok(CacheLookup::Miss { miss, globbed_inputs }), Err(err) => { Err(Report::failed(ExecutionError::Cache { kind: CacheErrorKind::Lookup, source: err })) @@ -572,15 +578,17 @@ async fn lookup_cache( } /// Phase 3 (cache hit): replay the captured stdout/stderr and restore the -/// output archive. -fn replay_cache_hit( +/// output files. +async fn replay_cache_hit( stdio_config: &mut StdioConfig, - cached: &CacheEntryValue, + hit: &CacheHit, + cache: &ExecutionCache, + cache_metadata: &CacheMetadata, workspace_root: &Arc, cache_dir: &AbsolutePath, program_name: &str, ) -> Report { - for output in cached.std_outputs.iter() { + for output in hit.value.std_outputs.iter() { let writer: &mut dyn std::io::Write = match output.kind { pipe::OutputKind::StdOut => &mut stdio_config.writers.stdout_writer, pipe::OutputKind::StdErr => &mut stdio_config.writers.stderr_writer, @@ -589,24 +597,23 @@ fn replay_cache_hit( let _ = writer.flush(); } - // Restore output files from the cached archive. Failure here means the - // archive file is missing, truncated, or otherwise unreadable — the - // task can't proceed because the cache promised the outputs would be - // restored. Surface a recovery instruction rather than just the raw - // I/O error so users know to clear the cache. - if let Some(ref archive_name) = cached.output_archive { - let archive_path = cache_dir.join(archive_name.as_str()); - if let Err(err) = archive::extract_output_archive(workspace_root, &archive_path) { - let err = err.context(vt_str::format!( - "failed to restore cached outputs from {}; the archive may have been deleted \ - or corrupted. Run `{program_name} cache clean` to clear the cache.", - archive_path.as_path().display() - )); - return Report::Failed { - cache_update: CacheUpdateStatus::NotUpdated(CacheNotUpdatedReason::CacheHit), - error: ExecutionError::Cache { kind: CacheErrorKind::Lookup, source: err }, - }; - } + // Failure here means the archive is missing or unreadable, or its files + // can't be written. The task fails because the cache promised the + // outputs would be restored. + if let Err(err) = cache.restore(cache_metadata, hit, workspace_root, cache_dir).await { + let error = match hit.source { + CacheHitSource::Local => { + ExecutionError::LocalCacheRestore { program_name: program_name.into(), source: err } + } + // Not recorded locally, so there's no entry to clear. + CacheHitSource::Remote => { + ExecutionError::Cache { kind: CacheErrorKind::Restore, source: err } + } + }; + return Report::Failed { + cache_update: CacheUpdateStatus::NotUpdated(CacheNotUpdatedReason::CacheHit), + error, + }; } Report::CacheHit diff --git a/crates/vt/src/session/reporter/summary.rs b/crates/vt/src/session/reporter/summary.rs index 36e89a05c..14936fc4f 100644 --- a/crates/vt/src/session/reporter/summary.rs +++ b/crates/vt/src/session/reporter/summary.rs @@ -73,6 +73,9 @@ pub enum TaskResult { source: CacheHitSource, }, + /// Cache hit whose output files couldn't be restored. Always a failure. + RestoreFailed { source: CacheHitSource, error: SavedError }, + /// In-process execution (built-in command like echo). Always successful. InProcess, @@ -90,8 +93,8 @@ pub enum TaskResult { /// - `Miss`: cache lookup found no match or a mismatch. /// - `Disabled`: no cache configuration for this task. /// -/// `Hit` and `InProcessExecution` are handled by [`TaskResult::CacheHit`] -/// and [`TaskResult::InProcess`] respectively. +/// `Hit` is handled by [`TaskResult::CacheHit`] or [`TaskResult::RestoreFailed`], +/// and `InProcessExecution` by [`TaskResult::InProcess`]. #[derive(Serialize, Deserialize)] pub enum SpawnedCacheStatus { Miss(SavedCacheMissReason), @@ -227,6 +230,7 @@ impl SummaryStats { } stats.total_saved += Duration::from_millis(*saved_duration_ms); } + TaskResult::RestoreFailed { .. } => stats.failed += 1, TaskResult::InProcess => { stats.cache_disabled += 1; } @@ -351,10 +355,14 @@ impl TaskResult { }; match cache_status { - CacheStatus::Hit { replayed_duration, source } => Self::CacheHit { - saved_duration_ms: duration_to_ms(*replayed_duration), - source: *source, - }, + // 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::Disabled(CacheDisabledReason::InProcessExecution) => Self::InProcess, CacheStatus::Disabled(CacheDisabledReason::NoCacheMetadata) => Self::Spawned { cache_status: SpawnedCacheStatus::Disabled, @@ -537,6 +545,7 @@ impl TaskResult { const fn is_success(&self) -> bool { match self { Self::CacheHit { .. } | Self::InProcess => true, + Self::RestoreFailed { .. } => false, Self::Spawned { outcome, .. } => matches!(outcome, SpawnOutcome::Success { .. }), } } @@ -548,6 +557,7 @@ impl TaskResult { /// Examples: /// - "→ Cache hit - output replayed - 102.96ms saved" /// - "→ Remote cache hit - output replayed - 102.96ms saved" + /// - "→ Cache hit, but the outputs couldn't be restored" /// - "→ Cache miss: no previous cache entry found" /// - "→ Cache disabled in task configuration" fn format_cache_detail(&self) -> (Str, &[Str]) { @@ -596,12 +606,12 @@ impl TaskResult { Self::CacheHit { saved_duration_ms, source } => { let d = Duration::from_millis(*saved_duration_ms); let formatted_duration = format_summary_duration(d); - let hit = match source { - CacheHitSource::Local => "Cache hit", - CacheHitSource::Remote => "Remote cache hit", - }; + let hit = format_hit(*source); vt_str::format!("→ {hit} - output replayed - {formatted_duration} saved") } + Self::RestoreFailed { source, .. } => { + vt_str::format!("→ {}, but the outputs couldn't be restored", format_hit(*source)) + } Self::InProcess => Str::from("→ Cache disabled for built-in command"), Self::Spawned { cache_status, .. } => match cache_status { SpawnedCacheStatus::Disabled => Str::from("→ Cache disabled in task configuration"), @@ -643,6 +653,7 @@ impl TaskResult { const fn cache_detail_style(&self) -> Style { match self { Self::CacheHit { .. } => Style::new().green(), + Self::RestoreFailed { .. } => Style::new().red(), Self::InProcess => Style::new().bright_black(), Self::Spawned { cache_status: SpawnedCacheStatus::Disabled, .. } => { Style::new().bright_black() @@ -685,6 +696,7 @@ impl TaskResult { pub const fn error(&self) -> Option<&SavedError> { match self { Self::CacheHit { .. } | Self::InProcess => None, + Self::RestoreFailed { error, .. } => Some(error), Self::Spawned { outcome, .. } => match outcome { SpawnOutcome::Success { infra_error, .. } => infra_error.as_ref(), SpawnOutcome::Failed { .. } => None, @@ -694,6 +706,14 @@ impl TaskResult { } } +/// "Cache hit" or "Remote cache hit", for the full summary's detail line. +const fn format_hit(source: CacheHitSource) -> &'static str { + match source { + CacheHitSource::Local => "Cache hit", + CacheHitSource::Remote => "Remote cache hit", + } +} + // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ // Full summary rendering (--verbose and --last-details) // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ diff --git a/crates/vt_bin/src/vtt/rm.rs b/crates/vt_bin/src/vtt/rm.rs index a8f98d6c0..52a623ccb 100644 --- a/crates/vt_bin/src/vtt/rm.rs +++ b/crates/vt_bin/src/vtt/rm.rs @@ -1,4 +1,17 @@ +/// Remove files and directories. +/// +/// Usage: `vtt rm [-rf] ...` or `vtt rm --ext ` +/// +/// With `--ext`, every file under ``, recursively, whose name ends with +/// `` is removed instead. Like `vtt list-dir --recursive`, this lets +/// tests remove cache archives without hardcoding their names or the +/// per-schema-version subdirectory they live under. pub fn run(args: &[String]) -> Result<(), Box> { + if let [flag, suffix, dir] = args + && flag == "--ext" + { + return remove_with_suffix(std::path::Path::new(dir), suffix); + } let mut recursive = false; let mut paths = Vec::new(); for arg in args { @@ -8,7 +21,7 @@ pub fn run(args: &[String]) -> Result<(), Box> { } } if paths.is_empty() { - return Err("Usage: vtt rm [-rf] ...".into()); + return Err("Usage: vtt rm [-rf] ... | vtt rm --ext ".into()); } for path in paths { let p = std::path::Path::new(path); @@ -20,3 +33,18 @@ pub fn run(args: &[String]) -> Result<(), Box> { } Ok(()) } + +fn remove_with_suffix( + dir: &std::path::Path, + suffix: &str, +) -> Result<(), Box> { + for entry in std::fs::read_dir(dir)? { + let entry = entry?; + if entry.file_type()?.is_dir() { + remove_with_suffix(&entry.path(), suffix)?; + } else if entry.file_name().to_string_lossy().ends_with(suffix) { + std::fs::remove_file(entry.path())?; + } + } + Ok(()) +} diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/output_cache_test/snapshots.toml b/crates/vt_bin/tests/e2e_snapshots/fixtures/output_cache_test/snapshots.toml index c72cc53f3..8ba3fa56a 100644 --- a/crates/vt_bin/tests/e2e_snapshots/fixtures/output_cache_test/snapshots.toml +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/output_cache_test/snapshots.toml @@ -32,6 +32,46 @@ steps = [ ], comment = "file restored from archive" }, ] +[[e2e]] +name = "output_globs___missing_archive_fails_cache_hit" +comment = """ +When the archive of a cache hit is missing, its output files can't be restored. The run fails and suggests clearing the cache, after which the task executes again. +""" +steps = [ + { argv = [ + "vt", + "run", + "build", + ], comment = "first run — cache miss, writes the archive" }, + { argv = [ + "vtt", + "rm", + "--ext", + ".tar.zst", + "node_modules/.vite/task-cache", + ], comment = "delete the archive" }, + { argv = [ + "vt", + "run", + "build", + ], comment = "second run — cache hit, but restoring fails, so the run fails" }, + { argv = [ + "vt", + "run", + "--last-details", + ], comment = "the task is reported as failed, with its error" }, + { argv = [ + "vt", + "cache", + "clean", + ], comment = "clear the cache, as the error suggests" }, + { argv = [ + "vt", + "run", + "build", + ], comment = "third run — cache miss, so the task executes" }, +] + [[e2e]] name = "output_globs___old_archive_removed_on_rewrite" comment = """ diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/output_cache_test/snapshots/output_globs___missing_archive_fails_cache_hit.md b/crates/vt_bin/tests/e2e_snapshots/fixtures/output_cache_test/snapshots/output_globs___missing_archive_fails_cache_hit.md new file mode 100644 index 000000000..dc13f8766 --- /dev/null +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/output_cache_test/snapshots/output_globs___missing_archive_fails_cache_hit.md @@ -0,0 +1,69 @@ +# output_globs___missing_archive_fails_cache_hit + +When the archive of a cache hit is missing, its output files can't be restored. The run fails and suggests clearing the cache, after which the task executes again. + +## `vt run build` + +first run — cache miss, writes the archive + +``` +$ vtt write-file dist/output.txt built +``` + +## `vtt rm --ext .tar.zst node_modules/.vite/task-cache` + +delete the archive + +``` +``` + +## `vt run build` + +second run — cache hit, but restoring fails, so the run fails + +**Exit code:** 1 + +``` +$ vtt write-file dist/output.txt built ◉ cache hit, replaying +✗ Cache restore failed. Run `vt cache clean` to clear the cache: failed to extract the output archive: +``` + +## `vt run --last-details` + +the task is reported as failed, with its error + +**Exit code:** 1 + +``` + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + Vite+ Task Runner • Execution Summary +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +Statistics: 1 task • 0 cache hits • 0 cache misses • 1 failed +Performance: 0% cache hit rate + +Task Details: +──────────────────────────────────────────────── + [1] output-cache-test#build: $ vtt write-file dist/output.txt built + → Cache hit, but the outputs couldn't be restored + ✗ Error: Cache restore failed. Run `vt cache clean` to clear the cache + ↳ failed to extract the output archive + ↳ +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +``` + +## `vt cache clean` + +clear the cache, as the error suggests + +``` +``` + +## `vt run build` + +third run — cache miss, so the task executes + +``` +$ vtt write-file dist/output.txt built +``` 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 ba61d0b67..16045e01c 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 @@ -251,6 +251,71 @@ steps = [ ], comment = "The corrupt download was removed." }, ] +[[e2e]] +name = "restore_failure" +cfg = "not(windows)" +ignore = true +steps = [ + { argv = [ + "remote-cache-server", + "vt", + "run", + "build", + ], envs = [ + [ + "VP_REMOTE_CACHE", + "read-write", + ], + ] }, + [ + "vt", + "cache", + "clean", + ], + [ + "vtt", + "rm", + "-rf", + "dist", + ], + { argv = [ + "vtt", + "write-file", + "dist", + "file", + ], comment = "A file where the output directory goes makes restoring fail." }, + { argv = [ + "remote-cache-server", + "vt", + "run", + "build", + ], comment = "The remote hit can't be restored, so the task fails." }, + { argv = [ + "vt", + "run", + "--last-details", + ], comment = "The details include the underlying error." }, + { argv = [ + "vtt", + "list-dir", + "node_modules/.vite/task-cache", + "--ext", + ".tar.zst", + "--recursive", + ], comment = "The downloaded archive was removed, and the entry wasn't cached locally." }, + [ + "vtt", + "rm", + "dist", + ], + { argv = [ + "remote-cache-server", + "vt", + "run", + "build", + ], comment = "Nothing was cached locally, so the remote entry is fetched and restored again." }, +] + [[e2e]] name = "invalid_endpoint" steps = [ diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache/snapshots/restore_failure.md b/crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache/snapshots/restore_failure.md new file mode 100644 index 000000000..3012d5dcf --- /dev/null +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache/snapshots/restore_failure.md @@ -0,0 +1,93 @@ +# restore_failure + +## `VP_REMOTE_CACHE=read-write remote-cache-server vt run build` + +``` +$ vtt write-file dist/output.txt built + +[remote-cache] POST /fetch 404 +[remote-cache] POST /store 200 +``` + +## `vt cache clean` + +``` +``` + +## `vtt rm -rf dist` + +``` +``` + +## `vtt write-file dist file` + +A file where the output directory goes makes restoring fail. + +``` +``` + +## `remote-cache-server vt run build` + +The remote hit can't be restored, so the task fails. + +**Exit code:** 1 + +``` +$ vtt write-file dist/output.txt built ◉ remote cache hit, replaying +✗ Cache restore failed: failed to extract the output archive: failed to unpack `/dist/output.txt`: failed to unpack `dist/output.txt` into `/dist/output.txt`: + +[remote-cache] POST /fetch 200 exact +[remote-cache] GET /blob/1 200 +``` + +## `vt run --last-details` + +The details include the underlying error. + +**Exit code:** 1 + +``` + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + Vite+ Task Runner • Execution Summary +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +Statistics: 1 task • 0 cache hits • 0 cache misses • 1 failed +Performance: 0% cache hit rate + +Task Details: +──────────────────────────────────────────────── + [1] remote-cache#build: $ vtt write-file dist/output.txt built + → Remote cache hit, but the outputs couldn't be restored + ✗ Error: Cache restore failed + ↳ failed to extract the output archive + ↳ failed to unpack `/dist/output.txt` + ↳ failed to unpack `dist/output.txt` into `/dist/output.txt` + ↳ +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +``` + +## `vtt list-dir node_modules/.vite/task-cache --ext .tar.zst --recursive` + +The downloaded archive was removed, and the entry wasn't cached locally. + +``` +``` + +## `vtt rm dist` + +``` +``` + +## `remote-cache-server vt run build` + +Nothing was cached locally, so the remote entry is fetched and restored again. + +``` +$ vtt write-file dist/output.txt built ◉ remote cache hit, replaying + +--- +vt run: remote cache hit. +[remote-cache] POST /fetch 200 exact +[remote-cache] GET /blob/1 200 +```