From a8d29169537c4f1c42c31aa59b384cf2edd0f7e3 Mon Sep 17 00:00:00 2001 From: Yusuke Tanaka Date: Wed, 21 Oct 2026 12:44:03 +0900 Subject: [PATCH 1/6] perf(cache): sync the cache database only at checkpoints in WAL mode The cache database runs in WAL mode with SQLite's default `synchronous=FULL`, so every commit waits for a sync of the WAL. Saving a task's result commits twice (the cache entry and the task fingerprint), so each task that misses the cache waits for two syncs before the run moves on. Set `synchronous=NORMAL` when WAL mode is on. In WAL mode this never corrupts the database: a power loss or an OS crash can only roll back the most recent commits, which become cache misses. The WAL is still synced at checkpoints, including the one when the last connection closes. If SQLite can't use WAL (for example, on a network share on macOS), `synchronous` stays `FULL`, because `NORMAL` with a rollback journal can corrupt the database after a power loss. Co-authored-by: Claude Opus 5.5 --- CHANGELOG.md | 1 + crates/vt/src/session/cache/mod.rs | 57 ++++++++++++++++++++++++++---- 2 files changed, 51 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f72308fd0..daa2565f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 ([#???](https://github.com/voidzero-dev/vite-task/pull/???)). - **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 ` 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)). diff --git a/crates/vt/src/session/cache/mod.rs b/crates/vt/src/session/cache/mod.rs index 2caefd817..d95606f57 100644 --- a/crates/vt/src/session/cache/mod.rs +++ b/crates/vt/src/session/cache/mod.rs @@ -285,6 +285,26 @@ pub fn cache_schema_dir_name() -> Str { vt_str::format!("v{CACHE_SCHEMA_VERSION}") } +/// Switch `conn` to WAL mode and, if that worked, stop syncing the WAL on every commit. +/// +/// In WAL mode, `synchronous=NORMAL` never corrupts the database: a power loss or an OS crash +/// can only roll back the most recent commits, and a lost entry is a cache miss. The WAL is still +/// synced at each checkpoint, including the one when the last connection closes. With the default +/// `FULL`, every commit waits for a sync, so each task whose result is recorded waits for two. +/// +/// SQLite keeps the old journal mode when it can't use WAL (for example, on a network share on +/// macOS). `synchronous` then stays `FULL`, because `NORMAL` with a rollback journal can corrupt +/// the database after a power loss. +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 { @@ -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 @@ -798,4 +818,27 @@ mod tests { .unwrap(); assert_eq!(count_b, 0); } + + /// `PRAGMA journal_mode` and `PRAGMA synchronous` (1 = NORMAL, 2 = FULL) of `conn`. + fn journal_and_sync(conn: &Connection) -> (Box, u32) { + let mode = conn.query_one("PRAGMA journal_mode", (), |r| r.get(0)).unwrap(); + let sync = conn.query_one("PRAGMA synchronous", (), |r| r.get(0)).unwrap(); + (mode, sync) + } + + /// The cache database syncs the WAL only at checkpoints (`synchronous=NORMAL`). When SQLite + /// can't use WAL, `synchronous` must stay `FULL`: `NORMAL` with a rollback journal can + /// corrupt the database after a power loss. + #[test] + fn sync_is_relaxed_only_in_wal_mode() { + let (_tmp, dir) = temp_dir(); + let cache = ExecutionCache::load_from_path(&dir).unwrap(); + assert_eq!(journal_and_sync(&cache.conn.try_lock().unwrap()), ("wal".into(), 1)); + + // An in-memory database can't use WAL: `journal_mode=WAL` leaves it in `memory` mode, + // and `synchronous` keeps the SQLite default, FULL. + let conn = Connection::open_in_memory().unwrap(); + enable_wal(&conn).unwrap(); + assert_eq!(journal_and_sync(&conn), ("memory".into(), 2)); + } } From 9dffc12ea9a1059fcfbb52810ebe61fd394f6bd9 Mon Sep 17 00:00:00 2001 From: Yusuke Tanaka Date: Wed, 21 Oct 2026 14:26:29 +0900 Subject: [PATCH 2/6] chore(cache): say only what a reader of enable_wal needs Co-authored-by: Claude Opus 5.5 --- crates/vt/src/session/cache/mod.rs | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/crates/vt/src/session/cache/mod.rs b/crates/vt/src/session/cache/mod.rs index d95606f57..9409fcfaa 100644 --- a/crates/vt/src/session/cache/mod.rs +++ b/crates/vt/src/session/cache/mod.rs @@ -285,16 +285,12 @@ pub fn cache_schema_dir_name() -> Str { vt_str::format!("v{CACHE_SCHEMA_VERSION}") } -/// Switch `conn` to WAL mode and, if that worked, stop syncing the WAL on every commit. +/// Switches `conn` to WAL mode and, only if that worked, sets `synchronous=NORMAL`. /// -/// In WAL mode, `synchronous=NORMAL` never corrupts the database: a power loss or an OS crash -/// can only roll back the most recent commits, and a lost entry is a cache miss. The WAL is still -/// synced at each checkpoint, including the one when the last connection closes. With the default -/// `FULL`, every commit waits for a sync, so each task whose result is recorded waits for two. -/// -/// SQLite keeps the old journal mode when it can't use WAL (for example, on a network share on -/// macOS). `synchronous` then stays `FULL`, because `NORMAL` with a rollback journal can corrupt -/// the database after a power loss. +/// 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. If SQLite can't use WAL, it keeps the old journal +/// mode without an error, and `NORMAL` with that journal can corrupt the database. 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")) From 967aada406671938cc478ed440577d9fc8f3362c Mon Sep 17 00:00:00 2001 From: Yusuke Tanaka Date: Wed, 21 Oct 2026 14:32:45 +0900 Subject: [PATCH 3/6] chore(cache): explain why NORMAL needs the WAL check Co-authored-by: Claude Opus 5.5 --- crates/vt/src/session/cache/mod.rs | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/crates/vt/src/session/cache/mod.rs b/crates/vt/src/session/cache/mod.rs index 9409fcfaa..6d256ff70 100644 --- a/crates/vt/src/session/cache/mod.rs +++ b/crates/vt/src/session/cache/mod.rs @@ -285,12 +285,16 @@ pub fn cache_schema_dir_name() -> Str { vt_str::format!("v{CACHE_SCHEMA_VERSION}") } -/// Switches `conn` to WAL mode and, only if that worked, sets `synchronous=NORMAL`. +/// 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. If SQLite can't use WAL, it keeps the old journal -/// mode without an error, and `NORMAL` with that journal can corrupt the database. +/// 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")) @@ -822,9 +826,7 @@ mod tests { (mode, sync) } - /// The cache database syncs the WAL only at checkpoints (`synchronous=NORMAL`). When SQLite - /// can't use WAL, `synchronous` must stay `FULL`: `NORMAL` with a rollback journal can - /// corrupt the database after a power loss. + /// `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(); From fcc550a5cf35c173e2b6f3766c739f4efeef85f2 Mon Sep 17 00:00:00 2001 From: Yusuke Tanaka Date: Wed, 21 Oct 2026 14:41:07 +0900 Subject: [PATCH 4/6] test(cache): inline the pragma queries Co-authored-by: Claude Opus 5.5 --- crates/vt/src/session/cache/mod.rs | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/crates/vt/src/session/cache/mod.rs b/crates/vt/src/session/cache/mod.rs index 6d256ff70..b616f950c 100644 --- a/crates/vt/src/session/cache/mod.rs +++ b/crates/vt/src/session/cache/mod.rs @@ -819,24 +819,23 @@ mod tests { assert_eq!(count_b, 0); } - /// `PRAGMA journal_mode` and `PRAGMA synchronous` (1 = NORMAL, 2 = FULL) of `conn`. - fn journal_and_sync(conn: &Connection) -> (Box, u32) { - let mode = conn.query_one("PRAGMA journal_mode", (), |r| r.get(0)).unwrap(); - let sync = conn.query_one("PRAGMA synchronous", (), |r| r.get(0)).unwrap(); - (mode, sync) - } - /// `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(); - assert_eq!(journal_and_sync(&cache.conn.try_lock().unwrap()), ("wal".into(), 1)); + let conn = cache.conn.try_lock().unwrap(); + let mode: Box = conn.query_one("PRAGMA journal_mode", (), |r| r.get(0)).unwrap(); + let sync: u32 = conn.query_one("PRAGMA synchronous", (), |r| r.get(0)).unwrap(); + drop(conn); + 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 conn = Connection::open_in_memory().unwrap(); - enable_wal(&conn).unwrap(); - assert_eq!(journal_and_sync(&conn), ("memory".into(), 2)); + let memory = Connection::open_in_memory().unwrap(); + enable_wal(&memory).unwrap(); + let mode: Box = memory.query_one("PRAGMA journal_mode", (), |r| r.get(0)).unwrap(); + let sync: u32 = memory.query_one("PRAGMA synchronous", (), |r| r.get(0)).unwrap(); + assert_eq!((&*mode, sync), ("memory", 2)); // 2 = FULL } } From 9d750ca9aec077a5b50f0536b6c67e5bb515e468 Mon Sep 17 00:00:00 2001 From: Yusuke Tanaka Date: Wed, 21 Oct 2026 14:51:47 +0900 Subject: [PATCH 5/6] test(cache): scope the connection lock with a block Co-authored-by: Claude Opus 5.5 --- crates/vt/src/session/cache/mod.rs | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/crates/vt/src/session/cache/mod.rs b/crates/vt/src/session/cache/mod.rs index b616f950c..ab6c806f9 100644 --- a/crates/vt/src/session/cache/mod.rs +++ b/crates/vt/src/session/cache/mod.rs @@ -824,10 +824,13 @@ mod tests { fn sync_is_relaxed_only_in_wal_mode() { let (_tmp, dir) = temp_dir(); let cache = ExecutionCache::load_from_path(&dir).unwrap(); - let conn = cache.conn.try_lock().unwrap(); - let mode: Box = conn.query_one("PRAGMA journal_mode", (), |r| r.get(0)).unwrap(); - let sync: u32 = conn.query_one("PRAGMA synchronous", (), |r| r.get(0)).unwrap(); - drop(conn); + let (mode, sync): (Box, _) = { + 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, @@ -835,7 +838,7 @@ mod tests { let memory = Connection::open_in_memory().unwrap(); enable_wal(&memory).unwrap(); let mode: Box = memory.query_one("PRAGMA journal_mode", (), |r| r.get(0)).unwrap(); - let sync: u32 = memory.query_one("PRAGMA synchronous", (), |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 } } From 21f34157e51d1d453aa8cff571c1713a22b9799a Mon Sep 17 00:00:00 2001 From: Yusuke Tanaka Date: Wed, 21 Oct 2026 16:07:27 +0900 Subject: [PATCH 6/6] docs: add the PR number to the changelog entry Co-authored-by: Claude Opus 5.5 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index daa2565f8..26e2ea8f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +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 ([#???](https://github.com/voidzero-dev/vite-task/pull/???)). +- **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 ` 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)).