diff --git a/src/test/kotlin/kestrel/PersistenceTest.kt b/src/test/kotlin/kestrel/PersistenceTest.kt index 7575dd7..acdc542 100644 --- a/src/test/kotlin/kestrel/PersistenceTest.kt +++ b/src/test/kotlin/kestrel/PersistenceTest.kt @@ -1,11 +1,17 @@ package kestrel +import java.io.BufferedReader import java.io.File +import java.io.IOException +import java.io.Reader +import java.io.StringReader import java.nio.file.Files import kestrel.cli.Repl import kestrel.persistence.Persistence import kotlin.test.Test import kotlin.test.assertContains +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertTrue class PersistenceTest { @@ -17,6 +23,34 @@ class PersistenceTest { return output.toString() } + /** + * Delivers [text] like a normal [StringReader], but throws instead of ever signalling EOF - + * simulating a process that vanished (killed, crashed, power loss) right after its last + * statement ran, never reaching either `.exit` or a graceful end of input. [Repl.run] only + * checkpoints (see [Repl.finish]) on one of those two paths, so this is the one way to drive it + * through every statement in [text] while proving NOTHING beyond the incremental WAL write each + * statement already triggered on its own (via [kestrel.cli.Repl.recordForPersistence]) ever + * makes it to disk - exactly the scenario a TEMP table's persistence exclusion must hold up + * under, not just the far more common clean-`.exit`-then-checkpoint path every other test here + * exercises. + */ + private class CrashingReader(text: String) : Reader() { + private val delegate = StringReader(text) + override fun read(cbuf: CharArray, off: Int, len: Int): Int { + val read = delegate.read(cbuf, off, len) + if (read == -1) throw IOException("simulated crash: process died before EOF") + return read + } + override fun close() = delegate.close() + } + + private fun runScriptThatCrashesBeforeItCanCheckpoint(dir: File, script: String) { + val output = StringBuilder() + assertFailsWith { + Repl(output, output, Persistence(dir)).run(BufferedReader(CrashingReader(script))) + } + } + @Test fun dataSurvivesReopenAfterCheckpoint() { val dir = tempDir() @@ -74,6 +108,82 @@ class PersistenceTest { assertTrue(!reopened.contains("2"), "rolled back insert must not persist") } + @Test + fun aMergeSurvivesReopenWithBothItsUpdateAndItsInsertIntact() { + val dir = tempDir() + runScript( + dir, + """ + CREATE TABLE accounts (id INTEGER PRIMARY KEY, balance INTEGER); + CREATE TABLE staging (id INTEGER, amount INTEGER); + INSERT INTO accounts VALUES (1, 100); + INSERT INTO staging VALUES (1, 10), (2, 5); + MERGE INTO accounts USING staging ON accounts.id = staging.id + WHEN MATCHED THEN UPDATE SET balance = accounts.balance + staging.amount + WHEN NOT MATCHED THEN INSERT (id, balance) VALUES (staging.id, staging.amount); + .exit + """.trimIndent() + ) + val reopened = runScript(dir, "SELECT id, balance FROM accounts ORDER BY id ASC;\n.exit\n") + assertContains(reopened, "110") + assertContains(reopened, "5") + } + + @Test + fun rollbackToSavepointInsideACommittedTransactionDoesNotPersistTheUndoneInsert() { + val dir = tempDir() + runScript( + dir, + """ + CREATE TABLE t (id INTEGER PRIMARY KEY); + BEGIN; + INSERT INTO t VALUES (111); + SAVEPOINT s1; + INSERT INTO t VALUES (222); + ROLLBACK TO SAVEPOINT s1; + INSERT INTO t VALUES (333); + COMMIT; + .exit + """.trimIndent() + ) + val reopened = runScript(dir, "SELECT id FROM t ORDER BY id ASC;\n.exit\n") + assertContains(reopened, "111") + assertContains(reopened, "333") + assertTrue(!reopened.contains("222"), "insert undone by ROLLBACK TO SAVEPOINT must not persist") + } + + @Test + fun insertOnConflictDoUpdateSurvivesReopen() { + val dir = tempDir() + runScript( + dir, + """ + CREATE TABLE accounts (id INTEGER PRIMARY KEY, balance INTEGER); + INSERT INTO accounts VALUES (1, 100); + INSERT INTO accounts VALUES (1, 50) ON CONFLICT (id) DO UPDATE SET balance = accounts.balance + excluded.balance; + .exit + """.trimIndent() + ) + val reopened = runScript(dir, "SELECT balance FROM accounts WHERE id = 1;\n.exit\n") + assertContains(reopened, "150") + } + + @Test + fun walReplayReconstructsAnInsertOnConflictStatementCorrectly() { + val dir = tempDir() + val persistence = Persistence(dir) + persistence.appendWal( + listOf( + "CREATE TABLE accounts (id INTEGER PRIMARY KEY, balance INTEGER);", + "INSERT INTO accounts VALUES (1, 100);", + "INSERT INTO accounts VALUES (1, 50) ON CONFLICT (id) DO UPDATE SET balance = accounts.balance + excluded.balance;" + ) + ) + val output = StringBuilder() + Repl(output, output, Persistence(dir)).run("SELECT balance FROM accounts WHERE id = 1;\n.exit\n".reader().buffered()) + assertContains(output.toString(), "150") + } + @Test fun walReplayRecoversWithoutCheckpoint() { val dir = tempDir()