From bebe230b05f860ea89b88f3c5a7fb615fffc6fa8 Mon Sep 17 00:00:00 2001 From: Jussi Saurio Date: Mon, 20 Oct 2025 13:59:02 +0300 Subject: [PATCH 1/2] Regression test: deferred FK violations are checked before commit --- .../query_processing/test_transactions.rs | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/tests/integration/query_processing/test_transactions.rs b/tests/integration/query_processing/test_transactions.rs index 36df15f9d..89f102ae5 100644 --- a/tests/integration/query_processing/test_transactions.rs +++ b/tests/integration/query_processing/test_transactions.rs @@ -208,6 +208,37 @@ fn test_constraint_error_aborts_transaction() { assert_eq!(row, vec![Value::Integer(0)]); } +#[test] +/// Regression test for https://github.com/tursodatabase/turso/issues/3784 where dirty pages +/// were flushed to WAL _before_ deferred FK violations were checked. This resulted in the +/// violations being persisted to the database, even though the transaction was aborted. +/// This test ensures that dirty pages are not flushed to WAL until after deferred violations are checked. +fn test_deferred_fk_violation_rollback_in_autocommit() { + let tmp_db = TempDatabase::new("test_deferred_fk_violation_rollback.db", true); + let conn = tmp_db.connect_limbo(); + + // Enable foreign keys + conn.execute("PRAGMA foreign_keys = ON").unwrap(); + + // Create parent and child tables with deferred FK constraint + conn.execute("CREATE TABLE parent(a PRIMARY KEY)").unwrap(); + conn.execute("CREATE TABLE child(a, b, FOREIGN KEY(b) REFERENCES parent(a) DEFERRABLE INITIALLY DEFERRED)") + .unwrap(); + + // This insert should fail because parent(1) doesn't exist + // and the deferred FK violation should be caught at statement end in autocommit mode + let result = conn.execute("INSERT INTO child VALUES(1,1)"); + assert!(matches!(result, Err(LimboError::Constraint(_)))); + + // Do a truncating checkpoint + conn.execute("PRAGMA wal_checkpoint(TRUNCATE)").unwrap(); + + // Verify that the child table is empty (the insert was rolled back) + let stmt = conn.query("SELECT COUNT(*) FROM child").unwrap().unwrap(); + let row = helper_read_single_row(stmt); + assert_eq!(row, vec![Value::Integer(0)]); +} + #[test] fn test_mvcc_transactions_autocommit() { let tmp_db = TempDatabase::new_with_opts( From 10532544dc2e569586f4ef593556883e007f193f Mon Sep 17 00:00:00 2001 From: Jussi Saurio Date: Mon, 20 Oct 2025 14:00:49 +0300 Subject: [PATCH 2/2] Fix: check deferred FK violations before committing to WAL DEFERRED was a bit too deferred - it allowed the dirty pages to be written out to WAL before checking for violations, resulting in the violations effectively being committed even though the transaction ended up aborting --- core/vdbe/execute.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/core/vdbe/execute.rs b/core/vdbe/execute.rs index 962f1b3bc..974538de0 100644 --- a/core/vdbe/execute.rs +++ b/core/vdbe/execute.rs @@ -2165,21 +2165,21 @@ pub fn halt( let auto_commit = program.connection.auto_commit.load(Ordering::SeqCst); tracing::trace!("halt(auto_commit={})", auto_commit); if auto_commit { - let res = program.commit_txn(pager.clone(), state, mv_store, false); - if res.is_ok() - && program.connection.foreign_keys_enabled() + // In autocommit mode, a statement that leaves deferred violations must fail here. + if program.connection.foreign_keys_enabled() && program .connection .fk_deferred_violations .swap(0, Ordering::AcqRel) > 0 { - // In autocommit mode, a statement that leaves deferred violations must fail here. return Err(LimboError::Constraint( "foreign key constraint failed".to_string(), )); } - res.map(Into::into) + program + .commit_txn(pager.clone(), state, mv_store, false) + .map(Into::into) } else { Ok(InsnFunctionStepResult::Done) }