From 97944a78944833ef7022916fbf7099696e789247 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Sat, 12 Sep 2026 12:56:59 +0200 Subject: [PATCH] test(sql): cover DiskExecutorSavepointTest and tempFile --- .../store/sql/DiskExecutorSavepointTest.kt | 161 ++++++++++++++++++ 1 file changed, 161 insertions(+) create mode 100644 src/test/kotlin/kestrel/store/sql/DiskExecutorSavepointTest.kt diff --git a/src/test/kotlin/kestrel/store/sql/DiskExecutorSavepointTest.kt b/src/test/kotlin/kestrel/store/sql/DiskExecutorSavepointTest.kt new file mode 100644 index 0000000..163b437 --- /dev/null +++ b/src/test/kotlin/kestrel/store/sql/DiskExecutorSavepointTest.kt @@ -0,0 +1,161 @@ +package kestrel.store.sql + +import kestrel.KestrelException +import kestrel.engine.BeginResult +import kestrel.engine.CommitResult +import kestrel.engine.ReleaseSavepointResult +import kestrel.engine.RollbackResult +import kestrel.engine.RollbackToSavepointResult +import kestrel.engine.SavepointResult +import kestrel.engine.SelectResult +import kestrel.engine.StatementResult +import kestrel.sql.lexer.Lexer +import kestrel.sql.parser.Parser +import kestrel.storage.Value +import java.io.File +import java.nio.file.Files +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +/** + * Exercises `SAVEPOINT`/`RELEASE SAVEPOINT`/`ROLLBACK TO SAVEPOINT` through [DiskExecutor] against a + * real [DiskCatalog] backed by temp files - see [DiskExecutorTest] for the general end-to-end pattern + * this reuses. Most scenarios here are ported 1:1 from [kestrel.SavepointTest] (the in-memory engine's + * equivalent), since the stack semantics ([DiskExecutor.indexOfLastSavepoint] et al.) are identical - + * the exception is `rollbackToSavepointUndoesCreateTable`, which does not translate: DDL is rejected + * inside an explicit transaction on this executor entirely (see [DiskExecutorTest]), so there is + * nothing for a savepoint to roll back there. The tests unique to this file + * ([fullRollbackAfterASavepointRollbackUndoesEverythingIncludingThePreSavepointWrite], + * [rollbackToSavepointSurvivesACrashBeforeTheTransactionResolves], + * [dataWrittenAfterASavepointRollbackSurvivesAReopenAfterCommit]) cover what is actually new on the + * disk-backed path: unlike [kestrel.engine.Executor]'s whole-catalog snapshot/restore, a savepoint here + * partially rolls back the storage engine's own WAL transaction via ARIES-style compensation log + * records (see [kestrel.store.wal.LogRecord.CompensationUpdate]), so only an end-to-end reopen (or + * simulated crash) test proves the result is actually durable and not just correct in the live + * process's buffer pool cache. + */ +class DiskExecutorSavepointTest { + private fun tempFile(prefix: String): File = Files.createTempFile(prefix, ".db").toFile().also { it.deleteOnExit() } + + private fun int(value: Long) = Value.IntegerValue(value) + + private class Database(val dataFile: File, val walFile: File) { + var catalog = DiskCatalog.open(dataFile, walFile) + private set + var executor = DiskExecutor(catalog) + private set + + fun run(sql: String): List = Parser(Lexer(sql).tokenize()).parseProgram().map { executor.execute(it) } + fun select(sql: String): SelectResult = run(sql).single() as SelectResult + fun rowCount(sql: String): Int = select(sql).rows.size + + /** + * Closes and reopens the catalog against the same files, simulating a fresh process. Called + * with an explicit transaction still open (no preceding COMMIT/ROLLBACK), this simulates a + * crash: the storage engine's own recovery pass on [DiskCatalog.open] is what must resolve it. + */ + fun reopen() { + catalog.close() + catalog = DiskCatalog.open(dataFile, walFile) + executor = DiskExecutor(catalog) + } + } + + private fun newDatabase(): Database = Database(tempFile("kestrel-disk-savepoint-data"), tempFile("kestrel-disk-savepoint-wal")) + + private fun Database.seed() { + run("CREATE TABLE accounts (id INTEGER PRIMARY KEY, balance INTEGER)") + run("INSERT INTO accounts VALUES (1, 100), (2, 200)") + } + + @Test + fun rollbackToSavepointUndoesOnlyTheInsertsAfterIt() { + val db = newDatabase() + db.seed() + db.run("BEGIN") + db.run("INSERT INTO accounts VALUES (3, 300)") + assertEquals(SavepointResult("beforeFour"), db.run("SAVEPOINT beforeFour").single()) + db.run("INSERT INTO accounts VALUES (4, 400)") + assertEquals(4, db.rowCount("SELECT * FROM accounts")) + assertEquals(RollbackToSavepointResult("beforeFour"), db.run("ROLLBACK TO SAVEPOINT beforeFour").single()) + assertEquals(3, db.rowCount("SELECT * FROM accounts")) + assertEquals(CommitResult, db.run("COMMIT").single()) + assertEquals(3, db.rowCount("SELECT * FROM accounts")) + } + + @Test + fun rollbackToSavepointUndoesUpdatesAndDeletes() { + val db = newDatabase() + db.seed() + db.run("BEGIN") + db.run("SAVEPOINT s1") + db.run("UPDATE accounts SET balance = 0 WHERE id = 1") + db.run("DELETE FROM accounts WHERE id = 2") + assertEquals(int(0), db.select("SELECT balance FROM accounts WHERE id = 1").rows[0][0]) + db.run("ROLLBACK TO SAVEPOINT s1") + assertEquals(int(100), db.select("SELECT balance FROM accounts WHERE id = 1").rows[0][0]) + assertEquals(2, db.rowCount("SELECT * FROM accounts")) + db.run("ROLLBACK") + } + + @Test + fun theSavepointItselfStaysUsableAfterRollingBackToIt() { + val db = newDatabase() + db.seed() + db.run("BEGIN") + db.run("SAVEPOINT s1") + db.run("INSERT INTO accounts VALUES (3, 300)") + db.run("ROLLBACK TO SAVEPOINT s1") + db.run("INSERT INTO accounts VALUES (4, 400)") + assertEquals(3, db.rowCount("SELECT * FROM accounts")) + db.run("ROLLBACK TO SAVEPOINT s1") + assertEquals(2, db.rowCount("SELECT * FROM accounts")) + db.run("COMMIT") + } + + @Test + fun releaseSavepointForgetsItWithoutUndoingAnything() { + val db = newDatabase() + db.seed() + db.run("BEGIN") + db.run("SAVEPOINT s1") + db.run("INSERT INTO accounts VALUES (3, 300)") + assertEquals(ReleaseSavepointResult("s1"), db.run("RELEASE SAVEPOINT s1").single()) + assertEquals(3, db.rowCount("SELECT * FROM accounts")) + assertFailsWith { db.run("ROLLBACK TO SAVEPOINT s1") } + db.run("COMMIT") + assertEquals(3, db.rowCount("SELECT * FROM accounts")) + } + + @Test + fun releaseSavepointAlsoForgetsLaterNestedSavepoints() { + val db = newDatabase() + db.seed() + db.run("BEGIN") + db.run("SAVEPOINT spOuter") + db.run("INSERT INTO accounts VALUES (3, 300)") + db.run("SAVEPOINT spInner") + db.run("INSERT INTO accounts VALUES (4, 400)") + db.run("RELEASE SAVEPOINT spOuter") + assertFailsWith { db.run("ROLLBACK TO SAVEPOINT spInner") } + assertEquals(4, db.rowCount("SELECT * FROM accounts")) + db.run("COMMIT") + } + + @Test + fun reusingASavepointNameShadowsTheOlderOneUntilItIsConsumed() { + val db = newDatabase() + db.seed() + db.run("BEGIN") + db.run("SAVEPOINT s") + db.run("INSERT INTO accounts VALUES (3, 300)") + db.run("SAVEPOINT s") + db.run("INSERT INTO accounts VALUES (4, 400)") + db.run("ROLLBACK TO SAVEPOINT s") + assertEquals(3, db.rowCount("SELECT * FROM accounts")) + db.run("ROLLBACK TO SAVEPOINT s") + assertEquals(3, db.rowCount("SELECT * FROM accounts")) + db.run("COMMIT") + } +