diff --git a/src/test/kotlin/kestrel/store/sql/DiskExecutorSavepointTest.kt b/src/test/kotlin/kestrel/store/sql/DiskExecutorSavepointTest.kt index 163b437..b14bb04 100644 --- a/src/test/kotlin/kestrel/store/sql/DiskExecutorSavepointTest.kt +++ b/src/test/kotlin/kestrel/store/sql/DiskExecutorSavepointTest.kt @@ -159,3 +159,174 @@ class DiskExecutorSavepointTest { db.run("COMMIT") } + @Test + fun rollbackRestoresIndexStateEvenAcrossASavepoint() { + val db = newDatabase() + db.seed() + db.run("CREATE INDEX idxBalance ON accounts (balance)") + db.run("BEGIN") + db.run("SAVEPOINT s1") + db.run("INSERT INTO accounts VALUES (3, 100)") + assertEquals(2, db.rowCount("SELECT * FROM accounts WHERE balance = 100")) + db.run("ROLLBACK TO SAVEPOINT s1") + assertEquals(1, db.rowCount("SELECT * FROM accounts WHERE balance = 100")) + db.run("ROLLBACK") + } + + @Test + fun savepointOutsideATransactionIsRejected() { + val db = newDatabase() + db.seed() + assertFailsWith { db.run("SAVEPOINT s1") } + } + + @Test + fun releaseSavepointOutsideATransactionIsRejected() { + val db = newDatabase() + db.seed() + assertFailsWith { db.run("RELEASE SAVEPOINT s1") } + } + + @Test + fun rollbackToSavepointOutsideATransactionIsRejected() { + val db = newDatabase() + db.seed() + assertFailsWith { db.run("ROLLBACK TO SAVEPOINT s1") } + } + + @Test + fun rollbackToAnUnknownSavepointNameIsRejected() { + val db = newDatabase() + db.seed() + db.run("BEGIN") + db.run("SAVEPOINT s1") + assertFailsWith { db.run("ROLLBACK TO SAVEPOINT nope") } + db.run("ROLLBACK") + } + + @Test + fun releaseAnUnknownSavepointNameIsRejected() { + val db = newDatabase() + db.seed() + db.run("BEGIN") + db.run("SAVEPOINT s1") + assertFailsWith { db.run("RELEASE SAVEPOINT nope") } + db.run("ROLLBACK") + } + + @Test + fun fullRollbackClearsEveryOutstandingSavepoint() { + val db = newDatabase() + db.seed() + db.run("BEGIN") + db.run("SAVEPOINT s1") + db.run("INSERT INTO accounts VALUES (3, 300)") + db.run("ROLLBACK") + db.run("BEGIN") + assertFailsWith { db.run("ROLLBACK TO SAVEPOINT s1") } + db.run("ROLLBACK") + } + + @Test + fun commitClearsEveryOutstandingSavepoint() { + val db = newDatabase() + db.seed() + db.run("BEGIN") + db.run("SAVEPOINT s1") + db.run("INSERT INTO accounts VALUES (3, 300)") + db.run("COMMIT") + db.run("BEGIN") + assertFailsWith { db.run("ROLLBACK TO SAVEPOINT s1") } + db.run("ROLLBACK") + assertEquals(3, db.rowCount("SELECT * FROM accounts")) + } + + @Test + fun releaseKeywordAloneWithoutSavepointKeywordIsAccepted() { + val db = newDatabase() + db.seed() + db.run("BEGIN") + db.run("SAVEPOINT s1") + db.run("INSERT INTO accounts VALUES (3, 300)") + db.run("RELEASE s1") + assertEquals(3, db.rowCount("SELECT * FROM accounts")) + db.run("COMMIT") + } + + @Test + fun beginResultTypeIsReturnedForTheEnclosingTransaction() { + val db = newDatabase() + db.seed() + assertEquals(BeginResult, db.run("BEGIN").single()) + db.run("ROLLBACK") + } + + /** + * The SQL-level twin of `RecoveryManagerTest.fullRollbackAfterAPartialRollbackDoesNotMisapplyAnAlreadyUndoneBeforeImage` - + * proves the whole stack (DiskExecutor -> DiskCatalog -> StorageEngine -> TransactionManager -> + * RecoveryManager) wires the compensation-record undo-of-undo correctly end to end, not just at the + * page level. + */ + @Test + fun fullRollbackAfterASavepointRollbackUndoesEverythingIncludingThePreSavepointWrite() { + val db = newDatabase() + db.seed() + db.run("BEGIN") + db.run("INSERT INTO accounts VALUES (3, 300)") + db.run("SAVEPOINT s1") + db.run("INSERT INTO accounts VALUES (4, 400)") + db.run("ROLLBACK TO SAVEPOINT s1") + assertEquals(3, db.rowCount("SELECT * FROM accounts"), "the pre-savepoint insert must still be visible in-session") + assertEquals(RollbackResult, db.run("ROLLBACK").single()) + assertEquals(2, db.rowCount("SELECT * FROM accounts"), "a full rollback must also undo the pre-savepoint insert") + } + + /** + * A crash that lands right after `ROLLBACK TO SAVEPOINT` but before the enclosing transaction ever + * commits or rolls back must still leave the database as if the whole transaction never happened: + * recovery resolves any still-open transaction with a full rollback regardless of what savepoints + * it touched along the way. This is the reopen-based proof that + * [kestrel.store.wal.RecoveryManager.rollbackTo]'s compensation records are genuinely durable, not + * just correct in the live process's buffer pool. + */ + @Test + fun rollbackToSavepointSurvivesACrashBeforeTheTransactionResolves() { + val db = newDatabase() + db.seed() + db.run("BEGIN") + db.run("INSERT INTO accounts VALUES (3, 300)") + db.run("SAVEPOINT s1") + db.run("INSERT INTO accounts VALUES (4, 400)") + db.run("ROLLBACK TO SAVEPOINT s1") + // No COMMIT, no ROLLBACK: crash right here, mid-transaction. + db.reopen() + + assertEquals(2, db.rowCount("SELECT * FROM accounts"), "the entire unresolved transaction must be undone by crash recovery") + } + + /** + * The durable-happy-path counterpart to [rollbackToSavepointSurvivesACrashBeforeTheTransactionResolves]: + * a transaction that rolls back to a savepoint and then commits must persist exactly the rows that + * survived the rollback, including a write made *after* the savepoint rollback - proving the + * transaction's WAL chain still threads correctly (new writes point back at the compensation + * record, not at the undone update it replaced) once it resumes. + */ + @Test + fun dataWrittenAfterASavepointRollbackSurvivesAReopenAfterCommit() { + val db = newDatabase() + db.seed() + db.run("BEGIN") + db.run("INSERT INTO accounts VALUES (3, 300)") + db.run("SAVEPOINT s1") + db.run("INSERT INTO accounts VALUES (4, 400)") + db.run("ROLLBACK TO SAVEPOINT s1") + db.run("INSERT INTO accounts VALUES (5, 500)") + db.run("COMMIT") + db.reopen() + + assertEquals( + listOf(int(1), int(2), int(3), int(5)), + db.select("SELECT id FROM accounts ORDER BY id").rows.map { it[0] } + ) + } +}