diff --git a/crates/tracedecay-rusqlite-runtime/src/connection/mod.rs b/crates/tracedecay-rusqlite-runtime/src/connection/mod.rs index 74df7011a33..4fac14c2754 100644 --- a/crates/tracedecay-rusqlite-runtime/src/connection/mod.rs +++ b/crates/tracedecay-rusqlite-runtime/src/connection/mod.rs @@ -979,11 +979,19 @@ fn apply_pragmas( .map_err(|source| policy("trusted schema", source))?; if mode == ConnectionMode::Writer { + // SQLite also remembers the last auto_vacuum assignment for a later + // full VACUUM of *any* database on this connection. Only repository + // writers deny full VACUUM. Maintenance allows it on main and attached + // stores, so its startup must leave their independent modes alone. + connection + .pragma_update(Some("temp"), "auto_vacuum", "FULL") + .map_err(|source| policy("TEMP auto-vacuum", source))?; + verify_pragma_i64(connection, Some("temp"), "auto_vacuum", 1)?; if fresh_writer { connection .pragma_update(None, "auto_vacuum", "INCREMENTAL") .map_err(|source| policy("fresh auto-vacuum", source))?; - verify_pragma_i64(connection, "auto_vacuum", 2)?; + verify_pragma_i64(connection, None, "auto_vacuum", 2)?; } connection .pragma_update(None, "journal_mode", "WAL") @@ -1010,8 +1018,8 @@ fn apply_pragmas( .map_err(|source| policy("query-only reader", source))?; } - verify_pragma_i64(connection, "foreign_keys", 1)?; - verify_pragma_i64(connection, "trusted_schema", 0)?; + verify_pragma_i64(connection, None, "foreign_keys", 1)?; + verify_pragma_i64(connection, None, "trusted_schema", 0)?; if !connection .db_config(DbConfig::SQLITE_DBCONFIG_NO_CKPT_ON_CLOSE) .map_err(|source| policy("checkpoint-on-close verification", source))? @@ -1026,13 +1034,13 @@ fn apply_pragmas( match mode { ConnectionMode::Writer => { verify_pragma_text(connection, "journal_mode", "wal")?; - verify_pragma_i64(connection, "wal_autocheckpoint", 0)?; - verify_pragma_i64(connection, "synchronous", synchronous_value)?; - verify_pragma_i64(connection, "journal_size_limit", RETAINED_WAL_BYTES)?; + verify_pragma_i64(connection, None, "wal_autocheckpoint", 0)?; + verify_pragma_i64(connection, None, "synchronous", synchronous_value)?; + verify_pragma_i64(connection, None, "journal_size_limit", RETAINED_WAL_BYTES)?; } - ConnectionMode::Reader => verify_pragma_i64(connection, "query_only", 1)?, + ConnectionMode::Reader => verify_pragma_i64(connection, None, "query_only", 1)?, ConnectionMode::Maintenance => { - verify_pragma_i64(connection, "journal_size_limit", RETAINED_WAL_BYTES)?; + verify_pragma_i64(connection, None, "journal_size_limit", RETAINED_WAL_BYTES)?; } } Ok(()) @@ -1112,11 +1120,12 @@ impl WriterPageCache { fn verify_pragma_i64( connection: &Connection, + schema: Option<&str>, name: &'static str, expected: i64, ) -> Result<(), ConnectionPolicyError> { let actual: i64 = connection - .pragma_query_value(None, name, |row| row.get(0)) + .pragma_query_value(schema, name, |row| row.get(0)) .map_err(|source| policy("pragma verification", source))?; if actual != expected { return Err(policy( diff --git a/crates/tracedecay-rusqlite-runtime/src/connection/tests.rs b/crates/tracedecay-rusqlite-runtime/src/connection/tests.rs index be04666e521..7050571229c 100644 --- a/crates/tracedecay-rusqlite-runtime/src/connection/tests.rs +++ b/crates/tracedecay-rusqlite-runtime/src/connection/tests.rs @@ -1,6 +1,6 @@ use std::path::{Path, PathBuf}; -use rusqlite::{Connection, ErrorCode, config::DbConfig, limits::Limit}; +use rusqlite::{Connection, ErrorCode, config::DbConfig, ffi, limits::Limit}; use tempfile::NamedTempFile; use tracedecay_store::WAL_SOFT_LIMIT_BYTES; @@ -49,6 +49,13 @@ fn writer_mode_applies_wal_integrity_and_write_policy() { assert_eq!(pragma_i64(&connection, "synchronous"), 1); assert_eq!(pragma_i64(&connection, "foreign_keys"), 1); assert_eq!(pragma_i64(&connection, "trusted_schema"), 0); + assert_eq!( + connection + .query_row("PRAGMA temp.auto_vacuum", [], |row| row.get::<_, i64>(0)) + .unwrap(), + 1, + "production writers must install TEMP reclamation before using TEMP" + ); assert!( connection .db_config(DbConfig::SQLITE_DBCONFIG_NO_CKPT_ON_CLOSE) @@ -247,6 +254,192 @@ fn limits_and_authorizer_reject_oversized_or_unsafe_sql() { assert!(connection.prepare(&oversized).is_err()); } +fn temp_file_bytes(connection: &Connection) -> i64 { + let mut file: *mut ffi::sqlite3_file = std::ptr::null_mut(); + let mut bytes = 0; + // The connection exclusively owns its TEMP pager. FILE_POINTER borrows + // the open VFS file, including on systems where its name was unlinked. + unsafe { + assert_eq!( + ffi::sqlite3_file_control( + connection.handle(), + c"temp".as_ptr(), + ffi::SQLITE_FCNTL_FILE_POINTER, + (&raw mut file).cast(), + ), + ffi::SQLITE_OK + ); + let methods = file.as_ref().unwrap().pMethods.as_ref().unwrap(); + assert_eq!(methods.xFileSize.unwrap()(file, &mut bytes), ffi::SQLITE_OK); + } + bytes +} + +/// Reclaim the actual FILE-backed SQLite TEMP pager while leaving both the +/// main database and an independent live TEMP table intact. Clearing temp_store +/// would also free the file, but would silently delete the survivor's rows. +#[test] +fn temp_auto_vacuum_reclaims_pages_without_clearing_live_tables() { + let file = database(); + let connection = Connection::open(file.path()).expect("open FILE-backed test connection"); + connection + .execute_batch("PRAGMA temp_store = FILE") + .expect("select filesystem TEMP storage before connection policy"); + super::apply_pragmas( + &connection, + ConnectionMode::Writer, + false, + Durability::Normal, + ) + .expect("apply canonical connection policy"); + assert_eq!( + connection + .query_row("PRAGMA temp.auto_vacuum", [], |row| row.get::<_, i64>(0)) + .unwrap(), + 1, + "TEMP must use FULL auto-vacuum to reclaim dropped b-trees" + ); + connection + .execute_batch( + "CREATE TEMP TABLE survivor(value TEXT NOT NULL); + INSERT INTO temp.survivor VALUES ('live row'); + CREATE TEMP TABLE disposable(body BLOB NOT NULL); + BEGIN IMMEDIATE;", + ) + .expect("create a live and a disposable TEMP relation"); + let payload = vec![0x53_u8; 8192]; + { + let mut insert = connection + .prepare("INSERT INTO temp.disposable(body) VALUES (?1)") + .unwrap(); + for _ in 0..1024 { + insert.execute([payload.as_slice()]).unwrap(); + } + } + connection.execute_batch("COMMIT").unwrap(); + let before: i64 = connection + .query_row("PRAGMA temp.page_count", [], |row| row.get(0)) + .unwrap(); + assert!( + before > 1024, + "fixture must allocate actual FILE TEMP pages: {before}" + ); + let bytes_before = temp_file_bytes(&connection); + assert!( + bytes_before >= 8 * 1024 * 1024, + "TEMP data must spill to disk" + ); + // Rollback must restore a dropped TEMP b-tree and every committed row. + // Reclaiming pages on a failed transaction would silently lose data. + connection + .execute_batch("BEGIN IMMEDIATE; DROP TABLE temp.disposable; ROLLBACK;") + .expect("undo a tentative TEMP reclamation"); + assert_eq!( + connection + .query_row("SELECT COUNT(*) FROM temp.disposable", [], |row| { + row.get::<_, i64>(0) + }) + .unwrap(), + 1024, + "rolled-back TEMP drop must preserve every committed row" + ); + assert_eq!( + connection + .query_row("PRAGMA temp.page_count", [], |row| row.get::<_, i64>(0)) + .unwrap(), + before, + "rollback must restore the original TEMP page allocation" + ); + connection + .execute_batch("DROP TABLE temp.disposable") + .expect("drop disposable TEMP b-tree"); + let bytes_after = temp_file_bytes(&connection); + assert!( + bytes_after > 0 && bytes_after * 8 < bytes_before, + "the still-open TEMP file must shrink: {bytes_before} -> {bytes_after}" + ); + let after: i64 = connection + .query_row("PRAGMA temp.page_count", [], |row| row.get(0)) + .unwrap(); + assert!( + after * 8 < before, + "dropping a TEMP table must shrink its pages: {before} -> {after}" + ); + assert_eq!( + connection + .query_row("SELECT value FROM temp.survivor", [], |row| { + row.get::<_, String>(0) + }) + .unwrap(), + "live row", + "reclamation must not reset active TEMP objects" + ); + assert_eq!( + connection + .query_row("SELECT value FROM main.items", [], |row| row + .get::<_, i64>(0)) + .unwrap(), + 1, + "main database rows must survive" + ); + for schema in ["temp", "main"] { + assert_eq!( + connection + .query_row(&format!("PRAGMA {schema}.integrity_check"), [], |row| { + row.get::<_, String>(0) + }) + .unwrap(), + "ok", + "{schema} corruption after reclaiming TEMP pages" + ); + } + drop(connection); + + // Public opens must install the policy before the first TEMP allocation. + let reopened = open(file.path(), ConnectionMode::Writer).unwrap(); + assert_eq!( + reopened + .query_row("PRAGMA temp.auto_vacuum", [], |row| row.get::<_, i64>(0)) + .unwrap(), + 1 + ); + assert_eq!( + reopened + .query_row("SELECT value FROM main.items", [], |row| row + .get::<_, i64>(0)) + .unwrap(), + 1 + ); +} + +#[test] +fn maintenance_vacuum_preserves_each_stores_auto_vacuum_mode() { + let root = tempfile::tempdir().unwrap(); + let main = root.path().join("main.db"); + let attached = root.path().join("attached.db"); + for (path, mode) in [(&main, "INCREMENTAL"), (&attached, "NONE")] { + let db = Connection::open(path).unwrap(); + db.pragma_update(None, "auto_vacuum", mode).unwrap(); + db.execute_batch("CREATE TABLE sample(value INTEGER); INSERT INTO sample VALUES(7)") + .unwrap(); + } + + let db = open(&main, ConnectionMode::Maintenance).unwrap(); + db.execute("ATTACH ?1 AS auxiliary", [attached.to_str().unwrap()]) + .unwrap(); + db.execute_batch("VACUUM main; VACUUM auxiliary").unwrap(); + drop(db); + for (path, expected) in [(&main, 2), (&attached, 0)] { + let db = Connection::open(path).unwrap(); + assert_eq!(pragma_i64(&db, "auto_vacuum"), expected); + assert_eq!( + db.query_row("SELECT value FROM sample", [], |row| row.get::<_, i64>(0)) + .unwrap(), + 7 + ); + } +} + #[test] fn writer_bootstraps_fresh_incremental_auto_vacuum_before_wal() { let directory = tempfile::tempdir().expect("temporary directory"); diff --git a/crates/tracedecay-rusqlite-runtime/src/exact_sql/tests/authority.rs b/crates/tracedecay-rusqlite-runtime/src/exact_sql/tests/authority.rs index 1f6ba5c7f75..2850ef8361d 100644 --- a/crates/tracedecay-rusqlite-runtime/src/exact_sql/tests/authority.rs +++ b/crates/tracedecay-rusqlite-runtime/src/exact_sql/tests/authority.rs @@ -61,16 +61,17 @@ fn writer_actor_allows_only_product_schema_pragmas() { .execute_batch(pragma.to_owned()) .unwrap_or_else(|error| panic!("{pragma} must remain available: {error}")); } - for pragma in [ + for sql in [ + "VACUUM main", "PRAGMA auto_vacuum = NONE", "PRAGMA foreign_keys = OFF", "PRAGMA secure_delete = OFF", "PRAGMA writable_schema = ON", ] { - let error = channel.execute_batch(pragma.to_owned()).unwrap_err(); + let error = channel.execute_batch(sql.to_owned()).unwrap_err(); assert!( matches!(error, ExactSqlError::Sqlite { .. }), - "{pragma}: {error}" + "{sql}: {error}" ); } }