feat(sql): add DiskTableException and SecondaryIndexHandle
This commit is contained in:
1 parent
76dd40dcba
commit
66d6a4ac5d
1 file changed
+165
@@ -0,0 +1,165 @@
|
||||
package kestrel.store.sql
|
||||
|
||||
import kestrel.KestrelException
|
||||
import kestrel.catalog.TableSchema
|
||||
import kestrel.engine.collationFoldedUniquenessKey
|
||||
import kestrel.store.codec.ByteWriter
|
||||
import kestrel.store.codec.KeyCodec
|
||||
import kestrel.store.codec.TupleCodec
|
||||
import kestrel.store.engine.TransactionalHeap
|
||||
import kestrel.store.engine.TransactionalIndex
|
||||
import kestrel.store.heap.RecordId
|
||||
import kestrel.storage.Row
|
||||
import kestrel.storage.Value
|
||||
|
||||
class DiskTableException(message: String) : KestrelException(message)
|
||||
|
||||
/**
|
||||
* A live secondary index attached to a [DiskTable]: the B+Tree plus which column(s) it is keyed
|
||||
* on - [columnNames]/[columnIndices] have more than one entry for a composite (multi-column)
|
||||
* index, in the order the index was declared. [unique] mirrors a `CREATE UNIQUE INDEX` - see
|
||||
* [DiskTable.rejectDuplicateUniqueSecondary].
|
||||
*/
|
||||
class SecondaryIndexHandle(
|
||||
val name: String,
|
||||
val columnNames: List<String>,
|
||||
val columnIndices: List<Int>,
|
||||
val index: TransactionalIndex,
|
||||
val unique: Boolean = false
|
||||
)
|
||||
|
||||
/**
|
||||
* The disk-resident counterpart of [kestrel.storage.Table]: a schema-aware
|
||||
* row store backed by a [TransactionalHeap] for the row bodies, an optional
|
||||
* unique [TransactionalIndex] on the primary key, and any number of
|
||||
* non-unique secondary [TransactionalIndex]es - all driven by the same
|
||||
* [kestrel.storage.Value]/[TableSchema] types the in-memory SQL engine uses,
|
||||
* so this is the storage backend a future Catalog/Executor integration would
|
||||
* sit on top of, not a parallel data model needing translation.
|
||||
*
|
||||
* [kestrel.store.btree.BPlusTree] only ever stores one value per key, so a
|
||||
* non-unique secondary index cannot key its B+Tree entries by the indexed
|
||||
* value(s) alone: two rows with the same value(s) would silently overwrite
|
||||
* each other. Every secondary index instead keys on `encodeKey(values) ++ rid`
|
||||
* (one or more columns, via [kestrel.store.codec.KeyCodec.encodeKey] - the
|
||||
* same order-preserving multi-value encoding a composite PRIMARY KEY uses), a
|
||||
* composite that is unique per row (see [compositeKey]); an equality lookup
|
||||
* becomes a range scan bounded by that encoding's smallest and largest possible
|
||||
* suffix (see [equalRangeFor]). This is the standard way an index built on a
|
||||
* unique-key structure supports duplicates (e.g. how InnoDB appends the
|
||||
* primary key to secondary index entries).
|
||||
*
|
||||
* Every mutating operation may change a primary or secondary index's root
|
||||
* page (a B+Tree split or merge) purely in the caller process's memory - see
|
||||
* [kestrel.store.engine.TransactionalIndex]'s own documentation. [onRootsChanged]
|
||||
* is how a [DiskTable] tells its owning [DiskCatalog] to persist that new
|
||||
* root into the directory row for this table, in the same transaction as the
|
||||
* data write, so a fresh process reopening the catalog later sees the
|
||||
* correct, committed root rather than the one recorded at table-creation time.
|
||||
*/
|
||||
class DiskTable internal constructor(
|
||||
schema: TableSchema,
|
||||
private val heap: TransactionalHeap,
|
||||
private var primaryIndex: TransactionalIndex?,
|
||||
private val secondaryIndexHandles: MutableList<SecondaryIndexHandle>,
|
||||
private val onRootsChanged: (txnId: Long) -> Unit
|
||||
) {
|
||||
/**
|
||||
* Mutable so `ALTER TABLE` (see [replaceSchemaMetadata]/[renameColumnAndIndexes]/[rewriteRows])
|
||||
* can swap in a new [TableSchema] after CREATE TABLE - private setter because every mutation
|
||||
* needs to happen alongside bookkeeping (secondary index column realignment, directory
|
||||
* persistence) that only this class and [DiskCatalog] (its sole caller for these methods,
|
||||
* same module) know how to do correctly.
|
||||
*/
|
||||
var schema: TableSchema = schema
|
||||
private set
|
||||
|
||||
val headerPageId: Long get() = heap.headerPageId
|
||||
val secondaryIndexes: List<SecondaryIndexHandle> get() = secondaryIndexHandles.toList()
|
||||
|
||||
/**
|
||||
* Access counters, incremented by [scan]/[findByPrimaryKey]/[rangeByPrimaryKey]/
|
||||
* [findBySecondary]. Exists so a caller (chiefly [DiskIndexAccessPlanner]-driven
|
||||
* code and its tests) can confirm an index lookup actually happened instead of
|
||||
* a full heap scan - correctness alone cannot distinguish the two, since both
|
||||
* return the same rows.
|
||||
*/
|
||||
var scanCount: Int = 0; private set
|
||||
var pointLookupCount: Int = 0; private set
|
||||
var rangeScanCount: Int = 0; private set
|
||||
var secondaryLookupCount: Int = 0; private set
|
||||
|
||||
internal fun attachSecondaryIndex(handle: SecondaryIndexHandle) {
|
||||
secondaryIndexHandles.add(handle)
|
||||
}
|
||||
|
||||
internal fun dropSecondaryIndexByName(name: String, txnId: Long) {
|
||||
val handle = secondaryIndexHandles.firstOrNull { it.name.equals(name, ignoreCase = true) } ?: return
|
||||
secondaryIndexHandles.remove(handle)
|
||||
handle.index.destroy(txnId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Frees this table's own storage - its heap and every attached index (primary and secondary) -
|
||||
* back to the shared page free list. Called by `DROP TABLE` once the directory row itself is
|
||||
* already gone, since nothing will ever reopen this [DiskTable] again.
|
||||
*/
|
||||
internal fun destroyStorage(txnId: Long) {
|
||||
for (handle in secondaryIndexHandles) handle.index.destroy(txnId)
|
||||
primaryIndex?.destroy(txnId)
|
||||
heap.destroy(txnId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Metadata-only schema swap used by `ALTER TABLE ... RENAME TO` (this table's own name/
|
||||
* self-referencing FK) and, on an unrelated table, by `RENAME TO`/`RENAME COLUMN`/`DROP COLUMN`
|
||||
* rewriting a FOREIGN KEY that points at the table being altered - none of these change this
|
||||
* table's own column count or positions, so no row rewrite or index realignment is needed.
|
||||
*/
|
||||
internal fun replaceSchemaMetadata(newSchema: TableSchema) {
|
||||
schema = newSchema
|
||||
}
|
||||
|
||||
/**
|
||||
* `ALTER TABLE ... RENAME COLUMN`: swaps in [newSchema] (column list already renamed by the
|
||||
* caller) and renames [oldName] to [newName] wherever it appears in a secondary index's
|
||||
* [SecondaryIndexHandle.columnNames] - the index's [SecondaryIndexHandle.columnIndices] stay
|
||||
* exactly the same, since a pure rename never moves a column's position.
|
||||
*/
|
||||
internal fun renameColumnAndIndexes(newSchema: TableSchema, oldName: String, newName: String) {
|
||||
schema = newSchema
|
||||
for (i in secondaryIndexHandles.indices) {
|
||||
val handle = secondaryIndexHandles[i]
|
||||
if (handle.columnNames.none { it.equals(oldName, ignoreCase = true) }) continue
|
||||
val renamedNames = handle.columnNames.map { if (it.equals(oldName, ignoreCase = true)) newName else it }
|
||||
secondaryIndexHandles[i] = SecondaryIndexHandle(handle.name, renamedNames, handle.columnIndices, handle.index, handle.unique)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `ALTER TABLE ... ADD/DROP COLUMN`: rewrites every row's stored bytes through [transform] and
|
||||
* swaps in [newSchema] (already reflecting the added/removed column). Every surviving secondary
|
||||
* index's [SecondaryIndexHandle.columnIndices] is recomputed fresh from [newSchema] right after
|
||||
* the swap (mirrors [kestrel.catalog.Catalog.reindexOne] always recomputing positions from
|
||||
* names rather than trusting a cached one) - required for DROP COLUMN, where every column after
|
||||
* the dropped one shifts down by one position; a no-op for ADD COLUMN, which only ever appends
|
||||
* at the end. Reuses the ordinary [update] for each row (not a hand-rolled unindex/reindex pair)
|
||||
* so a row relocated to a new [RecordId] by a growing/shrinking body - see
|
||||
* [kestrel.store.heap.HeapFile.update] - keeps every index entry correctly pointed at it,
|
||||
* exactly like a normal SQL `UPDATE` already guarantees. The caller is responsible for having
|
||||
* already dropped (via [dropSecondaryIndexByName]) any index that depends on a column being
|
||||
* removed - a survivor's columns are always still present in [newSchema] by the time this runs.
|
||||
*/
|
||||
internal fun rewriteRows(txnId: Long, newSchema: TableSchema, transform: (Row) -> Row) {
|
||||
val existing = rawScan(txnId)
|
||||
schema = newSchema
|
||||
for (i in secondaryIndexHandles.indices) {
|
||||
val handle = secondaryIndexHandles[i]
|
||||
val realignedIndices = handle.columnNames.map {
|
||||
newSchema.columnIndex(it) ?: throw DiskTableException("index ${handle.name} references unknown column $it")
|
||||
}
|
||||
secondaryIndexHandles[i] = SecondaryIndexHandle(handle.name, handle.columnNames, realignedIndices, handle.index, handle.unique)
|
||||
}
|
||||
for ((rid, row) in existing) update(txnId, rid, transform(row))
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user