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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
{
"name": "@test/legacy-cache-ignored"
}
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff line change
@@ -0,0 +1 @@
test content
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
{
"cache": true,
"tasks": {
"cached-task": {
"command": "vtt print-file test.txt",
"cache": true
}
}
}
Original file line numberDiff line numberDiff 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