use std::collections::HashMap;
use std::time::Instant;
use fsqlite_btree::{
BtreePageHeader, BtreePageType, header_offset_for_page, read_cell_pointers, write_cell_pointers,
};
use fsqlite_error::{FrankenError, Result};
use fsqlite_types::limits::CELL_POINTER_SIZE;
use fsqlite_types::{PageData, PageNumber, Snapshot};
use tracing::{debug, info, warn};
use crate::cell_visibility::{CellDelta, CellDeltaKind};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MaterializationError {
InvalidBasePage { detail: String },
InteriorPageNotSupported,
PageOverflow { needed: usize, available: usize },
DeleteNonExistent { key_digest: [u8; 16] },
InsertExisting { key_digest: [u8; 16] },
}
impl std::fmt::Display for MaterializationError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::InvalidBasePage { detail } => write!(f, "invalid base page: {detail}"),
Self::InteriorPageNotSupported => {
write!(f, "cell-level MVCC not supported for interior pages")
}
Self::PageOverflow { needed, available } => {
write!(f, "page overflow: need {needed} bytes, have {available}")
}
Self::DeleteNonExistent { key_digest } => {
write!(f, "delete on non-existent cell: {:?}", &key_digest[..4])
}
Self::InsertExisting { key_digest } => {
write!(f, "insert on existing cell: {:?}", &key_digest[..4])
}
}
}
}
impl std::error::Error for MaterializationError {}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MaterializationTrigger {
Structural,
Checkpoint,
Threshold,
Explicit,
}
impl MaterializationTrigger {
fn as_str(self) -> &'static str {
match self {
Self::Structural => "split",
Self::Checkpoint => "checkpoint",
Self::Threshold => "threshold",
Self::Explicit => "explicit",
}
}
}
#[derive(Debug, Clone)]
pub struct MaterializationResult {
pub page: PageData,
pub deltas_applied: usize,
pub cell_count: u16,
pub duration_us: u64,
}
pub fn materialize_page(
base: &PageData,
page_number: PageNumber,
deltas: &[CellDelta],
snapshot: &Snapshot,
usable_size: u32,
trigger: MaterializationTrigger,
) -> Result<MaterializationResult> {
let start = Instant::now();
let header_offset = header_offset_for_page(page_number);
let header = BtreePageHeader::parse(base.as_bytes(), header_offset).map_err(|e| {
warn!(
pgno = page_number.get(),
error = %e,
"materialization_failed"
);
FrankenError::DatabaseCorrupt {
detail: format!("materialize_page: invalid base page: {e}"),
}
})?;
if header.page_type.is_interior() {
warn!(
pgno = page_number.get(),
page_type = ?header.page_type,
"materialization_failed"
);
return Err(FrankenError::DatabaseCorrupt {
detail: "materialize_page: interior pages not supported for cell-level MVCC".to_owned(),
});
}
let mut visible_deltas: Vec<&CellDelta> = deltas
.iter()
.filter(|d| d.is_visible_to(snapshot.high))
.collect();
visible_deltas.sort_by_key(|d| d.commit_seq);
if visible_deltas.is_empty() {
let duration_us = start.elapsed().as_micros() as u64;
info!(
pgno = page_number.get(),
delta_count = 0,
trigger = trigger.as_str(),
"page_materialized"
);
return Ok(MaterializationResult {
page: base.clone(),
deltas_applied: 0,
cell_count: header.cell_count,
duration_us,
});
}
let btree_ref = visible_deltas
.first()
.map(|delta| delta.cell_key.btree)
.ok_or_else(|| FrankenError::DatabaseCorrupt {
detail: "materialize_page: no visible deltas after non-empty check".to_owned(),
})?;
let mut state = WorkingPageState::from_base_page(
base,
page_number,
&header,
header_offset,
usable_size,
btree_ref,
)?;
for delta in &visible_deltas {
state.apply_delta(delta)?;
}
let result_cell_count = state.live_cell_count()?;
let page = state.build_page(base, &header, header_offset, usable_size)?;
let duration_us = start.elapsed().as_micros() as u64;
debug!(
pgno = page_number.get(),
base_commit_seq = 0, applied_deltas = visible_deltas.len(),
result_cell_count,
"materialization_detail"
);
info!(
pgno = page_number.get(),
delta_count = visible_deltas.len(),
trigger = trigger.as_str(),
"page_materialized"
);
info!(
pgno = page_number.get(),
duration_us, "materialization_timing"
);
Ok(MaterializationResult {
page,
deltas_applied: visible_deltas.len(),
cell_count: result_cell_count,
duration_us,
})
}
struct WorkingCell {
content: Vec<u8>,
sort_key: SortKey,
}
#[derive(Clone, PartialEq, Eq, PartialOrd, Ord)]
enum SortKey {
Rowid(i64),
IndexKey(Vec<u8>),
}
#[allow(dead_code)]
struct WorkingPageState {
cells_by_key: HashMap<[u8; 16], usize>,
cells: Vec<WorkingCell>,
page_type: BtreePageType,
usable_size: u32,
}
impl WorkingPageState {
fn live_cell_count(&self) -> Result<u16> {
let count = self
.cells
.iter()
.filter(|cell| !cell.content.is_empty())
.count();
u16::try_from(count).map_err(|_| FrankenError::DatabaseCorrupt {
detail: "materialize_page: too many live cells for b-tree page".to_owned(),
})
}
fn from_base_page(
base: &PageData,
page_number: PageNumber,
header: &BtreePageHeader,
header_offset: usize,
usable_size: u32,
btree_ref: fsqlite_types::BtreeRef,
) -> Result<Self> {
let cell_pointers = read_cell_pointers(base.as_bytes(), header, header_offset)?;
let page_bytes = base.as_bytes();
let mut cells = Vec::with_capacity(cell_pointers.len());
let mut cells_by_key = HashMap::with_capacity(cell_pointers.len());
for (idx, &ptr) in cell_pointers.iter().enumerate() {
let cell_offset = ptr as usize;
let cell_end =
compute_cell_end(page_bytes, cell_offset, header.page_type, usable_size)?;
let cell_content = page_bytes
.get(cell_offset..cell_end)
.ok_or_else(|| FrankenError::DatabaseCorrupt {
detail: "materialize_page: computed cell range out of bounds".to_owned(),
})?
.to_vec();
let (key_digest, sort_key) = compute_cell_key_and_sort_key(
page_bytes,
cell_offset,
header.page_type,
btree_ref,
usable_size,
)?;
cells.push(WorkingCell {
content: cell_content,
sort_key,
});
cells_by_key.insert(key_digest, idx);
}
debug!(
pgno = page_number.get(),
base_cell_count = cells.len(),
"working_state_initialized"
);
Ok(Self {
cells_by_key,
cells,
page_type: header.page_type,
usable_size,
})
}
fn apply_delta(&mut self, delta: &CellDelta) -> Result<()> {
match delta.kind {
CellDeltaKind::Insert => {
let (key_digest, sort_key) = compute_cell_key_and_sort_key_from_delta(
delta,
self.page_type,
self.usable_size,
)?;
if let Some(&idx) = self.cells_by_key.get(&key_digest) {
if let Some(cell) = self.cells.get_mut(idx) {
cell.content.clone_from(&delta.cell_data);
cell.sort_key = sort_key;
return Ok(());
}
self.cells_by_key.remove(&key_digest);
}
let idx = self.cells.len();
self.cells.push(WorkingCell {
content: delta.cell_data.clone(),
sort_key,
});
self.cells_by_key.insert(key_digest, idx);
Ok(())
}
CellDeltaKind::Update => {
let (key_digest, sort_key) = compute_cell_key_and_sort_key_from_delta(
delta,
self.page_type,
self.usable_size,
)?;
if let Some(&idx) = self.cells_by_key.get(&key_digest) {
if let Some(cell) = self.cells.get_mut(idx) {
cell.content.clone_from(&delta.cell_data);
cell.sort_key = sort_key;
return Ok(());
}
self.cells_by_key.remove(&key_digest);
}
let idx = self.cells.len();
self.cells.push(WorkingCell {
content: delta.cell_data.clone(),
sort_key,
});
self.cells_by_key.insert(key_digest, idx);
Ok(())
}
CellDeltaKind::Delete => {
if !delta.cell_data.is_empty() {
return Err(FrankenError::DatabaseCorrupt {
detail: "delete delta unexpectedly contains cell bytes".to_owned(),
});
}
let key_digest = delta.cell_key.key_digest;
if let Some(idx) = self.cells_by_key.remove(&key_digest) {
if let Some(cell) = self.cells.get_mut(idx) {
cell.content.clear();
}
}
Ok(())
}
}
}
fn build_page(
&self,
base: &PageData,
original_header: &BtreePageHeader,
header_offset: usize,
usable_size: u32,
) -> Result<PageData> {
let live_cells: Vec<&WorkingCell> = self
.cells
.iter()
.filter(|c| !c.content.is_empty())
.collect();
let mut sorted_cells: Vec<&WorkingCell> = live_cells;
sorted_cells.sort_by(|a, b| a.sort_key.cmp(&b.sort_key));
let header_size = original_header.page_type.header_size() as usize;
let ptr_array_size = sorted_cells.len() * CELL_POINTER_SIZE as usize;
let total_cell_bytes: usize = sorted_cells.iter().map(|c| c.content.len()).sum();
let needed_space = header_offset + header_size + ptr_array_size + total_cell_bytes;
if needed_space > usable_size as usize {
return Err(FrankenError::DatabaseCorrupt {
detail: format!(
"materialize_page: page overflow: need {} bytes, have {}",
needed_space, usable_size
),
});
}
let page_size = base.len();
let mut page = vec![0u8; page_size];
if header_offset > 0 {
let dst =
page.get_mut(..header_offset)
.ok_or_else(|| FrankenError::DatabaseCorrupt {
detail: "materialize_page: page header offset out of bounds".to_owned(),
})?;
let src = base.as_bytes().get(..header_offset).ok_or_else(|| {
FrankenError::DatabaseCorrupt {
detail: "materialize_page: base header offset out of bounds".to_owned(),
}
})?;
dst.copy_from_slice(src);
}
let mut content_offset = usable_size as usize;
let mut cell_pointers = Vec::with_capacity(sorted_cells.len());
for cell in &sorted_cells {
content_offset = content_offset
.checked_sub(cell.content.len())
.ok_or_else(|| FrankenError::DatabaseCorrupt {
detail: "materialize_page: cell content offset underflow".to_owned(),
})?;
page.get_mut(content_offset..content_offset + cell.content.len())
.ok_or_else(|| FrankenError::DatabaseCorrupt {
detail: "materialize_page: cell content range out of bounds".to_owned(),
})?
.copy_from_slice(&cell.content);
cell_pointers.push(content_offset as u16);
}
let cell_count =
u16::try_from(sorted_cells.len()).map_err(|_| FrankenError::DatabaseCorrupt {
detail: "materialize_page: too many cells for b-tree page".to_owned(),
})?;
let new_header = BtreePageHeader {
page_type: original_header.page_type,
first_freeblock: 0, cell_count,
cell_content_offset: content_offset as u32,
fragmented_free_bytes: 0, right_child: original_header.right_child,
};
new_header.write(&mut page, header_offset);
write_cell_pointers(&mut page, header_offset, &new_header, &cell_pointers);
Ok(PageData::from_vec(page))
}
}
fn compute_cell_key_and_sort_key(
page: &[u8],
cell_offset: usize,
page_type: BtreePageType,
btree_ref: fsqlite_types::BtreeRef,
usable_size: u32,
) -> Result<([u8; 16], SortKey)> {
if page_type == BtreePageType::LeafTable {
let cell = page
.get(cell_offset..)
.ok_or_else(|| FrankenError::DatabaseCorrupt {
detail: "cell offset out of bounds".to_owned(),
})?;
let (_, ps_len) = fsqlite_types::serial_type::read_varint(cell).ok_or_else(|| {
FrankenError::DatabaseCorrupt {
detail: "invalid varint in cell (payload size)".to_owned(),
}
})?;
let rowid_start =
cell_offset
.checked_add(ps_len)
.ok_or_else(|| FrankenError::DatabaseCorrupt {
detail: "cell rowid offset overflow".to_owned(),
})?;
let rowid_cell = page
.get(rowid_start..)
.ok_or_else(|| FrankenError::DatabaseCorrupt {
detail: "cell rowid offset out of bounds".to_owned(),
})?;
let (rowid, _rowid_len) =
fsqlite_types::serial_type::read_varint(rowid_cell).ok_or_else(|| {
FrankenError::DatabaseCorrupt {
detail: "invalid varint in cell (rowid)".to_owned(),
}
})?;
let rowid = rowid as i64;
let mut key_bytes = [0u8; 10];
let len = encode_varint_i64(rowid, &mut key_bytes);
let key_digest = fsqlite_types::SemanticKeyRef::compute_digest(
fsqlite_types::SemanticKeyKind::TableRow,
btree_ref,
&key_bytes[..len],
);
Ok((key_digest, SortKey::Rowid(rowid)))
} else if page_type == BtreePageType::LeafIndex {
use fsqlite_btree::local_payload_size;
let cell = page
.get(cell_offset..)
.ok_or_else(|| FrankenError::DatabaseCorrupt {
detail: "cell offset out of bounds".to_owned(),
})?;
let (payload_size, ps_len) =
fsqlite_types::serial_type::read_varint(cell).ok_or_else(|| {
FrankenError::DatabaseCorrupt {
detail: "invalid varint in cell (payload size)".to_owned(),
}
})?;
let key_start =
cell_offset
.checked_add(ps_len)
.ok_or_else(|| FrankenError::DatabaseCorrupt {
detail: "index cell key offset overflow".to_owned(),
})?;
let payload_size =
u32::try_from(payload_size).map_err(|_| FrankenError::DatabaseCorrupt {
detail: "index cell payload size exceeds supported page size".to_owned(),
})?;
let local_payload = local_payload_size(payload_size, usable_size, page_type);
let key_len = usize::try_from(payload_size.min(local_payload)).map_err(|_| {
FrankenError::DatabaseCorrupt {
detail: "index cell local key length exceeds addressable size".to_owned(),
}
})?;
let key_end =
key_start
.checked_add(key_len)
.ok_or_else(|| FrankenError::DatabaseCorrupt {
detail: "index cell key end offset overflow".to_owned(),
})?;
let digest_len = key_len
.checked_add(if local_payload < payload_size { 4 } else { 0 })
.ok_or_else(|| FrankenError::DatabaseCorrupt {
detail: "index cell digest length overflow".to_owned(),
})?;
let digest_end =
key_start
.checked_add(digest_len)
.ok_or_else(|| FrankenError::DatabaseCorrupt {
detail: "index cell digest end offset overflow".to_owned(),
})?;
let key_bytes = page
.get(key_start..key_end)
.ok_or_else(|| FrankenError::DatabaseCorrupt {
detail: "index cell key range out of bounds".to_owned(),
})?
.to_vec();
let digest_bytes =
page.get(key_start..digest_end)
.ok_or_else(|| FrankenError::DatabaseCorrupt {
detail: "index cell digest range out of bounds".to_owned(),
})?;
let key_digest = fsqlite_types::SemanticKeyRef::compute_digest(
fsqlite_types::SemanticKeyKind::IndexEntry,
btree_ref,
digest_bytes,
);
Ok((key_digest, SortKey::IndexKey(key_bytes)))
} else {
Err(FrankenError::DatabaseCorrupt {
detail: "cannot compute key digest for interior page cells".to_owned(),
})
}
}
fn compute_cell_key_and_sort_key_from_delta(
delta: &CellDelta,
page_type: BtreePageType,
usable_size: u32,
) -> Result<([u8; 16], SortKey)> {
let key_digest = delta.cell_key.key_digest;
if delta.cell_data.is_empty() {
return Err(FrankenError::DatabaseCorrupt {
detail: "insert/update delta missing cell bytes".to_owned(),
});
}
if page_type == BtreePageType::LeafTable {
let (_, ps_len) =
fsqlite_types::serial_type::read_varint(&delta.cell_data).ok_or_else(|| {
FrankenError::DatabaseCorrupt {
detail: "invalid varint in delta cell (payload size)".to_owned(),
}
})?;
let rowid_cell =
delta
.cell_data
.get(ps_len..)
.ok_or_else(|| FrankenError::DatabaseCorrupt {
detail: "delta cell rowid offset out of bounds".to_owned(),
})?;
let (rowid, _) = fsqlite_types::serial_type::read_varint(rowid_cell).ok_or_else(|| {
FrankenError::DatabaseCorrupt {
detail: "invalid varint in delta cell (rowid)".to_owned(),
}
})?;
let rowid = rowid as i64;
Ok((key_digest, SortKey::Rowid(rowid)))
} else if page_type == BtreePageType::LeafIndex {
use fsqlite_btree::local_payload_size;
if let Some((payload_size, ps_len)) =
fsqlite_types::serial_type::read_varint(&delta.cell_data)
{
let key_start = ps_len;
let payload_size =
u32::try_from(payload_size).map_err(|_| FrankenError::DatabaseCorrupt {
detail: "delta index cell payload size exceeds supported page size".to_owned(),
})?;
let key_len = usize::try_from(payload_size.min(local_payload_size(
payload_size,
usable_size,
page_type,
)))
.map_err(|_| FrankenError::DatabaseCorrupt {
detail: "delta index cell local key length exceeds addressable size".to_owned(),
})?;
let key_end =
key_start
.checked_add(key_len)
.ok_or_else(|| FrankenError::DatabaseCorrupt {
detail: "delta index cell key end offset overflow".to_owned(),
})?;
let key_bytes = delta
.cell_data
.get(key_start..key_end)
.ok_or_else(|| FrankenError::DatabaseCorrupt {
detail: "delta index cell key range out of bounds".to_owned(),
})?
.to_vec();
return Ok((key_digest, SortKey::IndexKey(key_bytes)));
}
Err(FrankenError::DatabaseCorrupt {
detail: "invalid varint in delta index cell (payload size)".to_owned(),
})
} else {
Err(FrankenError::DatabaseCorrupt {
detail: "cannot compute key digest for interior delta cell".to_owned(),
})
}
}
fn compute_cell_end(
page: &[u8],
cell_offset: usize,
page_type: BtreePageType,
usable_size: u32,
) -> Result<usize> {
use fsqlite_btree::local_payload_size;
let cell = page
.get(cell_offset..)
.ok_or_else(|| FrankenError::DatabaseCorrupt {
detail: "cell offset out of bounds".to_owned(),
})?;
let (payload_size, ps_len) =
fsqlite_types::serial_type::read_varint(cell).ok_or_else(|| {
FrankenError::DatabaseCorrupt {
detail: "invalid varint in cell (payload size)".to_owned(),
}
})?;
let mut pos = cell_offset
.checked_add(ps_len)
.ok_or_else(|| FrankenError::DatabaseCorrupt {
detail: "cell payload offset overflow".to_owned(),
})?;
if page_type.is_table() && page_type.is_leaf() {
let rowid_cell = page
.get(pos..)
.ok_or_else(|| FrankenError::DatabaseCorrupt {
detail: "cell rowid offset out of bounds".to_owned(),
})?;
let (_, rowid_len) =
fsqlite_types::serial_type::read_varint(rowid_cell).ok_or_else(|| {
FrankenError::DatabaseCorrupt {
detail: "invalid varint in cell (rowid)".to_owned(),
}
})?;
pos = pos
.checked_add(rowid_len)
.ok_or_else(|| FrankenError::DatabaseCorrupt {
detail: "cell rowid end offset overflow".to_owned(),
})?;
}
let payload_size = u32::try_from(payload_size).map_err(|_| FrankenError::DatabaseCorrupt {
detail: "cell payload size exceeds supported page size".to_owned(),
})?;
let local_size = local_payload_size(payload_size, usable_size, page_type);
pos = pos
.checked_add(local_size as usize)
.ok_or_else(|| FrankenError::DatabaseCorrupt {
detail: "cell payload end offset overflow".to_owned(),
})?;
if local_size < payload_size {
pos = pos
.checked_add(4)
.ok_or_else(|| FrankenError::DatabaseCorrupt {
detail: "cell overflow pointer offset overflow".to_owned(),
})?;
}
Ok(pos)
}
fn encode_varint_i64(value: i64, buf: &mut [u8]) -> usize {
fsqlite_types::serial_type::write_varint(buf, value as u64)
}
pub const DEFAULT_MATERIALIZATION_THRESHOLD: usize = 32;
#[must_use]
pub fn should_materialize_eagerly(delta_count: usize, threshold: usize) -> bool {
delta_count > threshold
}
#[cfg(test)]
mod tests {
use super::*;
use fsqlite_types::{CommitSeq, PageNumber, SchemaEpoch, TxnEpoch, TxnId, TxnToken};
const PAGE_SIZE: u32 = 4096;
const USABLE_SIZE: u32 = 4096;
fn test_snapshot(high: u64) -> Snapshot {
Snapshot {
high: CommitSeq::new(high),
schema_epoch: SchemaEpoch::new(1),
}
}
fn test_txn() -> TxnToken {
TxnToken::new(TxnId::new(1).unwrap(), TxnEpoch::new(0))
}
fn create_empty_leaf_table_page() -> PageData {
let mut page = vec![0u8; PAGE_SIZE as usize];
page[0] = BtreePageType::LeafTable as u8; page[1] = 0;
page[2] = 0; page[3] = 0;
page[4] = 0; page[5] = 0x10;
page[6] = 0x00; page[7] = 0;
PageData::from_vec(page)
}
fn create_empty_leaf_index_page() -> PageData {
let mut page = vec![0u8; PAGE_SIZE as usize];
page[0] = BtreePageType::LeafIndex as u8;
page[1] = 0;
page[2] = 0;
page[3] = 0;
page[4] = 0;
page[5] = 0x10;
page[6] = 0x00;
page[7] = 0;
PageData::from_vec(page)
}
fn create_leaf_table_cell(rowid: i64, payload: &[u8]) -> Vec<u8> {
let mut cell = Vec::new();
let payload_len = payload.len() as u64;
let mut buf = [0u8; 10];
let ps_len = encode_varint_u64(payload_len, &mut buf);
cell.extend_from_slice(&buf[..ps_len]);
let ri_len = encode_varint_i64(rowid, &mut buf);
cell.extend_from_slice(&buf[..ri_len]);
cell.extend_from_slice(payload);
cell
}
fn create_leaf_index_cell(key: &[u8]) -> Vec<u8> {
let mut cell = Vec::new();
let mut buf = [0u8; 10];
let ps_len = encode_varint_u64(key.len() as u64, &mut buf);
cell.extend_from_slice(&buf[..ps_len]);
cell.extend_from_slice(key);
cell
}
fn materialized_table_payloads(page: &PageData) -> Result<Vec<(i64, Vec<u8>)>> {
let header = BtreePageHeader::parse(page.as_bytes(), 0)?;
let pointers = read_cell_pointers(page.as_bytes(), &header, 0)?;
let mut payloads = Vec::with_capacity(pointers.len());
for ptr in pointers {
let cell = page.as_bytes().get(ptr as usize..).ok_or_else(|| {
FrankenError::DatabaseCorrupt {
detail: "materialized cell pointer out of bounds".to_owned(),
}
})?;
let (payload_size, ps_len) =
fsqlite_types::serial_type::read_varint(cell).ok_or_else(|| {
FrankenError::DatabaseCorrupt {
detail: "materialized cell missing payload varint".to_owned(),
}
})?;
let rowid_cell = cell
.get(ps_len..)
.ok_or_else(|| FrankenError::DatabaseCorrupt {
detail: "materialized cell missing rowid varint".to_owned(),
})?;
let (rowid, rowid_len) = fsqlite_types::serial_type::read_varint(rowid_cell)
.ok_or_else(|| FrankenError::DatabaseCorrupt {
detail: "materialized cell invalid rowid varint".to_owned(),
})?;
let payload_start = ps_len + rowid_len;
let payload_len =
usize::try_from(payload_size).map_err(|_| FrankenError::DatabaseCorrupt {
detail: "materialized payload length exceeds usize".to_owned(),
})?;
let payload_end = payload_start.checked_add(payload_len).ok_or_else(|| {
FrankenError::DatabaseCorrupt {
detail: "materialized payload length overflow".to_owned(),
}
})?;
let payload = cell.get(payload_start..payload_end).ok_or_else(|| {
FrankenError::DatabaseCorrupt {
detail: "materialized payload out of bounds".to_owned(),
}
})?;
payloads.push((rowid as i64, payload.to_vec()));
}
Ok(payloads)
}
fn encode_varint_u64(value: u64, buf: &mut [u8]) -> usize {
encode_varint_i64(value as i64, buf)
}
fn create_delta_insert(rowid: i64, payload: &[u8], commit_seq: u64) -> CellDelta {
CellDelta {
commit_seq: CommitSeq::new(commit_seq),
created_by: test_txn(),
cell_key: crate::cell_visibility::CellKey::table_row(
fsqlite_types::BtreeRef::Table(fsqlite_types::TableId::new(1)),
rowid,
),
kind: CellDeltaKind::Insert,
page_number: PageNumber::new(2).unwrap(),
cell_data: create_leaf_table_cell(rowid, payload),
prev_idx: None,
}
}
fn create_delta_delete(commit_seq: u64) -> CellDelta {
CellDelta {
commit_seq: CommitSeq::new(commit_seq),
created_by: test_txn(),
cell_key: crate::cell_visibility::CellKey::table_row(
fsqlite_types::BtreeRef::Table(fsqlite_types::TableId::new(1)),
100,
),
kind: CellDeltaKind::Delete,
page_number: PageNumber::new(2).unwrap(),
cell_data: vec![],
prev_idx: None,
}
}
fn create_delta_update(rowid: i64, payload: &[u8], commit_seq: u64) -> CellDelta {
CellDelta {
commit_seq: CommitSeq::new(commit_seq),
created_by: test_txn(),
cell_key: crate::cell_visibility::CellKey::table_row(
fsqlite_types::BtreeRef::Table(fsqlite_types::TableId::new(1)),
rowid,
),
kind: CellDeltaKind::Update,
page_number: PageNumber::new(2).unwrap(),
cell_data: create_leaf_table_cell(rowid, payload),
prev_idx: None,
}
}
fn create_delta_index_insert(key: &[u8], commit_seq: u64) -> CellDelta {
let btree = fsqlite_types::BtreeRef::Index(fsqlite_types::IndexId::new(1));
CellDelta {
commit_seq: CommitSeq::new(commit_seq),
created_by: test_txn(),
cell_key: crate::cell_visibility::CellKey::index_entry(btree, key),
kind: CellDeltaKind::Insert,
page_number: PageNumber::new(2).unwrap(),
cell_data: create_leaf_index_cell(key),
prev_idx: None,
}
}
fn create_delta_index_delete(key: &[u8], commit_seq: u64) -> CellDelta {
let btree = fsqlite_types::BtreeRef::Index(fsqlite_types::IndexId::new(1));
CellDelta {
commit_seq: CommitSeq::new(commit_seq),
created_by: test_txn(),
cell_key: crate::cell_visibility::CellKey::index_entry(btree, key),
kind: CellDeltaKind::Delete,
page_number: PageNumber::new(2).unwrap(),
cell_data: Vec::new(),
prev_idx: None,
}
}
#[test]
fn test_materialize_single_insert() {
let base = create_empty_leaf_table_page();
let page_no = PageNumber::new(2).unwrap();
let deltas = vec![create_delta_insert(100, b"hello world", 5)];
let snapshot = test_snapshot(10);
let result = materialize_page(
&base,
page_no,
&deltas,
&snapshot,
USABLE_SIZE,
MaterializationTrigger::Explicit,
)
.expect("materialization should succeed");
assert_eq!(result.deltas_applied, 1);
assert_eq!(result.cell_count, 1);
let header = BtreePageHeader::parse(result.page.as_bytes(), 0).unwrap();
assert_eq!(header.cell_count, 1);
assert_eq!(header.page_type, BtreePageType::LeafTable);
}
#[test]
fn test_materialize_multiple_inserts() {
let base = create_empty_leaf_table_page();
let page_no = PageNumber::new(2).unwrap();
let deltas: Vec<CellDelta> = (0..10)
.map(|i| {
create_delta_insert(i as i64 + 100, format!("value{i}").as_bytes(), i as u64 + 1)
})
.collect();
let snapshot = test_snapshot(20);
let result = materialize_page(
&base,
page_no,
&deltas,
&snapshot,
USABLE_SIZE,
MaterializationTrigger::Explicit,
)
.expect("materialization should succeed");
assert_eq!(result.deltas_applied, 10);
assert_eq!(result.cell_count, 10);
let header = BtreePageHeader::parse(result.page.as_bytes(), 0).unwrap();
assert_eq!(header.cell_count, 10);
}
#[test]
fn test_materialize_update() {
let base = create_empty_leaf_table_page();
let page_no = PageNumber::new(2).unwrap();
let deltas = vec![
create_delta_insert(100, b"original", 5),
create_delta_update(100, b"updated", 10),
];
let snapshot = test_snapshot(15);
let result = materialize_page(
&base,
page_no,
&deltas,
&snapshot,
USABLE_SIZE,
MaterializationTrigger::Explicit,
)
.expect("materialization should succeed");
assert_eq!(result.deltas_applied, 2);
assert_eq!(result.cell_count, 1);
let header = BtreePageHeader::parse(result.page.as_bytes(), 0).unwrap();
assert_eq!(header.cell_count, 1);
}
#[test]
fn test_materialize_updates_existing_multi_byte_rowid_base_cell() {
let base = create_empty_leaf_table_page();
let page_no = PageNumber::new(2).unwrap();
let original_payload = vec![b'a'; 130];
let updated_payload = vec![b'b'; 131];
let initial = materialize_page(
&base,
page_no,
&[create_delta_insert(4242, &original_payload, 5)],
&test_snapshot(5),
USABLE_SIZE,
MaterializationTrigger::Explicit,
)
.expect("initial materialization should succeed");
let result = materialize_page(
&initial.page,
page_no,
&[create_delta_update(4242, &updated_payload, 10)],
&test_snapshot(10),
USABLE_SIZE,
MaterializationTrigger::Explicit,
)
.expect("update materialization should succeed");
assert_eq!(result.deltas_applied, 1);
assert_eq!(result.cell_count, 1);
assert_eq!(
materialized_table_payloads(&result.page).unwrap(),
vec![(4242, updated_payload)]
);
}
#[test]
fn test_materialize_preserves_negative_rowid_base_cell() {
let base = create_empty_leaf_table_page();
let page_no = PageNumber::new(2).unwrap();
let initial = materialize_page(
&base,
page_no,
&[create_delta_insert(-7, b"original", 5)],
&test_snapshot(5),
USABLE_SIZE,
MaterializationTrigger::Explicit,
)
.expect("initial negative-rowid materialization should succeed");
let result = materialize_page(
&initial.page,
page_no,
&[create_delta_update(-7, b"updated", 10)],
&test_snapshot(10),
USABLE_SIZE,
MaterializationTrigger::Explicit,
)
.expect("negative-rowid update materialization should succeed");
assert_eq!(result.deltas_applied, 1);
assert_eq!(
materialized_table_payloads(&result.page).unwrap(),
vec![(-7, b"updated".to_vec())]
);
}
#[test]
fn test_materialize_deletes_existing_index_base_cell_without_trailing_page_bytes() {
let base = create_empty_leaf_index_page();
let page_no = PageNumber::new(2).unwrap();
let key = b"abc";
let initial = materialize_page(
&base,
page_no,
&[create_delta_index_insert(key, 5)],
&test_snapshot(5),
USABLE_SIZE,
MaterializationTrigger::Explicit,
)
.expect("initial index materialization should succeed");
assert_eq!(initial.cell_count, 1);
let result = materialize_page(
&initial.page,
page_no,
&[create_delta_index_delete(key, 10)],
&test_snapshot(10),
USABLE_SIZE,
MaterializationTrigger::Explicit,
)
.expect("index delete materialization should match the existing base cell");
assert_eq!(result.cell_count, 0);
let header = BtreePageHeader::parse(result.page.as_bytes(), 0).unwrap();
assert_eq!(header.cell_count, 0);
}
#[test]
fn test_index_overflow_cell_digest_includes_overflow_pointer() {
let payload_size = 1_500_u32;
let local_payload =
fsqlite_btree::local_payload_size(payload_size, USABLE_SIZE, BtreePageType::LeafIndex);
assert!(local_payload < payload_size);
let cell_offset = 32_usize;
let mut page = vec![0_u8; PAGE_SIZE as usize];
let mut buf = [0_u8; 10];
let ps_len = encode_varint_u64(u64::from(payload_size), &mut buf);
page[cell_offset..cell_offset + ps_len].copy_from_slice(&buf[..ps_len]);
let key_start = cell_offset + ps_len;
let local_len = local_payload as usize;
for (idx, byte) in page[key_start..key_start + local_len]
.iter_mut()
.enumerate()
{
*byte = (idx % 251) as u8;
}
let overflow_ptr = 0x0102_0304_u32.to_be_bytes();
page[key_start + local_len..key_start + local_len + 4].copy_from_slice(&overflow_ptr);
let btree = fsqlite_types::BtreeRef::Index(fsqlite_types::IndexId::new(7));
let (digest, sort_key) = compute_cell_key_and_sort_key(
&page,
cell_offset,
BtreePageType::LeafIndex,
btree,
USABLE_SIZE,
)
.expect("overflow index cell key should parse");
let digest_bytes = &page[key_start..key_start + local_len + 4];
let expected = fsqlite_types::SemanticKeyRef::compute_digest(
fsqlite_types::SemanticKeyKind::IndexEntry,
btree,
digest_bytes,
);
let local_only = fsqlite_types::SemanticKeyRef::compute_digest(
fsqlite_types::SemanticKeyKind::IndexEntry,
btree,
&page[key_start..key_start + local_len],
);
assert_eq!(digest, expected);
assert_ne!(
digest, local_only,
"overflow index cell identity must include the overflow pointer"
);
assert!(
matches!(&sort_key, SortKey::IndexKey(_)),
"index cells must sort by index key bytes"
);
let SortKey::IndexKey(key) = sort_key else {
return;
};
assert_eq!(key, page[key_start..key_start + local_len]);
}
#[test]
fn test_materialize_rejects_insert_delta_without_cell_bytes() {
let base = create_empty_leaf_table_page();
let page_no = PageNumber::new(2).unwrap();
let mut delta = create_delta_insert(100, b"value", 5);
delta.cell_data.clear();
assert!(
materialize_page(
&base,
page_no,
&[delta],
&test_snapshot(5),
USABLE_SIZE,
MaterializationTrigger::Explicit,
)
.is_err()
);
}
#[test]
fn test_materialize_preserves_collected_same_txn_order_for_one_cell() -> Result<()> {
let base = create_empty_leaf_table_page();
let page_no = PageNumber::new(2).ok_or_else(|| FrankenError::DatabaseCorrupt {
detail: "valid test page number".to_owned(),
})?;
let log = crate::cell_visibility::CellVisibilityLog::new(1024 * 1024);
let btree = fsqlite_types::BtreeRef::Table(fsqlite_types::TableId::new(1));
let cell_key = crate::cell_visibility::CellKey::table_row(btree, 100);
let token = test_txn();
log.record_insert(
cell_key,
page_no,
create_leaf_table_cell(100, b"original"),
token,
)
.ok_or_else(|| FrankenError::DatabaseCorrupt {
detail: "insert should fit test budget".to_owned(),
})?;
log.record_update(
cell_key,
page_no,
create_leaf_table_cell(100, b"updated"),
token,
)
.ok_or_else(|| FrankenError::DatabaseCorrupt {
detail: "update should fit test budget".to_owned(),
})?;
log.commit_txn(token, CommitSeq::new(10));
let deltas = log.collect_visible_deltas(page_no, CommitSeq::new(10));
let snapshot = test_snapshot(10);
let result = materialize_page(
&base,
page_no,
&deltas,
&snapshot,
USABLE_SIZE,
MaterializationTrigger::Explicit,
)?;
assert_eq!(result.deltas_applied, 2);
assert_eq!(result.cell_count, 1);
assert_eq!(
materialized_table_payloads(&result.page)?,
vec![(100, b"updated".to_vec())]
);
Ok(())
}
#[test]
fn test_materialize_delete() {
let base = create_empty_leaf_table_page();
let page_no = PageNumber::new(2).unwrap();
let deltas = vec![
create_delta_insert(100, b"to be deleted", 5),
create_delta_delete(10),
];
let snapshot = test_snapshot(15);
let result = materialize_page(
&base,
page_no,
&deltas,
&snapshot,
USABLE_SIZE,
MaterializationTrigger::Explicit,
)
.expect("materialization should succeed");
assert_eq!(result.deltas_applied, 2);
assert_eq!(result.cell_count, 0);
assert_eq!(
materialized_table_payloads(&result.page).unwrap(),
Vec::new()
);
}
#[test]
fn test_materialize_mixed() {
let base = create_empty_leaf_table_page();
let page_no = PageNumber::new(2).unwrap();
let mut deltas = Vec::new();
for i in 0..20 {
deltas.push(create_delta_insert(
i as i64 + 100,
format!("val{i}").as_bytes(),
i as u64 + 1,
));
}
for i in 0..5 {
deltas.push(create_delta_update(
i as i64 + 100,
format!("upd{i}").as_bytes(),
25 + i as u64,
));
}
for _ in 0..3 {
deltas.push(create_delta_delete(35));
}
let snapshot = test_snapshot(50);
let result = materialize_page(
&base,
page_no,
&deltas,
&snapshot,
USABLE_SIZE,
MaterializationTrigger::Explicit,
)
.expect("materialization should succeed");
assert_eq!(result.deltas_applied, 28);
assert_eq!(result.cell_count, 19);
let payloads = materialized_table_payloads(&result.page).unwrap();
assert!(!payloads.iter().any(|(rowid, _)| *rowid == 100));
}
#[test]
fn test_materialize_snapshot_visibility() {
let base = create_empty_leaf_table_page();
let page_no = PageNumber::new(2).unwrap();
let deltas = vec![
create_delta_insert(100, b"visible", 5),
create_delta_insert(101, b"not visible", 15),
];
let snapshot = test_snapshot(10);
let result = materialize_page(
&base,
page_no,
&deltas,
&snapshot,
USABLE_SIZE,
MaterializationTrigger::Explicit,
)
.expect("materialization should succeed");
assert_eq!(result.deltas_applied, 1);
assert_eq!(result.cell_count, 1);
}
#[test]
fn test_materialize_threshold_trigger() {
assert!(!should_materialize_eagerly(
10,
DEFAULT_MATERIALIZATION_THRESHOLD
));
assert!(!should_materialize_eagerly(
32,
DEFAULT_MATERIALIZATION_THRESHOLD
));
assert!(should_materialize_eagerly(
33,
DEFAULT_MATERIALIZATION_THRESHOLD
));
assert!(should_materialize_eagerly(
100,
DEFAULT_MATERIALIZATION_THRESHOLD
));
}
#[test]
fn test_materialize_round_trip() {
let base = create_empty_leaf_table_page();
let page_no = PageNumber::new(2).unwrap();
let deltas: Vec<CellDelta> = (0..5)
.map(|i| create_delta_insert(i as i64 + 1, format!("data{i}").as_bytes(), i as u64 + 1))
.collect();
let snapshot = test_snapshot(10);
let result = materialize_page(
&base,
page_no,
&deltas,
&snapshot,
USABLE_SIZE,
MaterializationTrigger::Explicit,
)
.expect("materialization should succeed");
let header = BtreePageHeader::parse(result.page.as_bytes(), 0).unwrap();
assert_eq!(header.cell_count, 5);
assert_eq!(header.page_type, BtreePageType::LeafTable);
assert_eq!(header.first_freeblock, 0);
assert_eq!(header.fragmented_free_bytes, 0);
let pointers = read_cell_pointers(result.page.as_bytes(), &header, 0).unwrap();
assert_eq!(pointers.len(), 5);
for &ptr in &pointers {
assert!(ptr >= header.cell_content_offset as u16);
assert!((ptr as usize) < result.page.len());
}
}
#[test]
fn test_materialize_empty_deltas() {
let base = create_empty_leaf_table_page();
let page_no = PageNumber::new(2).unwrap();
let deltas: Vec<CellDelta> = vec![];
let snapshot = test_snapshot(10);
let result = materialize_page(
&base,
page_no,
&deltas,
&snapshot,
USABLE_SIZE,
MaterializationTrigger::Checkpoint,
)
.expect("materialization should succeed");
assert_eq!(result.deltas_applied, 0);
assert_eq!(result.cell_count, 0);
assert_eq!(result.page.as_bytes(), base.as_bytes());
}
#[test]
fn test_materialize_interior_page_fails() {
let mut page = vec![0u8; PAGE_SIZE as usize];
page[0] = BtreePageType::InteriorTable as u8;
page[3] = 0;
page[4] = 0; page[5] = 0x10;
page[6] = 0x00; page[8] = 0;
page[9] = 0;
page[10] = 0;
page[11] = 3;
let base = PageData::from_vec(page);
let page_no = PageNumber::new(2).unwrap();
let deltas = vec![create_delta_insert(100, b"test", 5)];
let snapshot = test_snapshot(10);
let result = materialize_page(
&base,
page_no,
&deltas,
&snapshot,
USABLE_SIZE,
MaterializationTrigger::Explicit,
);
assert!(result.is_err());
}
fn create_bulk_inserts(
count: usize,
payload_size: usize,
commit_seq_base: u64,
) -> Vec<CellDelta> {
(0..count)
.map(|i| {
let payload: Vec<u8> = (0..payload_size).map(|b| (b + i) as u8).collect();
create_delta_insert((i + 1) as i64, &payload, commit_seq_base + i as u64)
})
.collect()
}
#[test]
fn test_split_trigger_materializes() {
let base = create_empty_leaf_table_page();
let page_no = PageNumber::new(2).unwrap();
let snapshot = test_snapshot(50);
let deltas = create_bulk_inserts(35, 100, 1);
let result = materialize_page(
&base,
page_no,
&deltas,
&snapshot,
USABLE_SIZE,
MaterializationTrigger::Structural,
)
.expect("structural materialization of 35 cells should succeed");
assert_eq!(result.deltas_applied, 35);
assert_eq!(result.cell_count, 35);
assert!(result.duration_us < 10_000_000, "should be fast");
let header = BtreePageHeader::parse(result.page.as_bytes(), 0).unwrap();
assert_eq!(header.cell_count, 35);
assert_eq!(header.page_type, BtreePageType::LeafTable);
assert_eq!(
header.fragmented_free_bytes, 0,
"freshly packed page has no fragmentation"
);
let ptr_array_end = 8 + 35 * CELL_POINTER_SIZE as usize;
assert!(
header.cell_content_offset as usize >= ptr_array_end,
"cell content offset ({}) must be past pointer array ({})",
header.cell_content_offset,
ptr_array_end,
);
let payloads = materialized_table_payloads(&result.page).unwrap();
assert_eq!(payloads.len(), 35);
for (i, (rowid, payload)) in payloads.iter().enumerate() {
let expected_rowid = (i + 1) as i64;
assert_eq!(*rowid, expected_rowid, "row {i} has wrong rowid");
assert_eq!(payload.len(), 100, "row {i} has wrong payload length");
assert_eq!(payload[0], i as u8, "row {i} has wrong payload content");
}
let overflow_deltas = create_bulk_inserts(50, 100, 1);
let overflow_result = materialize_page(
&base,
page_no,
&overflow_deltas,
&snapshot,
USABLE_SIZE,
MaterializationTrigger::Structural,
);
assert!(
overflow_result.is_err(),
"50 cells x 100-byte payload should overflow a 4096-byte page"
);
let committed = create_bulk_inserts(20, 80, 1);
let extra: Vec<CellDelta> = (20..30)
.map(|i| {
let payload: Vec<u8> = (0..80_usize).map(|b| (b + i) as u8).collect();
create_delta_insert((i + 1) as i64, &payload, (i + 1) as u64)
})
.collect();
let all_deltas: Vec<CellDelta> = committed.into_iter().chain(extra).collect();
let result2 = materialize_page(
&base,
page_no,
&all_deltas,
&snapshot,
USABLE_SIZE,
MaterializationTrigger::Structural,
)
.expect("structural materialization of 30 cells should succeed");
assert_eq!(result2.deltas_applied, 30);
assert_eq!(result2.cell_count, 30);
let payloads2 = materialized_table_payloads(&result2.page).unwrap();
let rowids: Vec<i64> = payloads2.iter().map(|(r, _)| *r).collect();
let mut sorted = rowids.clone();
sorted.sort();
assert_eq!(rowids, sorted, "rows must be in ascending rowid order");
}
#[test]
fn test_checkpoint_materializes_all() {
let log = crate::cell_visibility::CellVisibilityLog::new(1024 * 1024);
let btree = fsqlite_types::BtreeRef::Table(fsqlite_types::TableId::new(1));
let txn1 = TxnToken::new(TxnId::new(1).unwrap(), TxnEpoch::new(0));
let txn2 = TxnToken::new(TxnId::new(2).unwrap(), TxnEpoch::new(0));
let page2 = PageNumber::new(2).unwrap();
let page3 = PageNumber::new(3).unwrap();
let page4 = PageNumber::new(4).unwrap();
for i in 0..3 {
let cell_key = crate::cell_visibility::CellKey::table_row(btree, 100 + i);
let cell_data = create_leaf_table_cell(100 + i, format!("txn1_p2_{i}").as_bytes());
log.record_insert(cell_key, page2, cell_data, txn1)
.expect("insert should fit budget");
}
for i in 0..2 {
let cell_key = crate::cell_visibility::CellKey::table_row(btree, 200 + i);
let cell_data = create_leaf_table_cell(200 + i, format!("txn1_p3_{i}").as_bytes());
log.record_insert(cell_key, page3, cell_data, txn1)
.expect("insert should fit budget");
}
for i in 0..4 {
let cell_key = crate::cell_visibility::CellKey::table_row(btree, 300 + i);
let cell_data = create_leaf_table_cell(300 + i, format!("txn2_p4_{i}").as_bytes());
log.record_insert(cell_key, page4, cell_data, txn2)
.expect("insert should fit budget");
}
{
let cell_key = crate::cell_visibility::CellKey::table_row(btree, 103);
let cell_data = create_leaf_table_cell(103, b"txn2_p2_overlap");
log.record_insert(cell_key, page2, cell_data, txn2)
.expect("insert should fit budget");
}
log.commit_txn(txn1, CommitSeq::new(10));
log.commit_txn(txn2, CommitSeq::new(20));
let dirty_pages = log.pages_with_deltas();
assert!(
dirty_pages.len() >= 3,
"should have deltas on at least 3 pages, got {}",
dirty_pages.len()
);
assert!(dirty_pages.contains(&page2), "page 2 should have deltas");
assert!(dirty_pages.contains(&page3), "page 3 should have deltas");
assert!(dirty_pages.contains(&page4), "page 4 should have deltas");
let checkpoint_snapshot = test_snapshot(25);
let base = create_empty_leaf_table_page();
let expected: std::collections::HashMap<PageNumber, u16> =
[(page2, 4), (page3, 2), (page4, 4)].into_iter().collect();
let mut materialized_pages: std::collections::HashMap<PageNumber, MaterializationResult> =
std::collections::HashMap::new();
for &pgno in &dirty_pages {
let deltas = log.collect_visible_deltas(pgno, checkpoint_snapshot.high);
if deltas.is_empty() {
continue;
}
let result = materialize_page(
&base,
pgno,
&deltas,
&checkpoint_snapshot,
USABLE_SIZE,
MaterializationTrigger::Checkpoint,
)
.unwrap_or_else(|e| {
panic!("materialization of page {} failed: {e}", pgno.get());
});
materialized_pages.insert(pgno, result);
}
assert_eq!(
materialized_pages.len(),
3,
"checkpoint should materialize exactly 3 pages"
);
for (&pgno, result) in &materialized_pages {
let want = expected[&pgno];
assert_eq!(
result.cell_count,
want,
"page {} should have {want} cells, got {}",
pgno.get(),
result.cell_count
);
let header = BtreePageHeader::parse(result.page.as_bytes(), 0).unwrap();
assert_eq!(header.cell_count, want);
assert_eq!(header.page_type, BtreePageType::LeafTable);
assert_eq!(
header.fragmented_free_bytes, 0,
"freshly packed page should have no fragmentation"
);
let pointers = read_cell_pointers(result.page.as_bytes(), &header, 0).unwrap();
assert_eq!(pointers.len(), want as usize);
for &ptr in &pointers {
assert!(
(ptr as usize) < PAGE_SIZE as usize,
"cell pointer {ptr} out of bounds"
);
}
let payloads = materialized_table_payloads(&result.page).unwrap();
let rowids: Vec<i64> = payloads.iter().map(|(r, _)| *r).collect();
let mut sorted = rowids.clone();
sorted.sort();
assert_eq!(
rowids,
sorted,
"page {} rows must be in ascending rowid order",
pgno.get()
);
}
for &pgno in &dirty_pages {
log.clear_page_deltas(pgno, CommitSeq::new(25));
}
let remaining = log.pages_with_deltas();
assert!(
remaining.is_empty(),
"all deltas should be cleared after checkpoint; {} pages still dirty",
remaining.len()
);
assert_eq!(
log.delta_count(),
0,
"global delta count should be 0 after clearing all pages"
);
}
}