diff --git a/CHANGELOG.md b/CHANGELOG.md index f72308fd0..26e2ea8f4 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 ([#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)). diff --git a/crates/vt/src/session/cache/mod.rs b/crates/vt/src/session/cache/mod.rs index 2caefd817..ab6c806f9 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}") } +/// 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 { @@ -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); } + + /// `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, _) = { + 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 = 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 + } }