feat(heap): add HeapPageException, HeapPage and RecordId
This commit is contained in:
1 parent
93957baf2a
commit
a1ae100c72
2 files changed
+175
No files matched your search
@@ -0,0 +1,159 @@
|
||||
package kestrel.store.heap
|
||||
|
||||
import kestrel.KestrelException
|
||||
import kestrel.store.page.Page
|
||||
|
||||
class HeapPageException(message: String) : KestrelException(message)
|
||||
|
||||
/**
|
||||
* Slotted-page layout for heap-organized storage.
|
||||
*
|
||||
* ```
|
||||
* [ header (12B) | slot directory, grows down | free space | tuple bytes, grow up ]
|
||||
* ```
|
||||
*
|
||||
* The header holds the id of the next page in the heap's page chain, the
|
||||
* slot count, and the offset where tuple bytes currently begin. Each slot
|
||||
* directory entry is `[offset:u16][length:u16]`; a length of
|
||||
* [TOMBSTONE_LENGTH] marks a deleted slot. Deleting a record only tombstones
|
||||
* its slot so the [RecordId] stays stable; [compact] later reclaims the
|
||||
* space by repacking live tuples against the end of the page.
|
||||
*/
|
||||
object HeapPage {
|
||||
private const val HEADER_SIZE = 12
|
||||
private const val SLOT_SIZE = 4
|
||||
private const val TOMBSTONE_LENGTH = 0xFFFF
|
||||
const val NO_PAGE = -1L
|
||||
|
||||
val MAX_RECORD_SIZE: Int = Page.PAGE_SIZE - HEADER_SIZE - SLOT_SIZE
|
||||
|
||||
fun init(page: Page, nextPageId: Long = NO_PAGE) {
|
||||
page.data.fill(0)
|
||||
writeLong(page, 0, nextPageId)
|
||||
writeShort(page, 8, 0)
|
||||
writeShort(page, 10, Page.PAGE_SIZE)
|
||||
page.isDirty = true
|
||||
}
|
||||
|
||||
fun nextPageId(page: Page): Long = readLong(page, 0)
|
||||
|
||||
fun setNextPageId(page: Page, pageId: Long) {
|
||||
writeLong(page, 0, pageId)
|
||||
page.isDirty = true
|
||||
}
|
||||
|
||||
fun slotCount(page: Page): Int = readShort(page, 8)
|
||||
|
||||
fun freeSpaceOffset(page: Page): Int = readShort(page, 10)
|
||||
|
||||
fun freeBytes(page: Page): Int = freeSpaceOffset(page) - directoryEnd(page)
|
||||
|
||||
fun isDeleted(page: Page, slotId: Int): Boolean = lengthAt(page, slotId) == TOMBSTONE_LENGTH
|
||||
|
||||
fun lengthOf(page: Page, slotId: Int): Int {
|
||||
val length = lengthAt(page, slotId)
|
||||
if (length == TOMBSTONE_LENGTH) throw HeapPageException("slot $slotId is deleted")
|
||||
return length
|
||||
}
|
||||
|
||||
fun get(page: Page, slotId: Int): ByteArray {
|
||||
val entry = slotEntryOffset(page, slotId)
|
||||
val length = readShort(page, entry + 2)
|
||||
if (length == TOMBSTONE_LENGTH) throw HeapPageException("slot $slotId is deleted")
|
||||
val offset = readShort(page, entry)
|
||||
return page.data.copyOfRange(offset, offset + length)
|
||||
}
|
||||
|
||||
/** Returns the new slot id, or null if [bytes] does not fit even after [compact]. */
|
||||
fun insert(page: Page, bytes: ByteArray): Int? {
|
||||
require(bytes.size <= MAX_RECORD_SIZE) { "record of ${bytes.size} bytes exceeds page capacity" }
|
||||
if (!fits(page, bytes.size)) {
|
||||
compact(page)
|
||||
if (!fits(page, bytes.size)) return null
|
||||
}
|
||||
val slotId = slotCount(page)
|
||||
val newOffset = freeSpaceOffset(page) - bytes.size
|
||||
System.arraycopy(bytes, 0, page.data, newOffset, bytes.size)
|
||||
writeSlotEntry(page, slotId, newOffset, bytes.size)
|
||||
writeShort(page, 8, slotId + 1)
|
||||
writeShort(page, 10, newOffset)
|
||||
page.isDirty = true
|
||||
return slotId
|
||||
}
|
||||
|
||||
fun delete(page: Page, slotId: Int) {
|
||||
val entry = slotEntryOffset(page, slotId)
|
||||
if (readShort(page, entry + 2) == TOMBSTONE_LENGTH) throw HeapPageException("slot $slotId is already deleted")
|
||||
writeShort(page, entry, 0)
|
||||
writeShort(page, entry + 2, TOMBSTONE_LENGTH)
|
||||
page.isDirty = true
|
||||
}
|
||||
|
||||
/** Overwrites a slot in place. [bytes] must be no larger than the slot's current length. */
|
||||
fun overwrite(page: Page, slotId: Int, bytes: ByteArray) {
|
||||
val entry = slotEntryOffset(page, slotId)
|
||||
val length = readShort(page, entry + 2)
|
||||
if (length == TOMBSTONE_LENGTH) throw HeapPageException("slot $slotId is deleted")
|
||||
require(bytes.size <= length) { "overwrite of ${bytes.size} bytes must not grow slot $slotId (capacity $length)" }
|
||||
val offset = readShort(page, entry)
|
||||
System.arraycopy(bytes, 0, page.data, offset, bytes.size)
|
||||
writeShort(page, entry + 2, bytes.size)
|
||||
page.isDirty = true
|
||||
}
|
||||
|
||||
/** Repacks live tuples against the end of the page, discarding tombstoned bytes. */
|
||||
fun compact(page: Page) {
|
||||
val count = slotCount(page)
|
||||
val live = ArrayList<Pair<Int, ByteArray>>(count)
|
||||
for (slotId in 0 until count) {
|
||||
val entry = slotEntryOffset(page, slotId)
|
||||
val length = readShort(page, entry + 2)
|
||||
if (length == TOMBSTONE_LENGTH) continue
|
||||
val offset = readShort(page, entry)
|
||||
live.add(slotId to page.data.copyOfRange(offset, offset + length))
|
||||
}
|
||||
var writeOffset = Page.PAGE_SIZE
|
||||
for ((slotId, bytes) in live) {
|
||||
writeOffset -= bytes.size
|
||||
System.arraycopy(bytes, 0, page.data, writeOffset, bytes.size)
|
||||
writeSlotEntry(page, slotId, writeOffset, bytes.size)
|
||||
}
|
||||
writeShort(page, 10, writeOffset)
|
||||
page.isDirty = true
|
||||
}
|
||||
|
||||
private fun fits(page: Page, size: Int): Boolean = directoryEnd(page) + SLOT_SIZE + size <= freeSpaceOffset(page)
|
||||
|
||||
private fun directoryEnd(page: Page): Int = HEADER_SIZE + slotCount(page) * SLOT_SIZE
|
||||
|
||||
private fun lengthAt(page: Page, slotId: Int): Int = readShort(page, slotEntryOffset(page, slotId) + 2)
|
||||
|
||||
private fun slotEntryOffset(page: Page, slotId: Int): Int {
|
||||
if (slotId !in 0 until slotCount(page)) throw HeapPageException("slot $slotId out of range")
|
||||
return HEADER_SIZE + slotId * SLOT_SIZE
|
||||
}
|
||||
|
||||
private fun writeSlotEntry(page: Page, slotId: Int, offset: Int, length: Int) {
|
||||
val entry = HEADER_SIZE + slotId * SLOT_SIZE
|
||||
writeShort(page, entry, offset)
|
||||
writeShort(page, entry + 2, length)
|
||||
}
|
||||
|
||||
private fun readShort(page: Page, at: Int): Int =
|
||||
((page.data[at].toInt() and 0xFF) shl 8) or (page.data[at + 1].toInt() and 0xFF)
|
||||
|
||||
private fun writeShort(page: Page, at: Int, value: Int) {
|
||||
page.data[at] = (value ushr 8).toByte()
|
||||
page.data[at + 1] = value.toByte()
|
||||
}
|
||||
|
||||
private fun readLong(page: Page, at: Int): Long {
|
||||
var result = 0L
|
||||
for (i in 0 until 8) result = (result shl 8) or (page.data[at + i].toLong() and 0xFF)
|
||||
return result
|
||||
}
|
||||
|
||||
private fun writeLong(page: Page, at: Int, value: Long) {
|
||||
for (i in 0 until 8) page.data[at + i] = (value ushr (8 * (7 - i))).toByte()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package kestrel.store.heap
|
||||
|
||||
/**
|
||||
* Identifies a record's slot within a heap page. Stable across compaction:
|
||||
* only the slot directory entry moves, never the (pageId, slotId) pair, so a
|
||||
* B+Tree entry can point at a [RecordId] without tracking page-internal
|
||||
* offsets.
|
||||
*/
|
||||
data class RecordId(val pageId: Long, val slotId: Int) {
|
||||
/** Packs into a single [Long] so it can be stored as a B+Tree leaf value. */
|
||||
fun encode(): Long = (pageId shl 16) or (slotId.toLong() and 0xFFFF)
|
||||
|
||||
companion object {
|
||||
fun decode(encoded: Long): RecordId = RecordId(encoded ushr 16, (encoded and 0xFFFF).toInt())
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user