test(btree): cover BPlusTreeTest and newPool
This commit is contained in:
1 parent
b4524fcffb
commit
eeaf0b08ee
1 file changed
+184
@@ -0,0 +1,184 @@
|
|||||||
|
package kestrel.store.btree
|
||||||
|
|
||||||
|
import kestrel.store.codec.KeyCodec
|
||||||
|
import kestrel.store.page.BufferPool
|
||||||
|
import kestrel.store.page.DiskManager
|
||||||
|
import kestrel.storage.Value
|
||||||
|
import java.nio.file.Files
|
||||||
|
import kotlin.random.Random
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertFalse
|
||||||
|
import kotlin.test.assertNull
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
class BPlusTreeTest {
|
||||||
|
private fun newPool(capacity: Int = 64): BufferPool {
|
||||||
|
val file = Files.createTempFile("kestrel-btree", ".db").toFile().also { it.deleteOnExit() }
|
||||||
|
return BufferPool(DiskManager(file), capacity)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun key(value: Long): ByteArray = KeyCodec.encode(Value.IntegerValue(value))
|
||||||
|
|
||||||
|
private fun decodeKey(bytes: ByteArray): Long = (KeyCodec.decodeKey(bytes)[0] as Value.IntegerValue).value
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun searchOnEmptyTreeReturnsNull() {
|
||||||
|
val tree = BPlusTree.create(newPool())
|
||||||
|
assertNull(tree.search(key(1)))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun insertAndSearchSingleEntry() {
|
||||||
|
val tree = BPlusTree.create(newPool())
|
||||||
|
tree.insert(key(1), 100L)
|
||||||
|
assertEquals(100L, tree.search(key(1)))
|
||||||
|
assertNull(tree.search(key(2)))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun insertingSameKeyTwiceUpserts() {
|
||||||
|
val tree = BPlusTree.create(newPool())
|
||||||
|
tree.insert(key(5), 1L)
|
||||||
|
tree.insert(key(5), 2L)
|
||||||
|
assertEquals(2L, tree.search(key(5)))
|
||||||
|
assertEquals(listOf(5L to 2L), tree.rangeScan().asSequence().map { decodeKey(it.first) to it.second }.toList())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun deleteRemovesEntryAndReturnsFalseWhenAbsent() {
|
||||||
|
val tree = BPlusTree.create(newPool())
|
||||||
|
tree.insert(key(1), 10L)
|
||||||
|
assertTrue(tree.delete(key(1)))
|
||||||
|
assertNull(tree.search(key(1)))
|
||||||
|
assertFalse(tree.delete(key(1)))
|
||||||
|
assertFalse(tree.delete(key(99)))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun rangeScanOnEmptyTreeYieldsNothing() {
|
||||||
|
val tree = BPlusTree.create(newPool())
|
||||||
|
assertTrue(tree.rangeScan().asSequence().toList().isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun rangeScanRespectsInclusiveAndExclusiveUpperBound() {
|
||||||
|
val tree = BPlusTree.create(newPool())
|
||||||
|
for (i in 1L..10L) tree.insert(key(i), i * 10)
|
||||||
|
val exclusive = tree.rangeScan(key(3), key(7)).asSequence().map { decodeKey(it.first) }.toList()
|
||||||
|
assertEquals((3L..6L).toList(), exclusive)
|
||||||
|
val inclusive = tree.rangeScan(key(3), key(7), toInclusive = true).asSequence().map { decodeKey(it.first) }.toList()
|
||||||
|
assertEquals((3L..7L).toList(), inclusive)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun deletingEveryEntryLeavesAnEmptySearchableTree() {
|
||||||
|
val tree = BPlusTree.create(newPool())
|
||||||
|
for (i in 1L..20L) tree.insert(key(i), i)
|
||||||
|
for (i in 1L..20L) assertTrue(tree.delete(key(i)))
|
||||||
|
assertTrue(tree.rangeScan().asSequence().toList().isEmpty())
|
||||||
|
assertNull(tree.search(key(1)))
|
||||||
|
tree.insert(key(1), 42L)
|
||||||
|
assertEquals(42L, tree.search(key(1)))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun splitsAcrossMultipleLevelsAndPersistsThroughBufferEviction() {
|
||||||
|
val pool = newPool(capacity = 8) // small pool forces heavy eviction/reload traffic
|
||||||
|
val tree = BPlusTree.create(pool)
|
||||||
|
val n = 3000
|
||||||
|
for (i in 0 until n) tree.insert(key(i.toLong()), i.toLong() * 7)
|
||||||
|
for (i in 0 until n) assertEquals(i.toLong() * 7, tree.search(key(i.toLong())), "key $i")
|
||||||
|
val scanned = tree.rangeScan().asSequence().map { decodeKey(it.first) }.toList()
|
||||||
|
assertEquals((0 until n).map { it.toLong() }, scanned)
|
||||||
|
assertTrue(tree.rootPageId >= 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun stressInsertSearchDeleteAndRangeScanAgainstReferenceMap() {
|
||||||
|
val pool = newPool(capacity = 96)
|
||||||
|
val tree = BPlusTree.create(pool)
|
||||||
|
val random = Random(1234)
|
||||||
|
val reference = java.util.TreeMap<Long, Long>()
|
||||||
|
|
||||||
|
val keys = generateSequence { random.nextLong(-2_000_000_000L, 2_000_000_000L) }
|
||||||
|
.distinct()
|
||||||
|
.take(12000)
|
||||||
|
.toList()
|
||||||
|
for (k in keys) {
|
||||||
|
val rowId = k * 3 + 1
|
||||||
|
tree.insert(key(k), rowId)
|
||||||
|
reference[k] = rowId
|
||||||
|
}
|
||||||
|
|
||||||
|
for ((k, v) in reference) assertEquals(v, tree.search(key(k)), "expected key $k")
|
||||||
|
assertNull(tree.search(key(9_999_999_999L)))
|
||||||
|
|
||||||
|
val fullScan = tree.rangeScan().asSequence().map { decodeKey(it.first) to it.second }.toList()
|
||||||
|
assertEquals(reference.entries.map { it.key to it.value }, fullScan)
|
||||||
|
|
||||||
|
val lo = reference.keys.elementAt(reference.size / 4)
|
||||||
|
val hi = reference.keys.elementAt(reference.size / 2)
|
||||||
|
val expectedRange = reference.subMap(lo, true, hi, true).entries.map { it.key to it.value }
|
||||||
|
val actualRange = tree.rangeScan(key(lo), key(hi), toInclusive = true)
|
||||||
|
.asSequence().map { decodeKey(it.first) to it.second }.toList()
|
||||||
|
assertEquals(expectedRange, actualRange)
|
||||||
|
|
||||||
|
val toDelete = keys.filterIndexed { index, _ -> index % 2 == 0 }
|
||||||
|
for (k in toDelete) {
|
||||||
|
assertTrue(tree.delete(key(k)), "expected to delete $k")
|
||||||
|
reference.remove(k)
|
||||||
|
}
|
||||||
|
for (k in toDelete.take(50)) assertFalse(tree.delete(key(k)))
|
||||||
|
|
||||||
|
for ((k, v) in reference) assertEquals(v, tree.search(key(k)), "expected surviving key $k")
|
||||||
|
for (k in toDelete) assertNull(tree.search(key(k)), "expected deleted key $k to be gone")
|
||||||
|
|
||||||
|
val afterDeleteScan = tree.rangeScan().asSequence().map { decodeKey(it.first) to it.second }.toList()
|
||||||
|
assertEquals(reference.entries.map { it.key to it.value }, afterDeleteScan)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun treeReopensFromPersistedRootAfterBufferFlush() {
|
||||||
|
val file = Files.createTempFile("kestrel-btree-reopen", ".db").toFile().also { it.deleteOnExit() }
|
||||||
|
val pool1 = BufferPool(DiskManager(file), 32)
|
||||||
|
val tree1 = BPlusTree.create(pool1)
|
||||||
|
for (i in 0 until 500) tree1.insert(key(i.toLong()), i.toLong())
|
||||||
|
pool1.flushAll()
|
||||||
|
val rootId = tree1.rootPageId
|
||||||
|
|
||||||
|
val manager2 = DiskManager(file)
|
||||||
|
val pool2 = BufferPool(manager2, 32)
|
||||||
|
val tree2 = BPlusTree.open(pool2, rootId)
|
||||||
|
for (i in 0 until 500) assertEquals(i.toLong(), tree2.search(key(i.toLong())))
|
||||||
|
val scanned = tree2.rangeScan().asSequence().map { decodeKey(it.first) }.toList()
|
||||||
|
assertEquals((0 until 500).map { it.toLong() }, scanned)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun allPageIdsOnAFreshTreeIsJustTheRoot() {
|
||||||
|
val tree = BPlusTree.create(newPool())
|
||||||
|
assertEquals(listOf(tree.rootPageId), tree.allPageIds())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun allPageIdsCoversEveryInternalAndLeafPageAfterSplits() {
|
||||||
|
val pool = newPool()
|
||||||
|
val tree = BPlusTree.create(pool)
|
||||||
|
val n = 500
|
||||||
|
for (i in 0 until n) tree.insert(key(i.toLong()), i.toLong())
|
||||||
|
|
||||||
|
val pageIds = tree.allPageIds()
|
||||||
|
assertTrue(pageIds.contains(tree.rootPageId))
|
||||||
|
assertEquals(pageIds.size, pageIds.toSet().size, "expected no page id to be visited twice")
|
||||||
|
assertTrue(pageIds.size > 1, "expected $n inserts to have split the tree across more than one page")
|
||||||
|
|
||||||
|
val leafKeys = pageIds.flatMap { pageId ->
|
||||||
|
val page = pool.fetchPage(pageId)
|
||||||
|
val node = NodeCodec.readFrom(page)
|
||||||
|
pool.unpinPage(pageId, false)
|
||||||
|
if (node is BTreeNode.Leaf) node.keys.map { decodeKey(it) } else emptyList()
|
||||||
|
}
|
||||||
|
assertEquals((0 until n).map { it.toLong() }.sorted(), leafKeys.sorted(), "every leaf page must be reachable from allPageIds")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user