Skip to content
Merged
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
# Changelog

- **Fixed** The task cache is now stored in a per-schema-version subdirectory (e.g. `node_modules/.vite/task-cache/v13/`), so switching between branches that pin different Vite+ versions no longer fails with `Unrecognized database version`. Each version keeps its own cache directory; a cache from a different version is ignored rather than aborting the run ([#433](https://github.com/voidzero-dev/vite-task/pull/433))
- **Added** A task's `env` and `untrackedEnv` glob patterns now support `!` negation: a `!`-prefixed pattern excludes matching variables (e.g. `["VITE_*", "!VITE_SECRET"]` tracks every `VITE_*` except `VITE_SECRET`) ([#425](https://github.com/voidzero-dev/vite-task/pull/425))
- **Fixed** `package.json` and `pnpm-workspace.yaml` files with a UTF-8 BOM no longer fail to parse ([#424](https://github.com/voidzero-dev/vite-task/pull/424))
- **Changed** `vp run --filter <expr>` now exits 0 with a warning when the filter matches no packages, matching pnpm. Use `--fail-if-no-match` to restore the previous strict behavior ([#393](https://github.com/voidzero-dev/vite-task/pull/393))
Expand Down
139 changes: 105 additions & 34 deletions crates/vite_task/src/session/cache/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ pub use display::{
SpawnFingerprintChange, detect_spawn_fingerprint_changes, format_input_change_str,
format_spawn_change,
};
use rusqlite::{Connection, OptionalExtension as _, config::DbConfig};
use rusqlite::{Connection, OptionalExtension as _};
use serde::{Deserialize, Serialize};
use tokio::sync::Mutex;
use vite_path::{AbsolutePath, RelativePathBuf};
Expand Down Expand Up @@ -188,9 +188,30 @@ pub fn split_path(path: &str) -> (Option<&str>, &str) {
}
}

/// On-disk schema version of the cache database.
///
/// Bump this whenever the database layout (table structure, serialization
/// format, or fingerprint semantics) changes in an incompatible way.
///
/// The version is encoded *only* in the cache directory name (see
/// [`cache_schema_dir_name`], e.g. `v13`); there is no in-database version
/// marker. Keying the storage location on this version means Vite+ builds that
/// pin different schema versions never open each other's database: each keeps
/// its own cache warm across branch switches, and a cache from a different
/// version is simply ignored (it lives in a directory this build never looks
/// at) rather than aborting the run. Bumping the version starts a fresh cache.
const CACHE_SCHEMA_VERSION: u32 = 13;

/// Name of the per-version subdirectory (e.g. `v13`) under the task-cache
/// directory that holds the database and output archives for the current
/// [`CACHE_SCHEMA_VERSION`].
pub fn cache_schema_dir_name() -> Str {
vite_str::format!("v{CACHE_SCHEMA_VERSION}")
}

impl ExecutionCache {
#[tracing::instrument(level = "debug", skip_all)]
pub fn load_from_path(path: &AbsolutePath, program_name: &str) -> anyhow::Result<Self> {
pub fn load_from_path(path: &AbsolutePath) -> anyhow::Result<Self> {
tracing::info!("Creating task cache directory at {:?}", path);
std::fs::create_dir_all(path)?;

Expand All @@ -201,38 +222,18 @@ impl ExecutionCache {

let db_path = path.join("cache.db");
let conn = Connection::open(db_path.as_path())?;
conn.execute_batch("PRAGMA journal_mode=WAL;")?;
loop {
let user_version: u32 = conn.query_one("PRAGMA user_version", (), |row| row.get(0))?;
match user_version {
0 => {
// fresh new db
conn.execute(
"CREATE TABLE cache_entries (key BLOB PRIMARY KEY, value BLOB);",
(),
)?;
conn.execute(
"CREATE TABLE task_fingerprints (key BLOB PRIMARY KEY, value BLOB);",
(),
)?;
conn.execute("PRAGMA user_version = 13", ())?;
}
1..=12 => {
// old internal db version. reset
conn.set_db_config(DbConfig::SQLITE_DBCONFIG_RESET_DATABASE, true)?;
conn.execute("VACUUM", ())?;
conn.set_db_config(DbConfig::SQLITE_DBCONFIG_RESET_DATABASE, false)?;
}
13 => break, // current version
14.. => {
return Err(anyhow::anyhow!(
"Unrecognized database version: {user_version}. \
The cache may have been created by a newer version of Vite Task. \
Run `{program_name} cache clean` to remove it."
));
}
}
}
// The schema version is encoded in the directory name (see
// `cache_schema_dir_name`), so any database in this directory already has
// the current schema: there is nothing to migrate or version-check. Set
// WAL mode and ensure the tables exist in a single round-trip. On an
// existing database the `IF NOT EXISTS` creates are near-free no-ops (a
// schema lookup, no write); on a fresh one they create the tables. This
// runs once per process (the cache is `OnceCell`-initialized).
conn.execute_batch(
"PRAGMA journal_mode=WAL;
CREATE TABLE IF NOT EXISTS cache_entries (key BLOB PRIMARY KEY, value BLOB);
CREATE TABLE IF NOT EXISTS task_fingerprints (key BLOB PRIMARY KEY, value BLOB);",
)?;
// Lock is released when lock_file is dropped
Ok(Self { conn: Mutex::new(conn) })
}
Expand Down Expand Up @@ -527,3 +528,73 @@ impl ExecutionCache {
Ok(())
}
}

#[cfg(test)]
mod tests {
use rusqlite::Connection;
use tempfile::TempDir;
use vite_path::AbsolutePathBuf;

use super::*;

fn temp_dir() -> (TempDir, AbsolutePathBuf) {
let tmp = TempDir::new().unwrap();
let dir = AbsolutePathBuf::new(tmp.path().to_path_buf()).unwrap();
(tmp, dir)
}

fn open_raw(db: &AbsolutePath) -> Connection {
Connection::open(db.as_path()).unwrap()
}

/// Reopening the same cache directory keeps existing entries: the tables are
/// created with `IF NOT EXISTS`, so a second open never wipes the database.
#[test]
fn reopening_preserves_existing_entries() {
let (_tmp, dir) = temp_dir();

drop(ExecutionCache::load_from_path(&dir).unwrap());
{
let conn = open_raw(&dir.join("cache.db"));
conn.execute("INSERT INTO cache_entries (key, value) VALUES (X'01', X'02')", ())
.unwrap();
}

// Reopening must not recreate or clear the tables.
drop(ExecutionCache::load_from_path(&dir).unwrap());

let count: u32 = open_raw(&dir.join("cache.db"))
.query_one("SELECT COUNT(*) FROM cache_entries", (), |r| r.get(0))
.unwrap();
assert_eq!(count, 1);
}

/// Regression test for vite-plus#1785: two different schema-version
/// directories under the same cache base are fully independent, so caches
/// from different Vite+ versions never collide (each version reads and
/// writes only its own directory).
#[test]
fn version_directories_are_isolated() {
let (_tmp, base) = temp_dir();

let dir_a = base.join("v13");
let dir_b = base.join("v14");

drop(ExecutionCache::load_from_path(&dir_a).unwrap());
drop(ExecutionCache::load_from_path(&dir_b).unwrap());

assert!(dir_a.join("cache.db").as_path().exists());
assert!(dir_b.join("cache.db").as_path().exists());

// A row written into A is invisible to B.
{
let conn = open_raw(&dir_a.join("cache.db"));
conn.execute("INSERT INTO cache_entries (key, value) VALUES (X'01', X'02')", ())
.unwrap();
}
let count_b: u32 = open_raw(&dir_b.join("cache.db"))
.query_one("SELECT COUNT(*) FROM cache_entries", (), |r| r.get(0))
.unwrap();
assert_eq!(count_b, 0);
}
}
22 changes: 16 additions & 6 deletions crates/vite_task/src/session/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,13 @@ pub struct Session<'a> {
/// Cache is lazily initialized to avoid `SQLite` race conditions when multiple
/// processes (e.g., parallel `vt lib` commands) start simultaneously.
cache: OnceCell<ExecutionCache>,
/// Per-schema-version cache directory (e.g. `node_modules/.vite/task-cache/v13`)
/// that holds the database and output archives for this build.
cache_path: AbsolutePathBuf,
/// Root task-cache directory (parent of all `vN` version directories).
/// Used by `cache clean` to remove every version's cache (and any leftover
/// from a pre-versioned layout) in one shot.
cache_root: AbsolutePathBuf,
}

fn get_cache_path_of_workspace(workspace_root: &AbsolutePath) -> AbsolutePathBuf {
Expand Down Expand Up @@ -220,7 +226,10 @@ impl<'a> Session<'a> {
config: SessionConfig<'a>,
) -> anyhow::Result<Self> {
let (workspace_root, _) = find_workspace_root(&cwd)?;
let cache_path = get_cache_path_of_workspace(&workspace_root.path);
let cache_root = get_cache_path_of_workspace(&workspace_root.path);
// Nest the cache in a per-schema-version subdirectory so builds that pin
// different schema versions don't share (and corrupt) one database.
let cache_path = cache_root.join(cache::cache_schema_dir_name().as_str());

// Prepend workspace's node_modules/.bin to PATH
let workspace_node_modules_bin = workspace_root.path.join("node_modules").join(".bin");
Expand All @@ -239,6 +248,7 @@ impl<'a> Session<'a> {
program_name: config.program_name,
cache: OnceCell::new(),
cache_path,
cache_root,
})
}

Expand Down Expand Up @@ -403,8 +413,10 @@ impl<'a> Session<'a> {
fn handle_cache_command(&self, subcmd: &CacheSubcommand) -> Result<(), SessionError> {
match subcmd {
CacheSubcommand::Clean => {
if self.cache_path.as_path().exists() {
std::fs::remove_dir_all(&self.cache_path)?;
// Remove the whole task-cache directory (every version), not just
// the current build's `vN` subdirectory.
if self.cache_root.as_path().exists() {
std::fs::remove_dir_all(&self.cache_root)?;
}
}
}
Expand Down Expand Up @@ -586,9 +598,7 @@ impl<'a> Session<'a> {
///
/// Returns an error if the cache database cannot be loaded or created.
pub fn cache(&self) -> anyhow::Result<&ExecutionCache> {
self.cache.get_or_try_init(|| {
ExecutionCache::load_from_path(&self.cache_path, &self.program_name)
})
self.cache.get_or_try_init(|| ExecutionCache::load_from_path(&self.cache_path))
}

pub fn workspace_path(&self) -> Arc<AbsolutePath> {
Expand Down
37 changes: 31 additions & 6 deletions crates/vite_task_bin/src/vtt/list_dir.rs
Original file line number Diff line number Diff line change
@@ -1,33 +1,62 @@
/// List entries in a directory, sorted by name, one per line.
///
/// Usage: `vtt list-dir <dir> [--ext <suffix>]`
/// Usage: `vtt list-dir <dir> [--ext <suffix>] [--recursive]`
///
/// With `--ext`, only entries whose filename ends with the given suffix are
/// printed (the leading `.` is part of the suffix you pass, e.g. `.tar.zst`).
///
/// With `--recursive`, subdirectories are walked and only their leaf files are
/// printed (by basename, not path). This lets tests assert on cache contents
/// without hardcoding the per-schema-version subdirectory (e.g. `v13`) that the
/// cache database and archives live under, which changes whenever the cache
/// schema version is bumped.
///
/// Used by e2e tests to assert on cache directory contents (e.g. exactly one
/// `.tar.zst` archive after a re-run that should have cleaned up the prior
/// archive).
pub fn run(args: &[String]) -> Result<(), Box<dyn std::error::Error>> {
let mut dir: Option<&str> = None;
let mut ext: Option<&str> = None;
let mut recursive = false;
let mut i = 0;
while i < args.len() {
match args[i].as_str() {
"--ext" => {
i += 1;
ext = Some(args.get(i).ok_or("--ext requires a value")?.as_str());
}
"--recursive" => recursive = true,
other if dir.is_none() => dir = Some(other),
other => return Err(format!("unexpected argument: {other}").into()),
}
i += 1;
}
let dir = dir.ok_or("Usage: vtt list-dir <dir> [--ext <suffix>]")?;
let dir = dir.ok_or("Usage: vtt list-dir <dir> [--ext <suffix>] [--recursive]")?;

let mut names: Vec<String> = Vec::new();
collect(std::path::Path::new(dir), ext, recursive, &mut names)?;
names.sort();
for name in names {
println!("{name}");
}
Ok(())
}

/// Collect entry filenames under `dir`. In recursive mode, descend into
/// subdirectories and collect only leaf files (the directory names themselves
/// are not emitted).
fn collect(
dir: &std::path::Path,
ext: Option<&str>,
recursive: bool,
names: &mut Vec<String>,
) -> Result<(), Box<dyn std::error::Error>> {
for entry in std::fs::read_dir(dir)? {
let entry = entry?;
if recursive && entry.file_type()?.is_dir() {
collect(&entry.path(), ext, recursive, names)?;
continue;
}
let name = entry.file_name().to_string_lossy().into_owned();
if let Some(suffix) = ext
&& !name.ends_with(suffix)
Expand All @@ -36,9 +65,5 @@ pub fn run(args: &[String]) -> Result<(), Box<dyn std::error::Error>> {
}
names.push(name);
}
names.sort();
for name in names {
println!("{name}");
}
Ok(())
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"name": "@test/legacy-cache-ignored"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
[[e2e]]
name = "leftover_cache_from_another_version_is_ignored"
comment = """
A cache left at the old top-level location by a different Vite+ version is ignored: this build reads and writes only its own per-schema-version subdirectory, so the leftover database never aborts the run (the bug behind vite-plus#1785) and caching still works.
"""
steps = [
{ argv = [
"vtt",
"write-file",
"node_modules/.vite/task-cache/cache.db",
"cache from another version",
], comment = "simulate a leftover cache database from a different Vite+ version" },
{ argv = [
"vt",
"run",
"cached-task",
], comment = "first run is unaffected by the leftover (cache miss)" },
{ argv = [
"vt",
"run",
"cached-task",
], comment = "second run hits this build's own per-version cache" },
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# leftover_cache_from_another_version_is_ignored

A cache left at the old top-level location by a different Vite+ version is ignored: this build reads and writes only its own per-schema-version subdirectory, so the leftover database never aborts the run (the bug behind vite-plus#1785) and caching still works.

## `vtt write-file node_modules/.vite/task-cache/cache.db 'cache from another version'`

simulate a leftover cache database from a different Vite+ version

```
```

## `vt run cached-task`

first run is unaffected by the leftover (cache miss)

```
$ vtt print-file test.txt
test content
```

## `vt run cached-task`

second run hits this build's own per-version cache

```
$ vtt print-file test.txt ◉ cache hit, replaying
test content

---
vt run: cache hit.
```
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
test content
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"cache": true,
"tasks": {
"cached-task": {
"command": "vtt print-file test.txt",
"cache": true
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ steps = [
"node_modules/.vite/task-cache",
"--ext",
".tar.zst",
"--recursive",
], comment = "exactly one archive on disk" },
{ argv = [
"vtt",
Expand All @@ -67,6 +68,7 @@ steps = [
"node_modules/.vite/task-cache",
"--ext",
".tar.zst",
"--recursive",
], comment = "still exactly one archive — A was cleaned up" },
]

Expand Down
Loading
Loading