diff --git a/src/test/kotlin/kestrel/store/engine/LoggingPageStoreTest.kt b/src/test/kotlin/kestrel/store/engine/LoggingPageStoreTest.kt new file mode 100644 index 0000000..25cc45f --- /dev/null +++ b/src/test/kotlin/kestrel/store/engine/LoggingPageStoreTest.kt @@ -0,0 +1,74 @@ +package kestrel.store.engine + +import kestrel.store.heap.HeapFile +import kestrel.store.heap.HeapPageException +import kestrel.store.page.BufferPool +import kestrel.store.page.DiskManager +import kestrel.store.txn.TransactionManager +import kestrel.store.wal.RecoveryManager +import kestrel.store.wal.WriteAheadLog +import java.io.File +import java.nio.file.Files +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +class LoggingPageStoreTest { + private fun tempFile(prefix: String): File = Files.createTempFile(prefix, ".db").toFile().also { it.deleteOnExit() } + + private inner class Fixture { + val diskManager = DiskManager(tempFile("kestrel-logstore-data")) + val bufferPool = BufferPool(diskManager, 16) + val wal = WriteAheadLog(tempFile("kestrel-logstore-wal")) + val txnManager = TransactionManager(wal, diskManager) + } + + @Test + fun insertThroughTheLoggingStoreProducesAWalRecordForTheMutatedPage() { + val fx = Fixture() + val heapHeaderId = HeapFile.create(fx.bufferPool).headerPageId + + val txnId = fx.txnManager.begin() + val touched = mutableListOf() + val logging = LoggingPageStore(fx.bufferPool, fx.txnManager, txnId) { touched.add(it) } + val rid = HeapFile.open(logging, heapHeaderId).insert("hello".toByteArray()) + logging.finish() + + assertTrue(rid.pageId in touched, "the page the record landed on must be reported to the touch callback") + assertTrue(fx.wal.lastLsnOf(txnId) != WriteAheadLog.NO_LSN, "insert must produce at least one logged update") + } + + @Test + fun readOnlyAccessProducesNoWalRecord() { + val fx = Fixture() + val heap = HeapFile.create(fx.bufferPool) + val rid = heap.insert("already there".toByteArray()) + fx.bufferPool.flushAll() + + val txnId = fx.txnManager.begin() + val lsnAfterBegin = fx.wal.lastLsnOf(txnId) + val logging = LoggingPageStore(fx.bufferPool, fx.txnManager, txnId) + val bytes = HeapFile.open(logging, heap.headerPageId).get(rid) + logging.finish() + + assertEquals("already there", String(bytes)) + assertEquals(lsnAfterBegin, fx.wal.lastLsnOf(txnId), "a pure read must not append any update record") + } + + @Test + fun rollingBackAnUnfinishedInsertErasesItFromThePage() { + val fx = Fixture() + val heapHeaderId = HeapFile.create(fx.bufferPool).headerPageId + + val txnId = fx.txnManager.begin() + val logging = LoggingPageStore(fx.bufferPool, fx.txnManager, txnId) + val rid = HeapFile.open(logging, heapHeaderId).insert("temporary".toByteArray()) + logging.finish() + + RecoveryManager(fx.wal, fx.diskManager).rollback(txnId) + fx.bufferPool.invalidate(rid.pageId) + + assertFailsWith { HeapFile.open(fx.bufferPool, heapHeaderId).get(rid) } + } +} diff --git a/src/test/kotlin/kestrel/store/engine/StorageEngineTest.kt b/src/test/kotlin/kestrel/store/engine/StorageEngineTest.kt new file mode 100644 index 0000000..b1967e9 --- /dev/null +++ b/src/test/kotlin/kestrel/store/engine/StorageEngineTest.kt @@ -0,0 +1,122 @@ +package kestrel.store.engine + +import kestrel.store.codec.KeyCodec +import kestrel.store.codec.TupleCodec +import kestrel.store.heap.RecordId +import kestrel.storage.Value +import java.io.File +import java.nio.file.Files +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class StorageEngineTest { + private fun tempFile(prefix: String): File = Files.createTempFile(prefix, ".db").toFile().also { it.deleteOnExit() } + + @Test + fun aCommittedInsertSurvivesACrashThatNeverFlushedTheBufferPool() { + val dataFile = tempFile("kestrel-engine-data") + val walFile = tempFile("kestrel-engine-wal") + + val engine1 = StorageEngine.open(dataFile, walFile) + val heap = engine1.createHeap() + val txn = engine1.beginTransaction() + val rid = heap.insert(txn, "durable across a crash".toByteArray()) + engine1.commit(txn) + // No engine1.close() / flush(): the committed page may still be sitting only in + // the buffer pool, never written to dataFile. Only the WAL commit record is durable. + + val engine2 = StorageEngine.open(dataFile, walFile) + val reopenedHeap = engine2.openHeap(heap.headerPageId) + val reader = engine2.beginTransaction() + assertEquals("durable across a crash", String(reopenedHeap.get(reader, rid))) + assertEquals(listOf(rid), reopenedHeap.scan(reader).map { it.first }) + } + + @Test + fun abortMakesAnInsertInvisibleWithoutRestartingTheEngine() { + val engine = StorageEngine.open(tempFile("kestrel-engine-data"), tempFile("kestrel-engine-wal")) + val heap = engine.createHeap() + + val writer = engine.beginTransaction() + heap.insert(writer, "temporary".toByteArray()) + engine.abort(writer) + + val reader = engine.beginTransaction() + assertTrue(heap.scan(reader).isEmpty()) + } + + @Test + fun aTableAndItsSecondaryIndexCommitAndAbortTogetherAsOneTransaction() { + val engine = StorageEngine.open(tempFile("kestrel-engine-data"), tempFile("kestrel-engine-wal")) + val table = engine.createHeap() + val index = engine.createIndex() + + fun rowFor(id: Long, name: String) = TupleCodec.encode(listOf(Value.IntegerValue(id), Value.TextValue(name))) + fun keyFor(id: Long) = KeyCodec.encode(Value.IntegerValue(id)) + + val setup = engine.beginTransaction() + val committedRid = table.insert(setup, rowFor(1, "alice")) + index.insert(setup, keyFor(1), committedRid.encode()) + engine.commit(setup) + + val doomed = engine.beginTransaction() + val doomedRid = table.insert(doomed, rowFor(2, "bob")) + index.insert(doomed, keyFor(2), doomedRid.encode()) + engine.abort(doomed) + + val reader = engine.beginTransaction() + assertNull(index.search(reader, keyFor(2)), "the aborted row's index entry must not survive") + val foundRid = RecordId.decode(index.search(reader, keyFor(1))!!) + assertEquals(committedRid, foundRid) + val row = TupleCodec.decode(table.get(reader, foundRid)) + assertEquals(listOf(Value.IntegerValue(1), Value.TextValue("alice")), row) + assertEquals(listOf(committedRid), table.scan(reader).map { it.first }) + } + + @Test + fun anAbortedInsertFromAPriorSessionStaysGoneAfterReopening() { + val dataFile = tempFile("kestrel-engine-data") + val walFile = tempFile("kestrel-engine-wal") + + val engine1 = StorageEngine.open(dataFile, walFile) + val heap = engine1.createHeap() + + val committed = engine1.beginTransaction() + heap.insert(committed, "kept".toByteArray()) + engine1.commit(committed) + + val doomed = engine1.beginTransaction() + heap.insert(doomed, "discarded".toByteArray()) + engine1.abort(doomed) + engine1.close() + + val engine2 = StorageEngine.open(dataFile, walFile) + val reopenedHeap = engine2.openHeap(heap.headerPageId) + val reader = engine2.beginTransaction() + assertEquals(listOf("kept"), reopenedHeap.scan(reader).map { String(it.second) }) + } + + @Test + fun closingAndReopeningPreservesBothTableAndIndexData() { + val dataFile = tempFile("kestrel-engine-data") + val walFile = tempFile("kestrel-engine-wal") + + val engine1 = StorageEngine.open(dataFile, walFile) + val table = engine1.createHeap() + val index = engine1.createIndex() + val txn = engine1.beginTransaction() + val rid = table.insert(txn, TupleCodec.encode(listOf(Value.IntegerValue(7)))) + index.insert(txn, KeyCodec.encode(Value.IntegerValue(7)), rid.encode()) + engine1.commit(txn) + engine1.close() + + val engine2 = StorageEngine.open(dataFile, walFile) + val reopenedTable = engine2.openHeap(table.headerPageId) + val reopenedIndex = engine2.openIndex(index.rootPageId) + val reader = engine2.beginTransaction() + val foundRid = RecordId.decode(reopenedIndex.search(reader, KeyCodec.encode(Value.IntegerValue(7)))!!) + assertEquals(listOf(Value.IntegerValue(7)), TupleCodec.decode(reopenedTable.get(reader, foundRid))) + } +}