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
27 changes: 18 additions & 9 deletions crates/tracedecay-rusqlite-runtime/src/connection/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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))?
Expand All @@ -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(())
Expand Down Expand Up @@ -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(
Expand Down
195 changes: 194 additions & 1 deletion crates/tracedecay-rusqlite-runtime/src/connection/tests.rs
Original file line number Diff line number Diff line change
@@ -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;

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
);
}
}
Expand Down
Loading