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 number Diff line number Diff line change
@@ -1,5 +1,6 @@
# Changelog

- **Perf** When tasks miss the cache, `vp run` no longer waits for the disk after saving each task's result, so the run finishes sooner. If the computer loses power or the operating system crashes during `vp run`, the cache entries that the run saved can be lost, and those tasks run again next time instead of hitting the cache ([#871](https://github.com/voidzero-dev/vite-task/pull/871)).
- **Fixed** Running `vp run` in a package that sits inside a workspace folder but isn't listed in its `pnpm-workspace.yaml` `packages` or `package.json` `workspaces` now runs that package's own tasks, instead of treating the enclosing workspace as its root. When both configurations exist, `pnpm-workspace.yaml` takes precedence ([#859](https://github.com/voidzero-dev/vite-task/pull/859)).
- **Fixed** A mistyped or misplaced setting in a task now gets an error that names it. For example, `cache: { outputs: [...] }` now fails with an `unknown field` error that names `outputs` and lists the accepted settings, instead of `data did not match any variant of untagged enum UserTaskDefinition` ([vite-plus#2635](https://github.com/voidzero-dev/vite-plus/issues/2635), [#815](https://github.com/voidzero-dev/vite-task/pull/815)).
- **Fixed** In cached tasks on Linux and macOS, programs that pass `-1` as a directory descriptor to calls such as `openat` no longer abort. For example, `ps -p <pid>` from procps-ng 4.0.6 or later aborted when the process had already exited ([#862](https://github.com/voidzero-dev/vite-task/pull/862)).
Expand Down
57 changes: 50 additions & 7 deletions crates/vt/src/session/cache/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,26 @@ pub fn cache_schema_dir_name() -> Str {
vt_str::format!("v{CACHE_SCHEMA_VERSION}")
}

/// Switches `conn` to WAL mode and, if that worked, sets `synchronous=NORMAL`.
///
/// With `NORMAL`, commits don't wait for a sync; SQLite syncs the WAL at checkpoints instead.
/// In WAL mode, a power loss can roll back the latest commits (their entries become cache
/// misses) but can't corrupt the database.
///
/// `PRAGMA journal_mode=WAL` doesn't return an error when SQLite can't use WAL. It keeps the
/// current mode, usually a rollback journal, and returns that mode's name. With a rollback
/// journal, `NORMAL` has a small chance of corrupting the database after a power loss, so
/// `synchronous` stays `FULL` unless the result is `wal`.
fn enable_wal(conn: &Connection) -> rusqlite::Result<()> {
let is_wal = conn.pragma_update_and_check(None, "journal_mode", "WAL", |row| {
Ok(row.get_ref(0)?.as_str()?.eq_ignore_ascii_case("wal"))
})?;
if is_wal {
conn.pragma_update(None, "synchronous", "NORMAL")?;
}
Ok(())
}

impl ExecutionCache {
#[tracing::instrument(level = "debug", skip_all)]
pub fn load_from_path(path: &AbsolutePath) -> anyhow::Result<Self> {
Expand All @@ -298,16 +318,16 @@ impl ExecutionCache {

let db_path = path.join("cache.db");
let conn = Connection::open(db_path.as_path())?;
enable_wal(&conn)?;
// 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).
// the current schema: there is nothing to migrate or version-check. 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 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
Expand Down Expand Up @@ -798,4 +818,27 @@ mod tests {
.unwrap();
assert_eq!(count_b, 0);
}

/// `synchronous` is `NORMAL` only when the database is in WAL mode (see `enable_wal` for why).
#[test]
fn sync_is_relaxed_only_in_wal_mode() {
let (_tmp, dir) = temp_dir();
let cache = ExecutionCache::load_from_path(&dir).unwrap();
let (mode, sync): (Box<str>, _) = {
let conn = cache.conn.try_lock().unwrap();
(
conn.query_one("PRAGMA journal_mode", (), |r| r.get(0)).unwrap(),
conn.query_one("PRAGMA synchronous", (), |r| r.get(0)).unwrap(),
)
};
assert_eq!((&*mode, sync), ("wal", 1)); // 1 = NORMAL

// An in-memory database can't use WAL: `journal_mode=WAL` leaves it in `memory` mode,
// and `synchronous` keeps the SQLite default, FULL.
let memory = Connection::open_in_memory().unwrap();
enable_wal(&memory).unwrap();
let mode: Box<str> = memory.query_one("PRAGMA journal_mode", (), |r| r.get(0)).unwrap();
let sync = memory.query_one("PRAGMA synchronous", (), |r| r.get(0)).unwrap();
assert_eq!((&*mode, sync), ("memory", 2)); // 2 = FULL
}
}
Loading