test(sql): cover rollbackRestoresIndexStateEvenAcrossASavepoint

This commit is contained in:
dualfroz committed 2026-09-12 13:14:32 +02:00
1 parent 97944a7894
commit e0a11b123c
1 file changed
+171
@@ -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<KestrelException> { db.run("SAVEPOINT s1") }
}
@Test
fun releaseSavepointOutsideATransactionIsRejected() {
val db = newDatabase()
db.seed()
assertFailsWith<KestrelException> { db.run("RELEASE SAVEPOINT s1") }
}
@Test
fun rollbackToSavepointOutsideATransactionIsRejected() {
val db = newDatabase()
db.seed()
assertFailsWith<KestrelException> { db.run("ROLLBACK TO SAVEPOINT s1") }
}
@Test
fun rollbackToAnUnknownSavepointNameIsRejected() {
val db = newDatabase()
db.seed()
db.run("BEGIN")
db.run("SAVEPOINT s1")
assertFailsWith<KestrelException> { 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<KestrelException> { 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<KestrelException> { 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<KestrelException> { 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] }
)
}
}