use crate::error::StorageError;
use crate::heap::HeapFile;
use crate::page::{OVERFLOW_CHAIN_END, OVERFLOW_PAYLOAD_CAP};
use crate::row::{encode_row_into, encode_row_v2_into, plan_spill, OverflowStub, MAX_VALUE_SIZE};
use crate::table::Table;
use crate::types::*;
use crate::wal::{Wal, WalDurabilityTicket, WalRecord, WalRecordType, WalSyncMode};
use rustc_hash::FxHashMap;
use std::fs;
use std::io::{self, Read, Write};
use std::path::{Path, PathBuf};
use tracing::{info, warn};
fn check_encoded_row_size(encoded: &[u8]) -> io::Result<()> {
if encoded.len() > crate::page::MAX_ROW_DATA_SIZE {
return Err(crate::error::StorageError::RowTooLarge {
size: encoded.len(),
max: crate::page::MAX_ROW_DATA_SIZE,
}
.into());
}
Ok(())
}
fn validate_identifier(kind: &str, name: &str) -> io::Result<()> {
if name.is_empty() {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("invalid {kind} name: must not be empty"),
));
}
let mut chars = name.chars();
let first = chars.next().expect("non-empty name");
if !first.is_ascii_alphabetic() && first != '_' {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("invalid {kind} name '{name}': must start with a letter or underscore"),
));
}
for ch in chars {
if !ch.is_ascii_alphanumeric() && ch != '_' {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!(
"invalid {kind} name '{name}': must contain only letters, digits, and underscores"
),
));
}
}
Ok(())
}
fn validate_table_name(name: &str) -> io::Result<()> {
validate_identifier("table", name)
}
fn validate_column_name(name: &str) -> io::Result<()> {
validate_identifier("column", name)
}
const CATALOG_FILE: &str = "catalog.bin";
pub const CATALOG_LSN_FILE: &str = "catalog.lsn";
const CATALOG_MAGIC: &[u8; 4] = b"BCAT";
pub const CATALOG_VERSION: u16 = 5;
const WAL_FILE: &str = "wal.log";
const SYNC_STATE_DIR: &str = ".powdb-sync";
const SYNC_IDENTITY_FILE: &str = "identity.json";
const WAL_BATCH_SIZE: usize = 64;
type WalArchiveCallback<'a> = &'a mut dyn FnMut(&Path, &[WalRecord]) -> io::Result<()>;
fn read_durable_lsn(data_dir: &Path) -> io::Result<u64> {
let path = data_dir.join(CATALOG_LSN_FILE);
let bytes = match fs::read(path) {
Ok(bytes) => bytes,
Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(0),
Err(err) => return Err(err),
};
if bytes.len() != 8 {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"catalog LSN sidecar has invalid length",
));
}
let mut buf = [0u8; 8];
buf.copy_from_slice(&bytes);
Ok(u64::from_le_bytes(buf))
}
fn write_durable_lsn(data_dir: &Path, lsn: u64) -> io::Result<()> {
let path = data_dir.join(CATALOG_LSN_FILE);
let tmp_path = data_dir.join(format!("{CATALOG_LSN_FILE}.tmp"));
let mut file = fs::File::create(&tmp_path)?;
file.write_all(&lsn.to_le_bytes())?;
file.sync_all()?;
drop(file);
fs::rename(&tmp_path, &path)?;
sync_directory(data_dir)?;
Ok(())
}
#[cfg(unix)]
fn sync_directory(path: &Path) -> io::Result<()> {
fs::File::open(path)?.sync_all()
}
#[cfg(not(unix))]
fn sync_directory(path: &Path) -> io::Result<()> {
let _ = path;
Ok(())
}
fn max_record_lsn(records: &[WalRecord]) -> Option<u64> {
records.iter().map(|record| record.lsn).max()
}
pub struct Catalog {
tables: Vec<Table>,
name_to_slot: FxHashMap<String, usize>,
data_dir: PathBuf,
wal: Wal,
next_tx_id: u64,
active_tx_id: Option<u64>,
tx_start_len: Option<u64>,
pending_autocommit_tx_ids: Vec<u64>,
checkpointed: bool,
durable_lsn: u64,
pending_free_overflow: Vec<(usize, Vec<u32>)>,
}
impl Catalog {
pub fn create(data_dir: &Path) -> io::Result<Self> {
crate::create_data_dir_secure(data_dir)?;
let wal_path = data_dir.join(WAL_FILE);
let wal = Wal::create(&wal_path, WAL_BATCH_SIZE)?;
let cat = Catalog {
tables: Vec::new(),
name_to_slot: FxHashMap::default(),
data_dir: data_dir.to_path_buf(),
wal,
next_tx_id: 1,
active_tx_id: None,
tx_start_len: None,
pending_autocommit_tx_ids: Vec::new(),
pending_free_overflow: Vec::new(),
checkpointed: false,
durable_lsn: 0,
};
cat.persist()?;
Ok(cat)
}
pub fn open(data_dir: &Path) -> io::Result<Self> {
Self::open_inner(data_dir, None)
}
pub fn open_with_wal_archive<F>(data_dir: &Path, mut archive: F) -> io::Result<Self>
where
F: FnMut(&Path, &[WalRecord]) -> io::Result<()>,
{
let archive: WalArchiveCallback<'_> = &mut archive;
Self::open_inner(data_dir, Some(archive))
}
fn open_inner(data_dir: &Path, archive: Option<WalArchiveCallback<'_>>) -> io::Result<Self> {
let cat_path = data_dir.join(CATALOG_FILE);
if !cat_path.exists() {
return Err(io::Error::new(io::ErrorKind::NotFound, "no catalog file"));
}
let entries = read_catalog_file(&cat_path)?;
let durable_lsn = read_durable_lsn(data_dir)?;
let mut tables: Vec<Table> = Vec::with_capacity(entries.len());
let mut name_to_slot =
FxHashMap::with_capacity_and_hasher(entries.len(), Default::default());
for CatalogEntry {
schema,
indexed_cols,
defaults,
auto_cols,
} in entries
{
let name = schema.table_name.clone();
let mut table = Table::open_with_indexes(schema, data_dir, &indexed_cols)?;
table.set_defaults(defaults);
table.set_auto_cols(auto_cols);
name_to_slot.insert(name, tables.len());
tables.push(table);
}
let wal_path = data_dir.join(WAL_FILE);
let wal = Wal::open(&wal_path, WAL_BATCH_SIZE)?;
let mut cat = Catalog {
tables,
name_to_slot,
data_dir: data_dir.to_path_buf(),
wal,
next_tx_id: 1,
active_tx_id: None,
tx_start_len: None,
pending_autocommit_tx_ids: Vec::new(),
pending_free_overflow: Vec::new(),
checkpointed: false,
durable_lsn,
};
cat.replay_wal(archive)?;
let max_page_lsn = cat
.tables
.iter()
.map(|t| t.heap.max_page_lsn())
.max()
.unwrap_or(0);
let max_known_lsn = max_page_lsn.max(cat.durable_lsn);
cat.wal.set_next_lsn_at_least(max_known_lsn + 1);
if let Err(e) = cat.sweep_all() {
warn!(error = %e, "post-recovery overflow sweep failed (non-fatal)");
}
Ok(cat)
}
fn replay_wal(&mut self, mut archive: Option<WalArchiveCallback<'_>>) -> io::Result<()> {
let records = self.wal.read_all()?;
if records.is_empty() {
return Ok(());
}
if archive.is_none() {
self.ensure_plain_wal_truncate_allowed(&records)?;
}
self.replay_records(&records)?;
if let Some(archive) = archive.as_mut() {
archive(&self.data_dir, &records)?;
}
self.wal.truncate()?;
Ok(())
}
pub fn apply_wal_records(&mut self, records: &[WalRecord]) -> io::Result<()> {
self.ensure_no_active_transaction_for_checkpoint()?;
self.ensure_no_pending_wal_records()?;
self.replay_records(records)
}
pub fn ensure_no_pending_wal_records(&self) -> io::Result<()> {
if self.wal.has_pending() || !self.wal.read_all()?.is_empty() {
return Err(io::Error::other(
"cannot apply replicated WAL records while local WAL records are pending",
));
}
Ok(())
}
fn replay_records(&mut self, records: &[WalRecord]) -> io::Result<()> {
if records.is_empty() {
return Ok(());
}
info!(count = records.len(), "applying WAL records");
let has_boundaries = records.iter().any(|rec| {
matches!(
rec.record_type,
WalRecordType::Begin | WalRecordType::Commit | WalRecordType::Rollback
)
});
let mut committed_row_records = vec![true; records.len()];
if has_boundaries {
committed_row_records.fill(false);
let mut pending_tx_spans: Vec<(u64, Vec<usize>)> = Vec::new();
for (index, rec) in records.iter().enumerate() {
match rec.record_type {
WalRecordType::Insert
| WalRecordType::Update
| WalRecordType::Delete
| WalRecordType::OverflowWrite
| WalRecordType::OverflowFree
if rec.tx_id == 0 =>
{
committed_row_records[index] = true;
}
WalRecordType::Insert
| WalRecordType::Update
| WalRecordType::Delete
| WalRecordType::OverflowWrite
| WalRecordType::OverflowFree => {
if let Some((_, rows)) = pending_tx_spans
.iter_mut()
.rev()
.find(|(tx_id, _)| *tx_id == rec.tx_id)
{
rows.push(index);
} else {
pending_tx_spans.push((rec.tx_id, vec![index]));
}
}
WalRecordType::Begin if rec.tx_id != 0 => {
pending_tx_spans.push((rec.tx_id, Vec::new()));
}
WalRecordType::Commit if rec.tx_id != 0 => {
if let Some(span_index) = pending_tx_spans
.iter()
.rposition(|(tx_id, _)| *tx_id == rec.tx_id)
{
let (_, rows) = pending_tx_spans.remove(span_index);
for row_index in rows {
committed_row_records[row_index] = true;
}
}
}
WalRecordType::Rollback if rec.tx_id != 0 => {
if let Some(span_index) = pending_tx_spans
.iter()
.rposition(|(tx_id, _)| *tx_id == rec.tx_id)
{
pending_tx_spans.remove(span_index);
}
}
_ => {}
}
}
}
let mut replayed_inserts = 0usize;
let mut replayed_updates = 0usize;
let mut replayed_deletes = 0usize;
let mut skipped = 0usize;
let mut skipped_uncommitted = 0usize;
let mut saw_ddl = false;
for (index, rec) in records.iter().enumerate() {
if has_boundaries
&& !committed_row_records[index]
&& matches!(
rec.record_type,
WalRecordType::Insert
| WalRecordType::Update
| WalRecordType::Delete
| WalRecordType::OverflowWrite
| WalRecordType::OverflowFree
)
{
skipped_uncommitted += 1;
continue;
}
match rec.record_type {
WalRecordType::Insert => {
if let Some((table_name, rid, row_bytes)) = decode_wal_payload(&rec.data) {
if let Some(slot) = self.name_to_slot.get(&table_name).copied() {
let tbl = &mut self.tables[slot];
if rec.lsn > 0 && tbl.heap.page_lsn(rid.page_id) >= rec.lsn {
skipped += 1;
continue;
}
tbl.heap.insert_at(rid, &row_bytes)?;
tbl.heap.set_page_lsn(rid.page_id, rec.lsn)?;
replayed_inserts += 1;
}
}
}
WalRecordType::Update => {
if let Some((table_name, rid, row_bytes)) = decode_wal_payload(&rec.data) {
if let Some(slot) = self.name_to_slot.get(&table_name).copied() {
let tbl = &mut self.tables[slot];
if rec.lsn > 0 && tbl.heap.page_lsn(rid.page_id) >= rec.lsn {
skipped += 1;
continue;
}
let new_rid = tbl.heap.update(rid, &row_bytes)?;
tbl.heap.set_page_lsn(new_rid.page_id, rec.lsn)?;
replayed_updates += 1;
}
}
}
WalRecordType::Delete => {
if let Some((table_name, rid, _)) = decode_wal_payload(&rec.data) {
if let Some(slot) = self.name_to_slot.get(&table_name).copied() {
let tbl = &mut self.tables[slot];
if rec.lsn > 0 && tbl.heap.page_lsn(rid.page_id) >= rec.lsn {
skipped += 1;
continue;
}
let _ = tbl.heap.delete(rid);
tbl.heap.set_page_lsn(rid.page_id, rec.lsn)?;
replayed_deletes += 1;
}
}
}
WalRecordType::OverflowWrite => {
if let Some((table_name, page_id, next_page, chunk)) =
decode_overflow_write_payload(&rec.data)
{
if let Some(slot) = self.name_to_slot.get(&table_name).copied() {
let tbl = &mut self.tables[slot];
if rec.lsn > 0 && tbl.heap.overflow_page_lsn(page_id) >= rec.lsn {
skipped += 1;
continue;
}
tbl.heap
.write_overflow_page(page_id, next_page, &chunk, rec.lsn)?;
}
}
}
WalRecordType::OverflowFree => {
if let Some((table_name, pages)) = decode_overflow_free_payload(&rec.data) {
if let Some(slot) = self.name_to_slot.get(&table_name).copied() {
self.tables[slot].heap.release_overflow_pages(&pages);
}
}
}
WalRecordType::Begin | WalRecordType::Commit | WalRecordType::Rollback => {
}
WalRecordType::DdlCreateTable => {
saw_ddl = true;
if let Some((schema, defaults, auto_cols)) = decode_ddl_create_table(&rec.data)
{
if !self.name_to_slot.contains_key(&schema.table_name) {
if let Ok(mut table) = Table::create(schema, &self.data_dir) {
table.set_defaults(defaults);
table.set_auto_cols(auto_cols);
let slot = self.tables.len();
let name = table.schema.table_name.clone();
self.tables.push(table);
self.name_to_slot.insert(name, slot);
}
}
}
}
WalRecordType::DdlDropTable => {
saw_ddl = true;
if let Some((table_name, _)) = decode_ddl_table_name(&rec.data) {
if let Some(&slot) = self.name_to_slot.get(&table_name) {
let heap_path = self.data_dir.join(format!("{table_name}.heap"));
if heap_path.exists() {
let _ = fs::remove_file(&heap_path);
}
for col_name in self.tables[slot].indexed_column_names() {
let idx_path =
self.data_dir.join(format!("{table_name}_{col_name}.idx"));
if idx_path.exists() {
let _ = fs::remove_file(&idx_path);
}
}
self.name_to_slot.remove(&table_name);
let last = self.tables.len() - 1;
if slot != last {
let moved_name = self.tables[last].schema.table_name.clone();
self.tables.swap(slot, last);
self.name_to_slot.insert(moved_name, slot);
}
self.tables.pop();
}
}
}
WalRecordType::DdlAddColumn => {
saw_ddl = true;
if let Some((table_name, col)) = decode_ddl_alter_add_column(&rec.data) {
if let Some(&slot) = self.name_to_slot.get(&table_name) {
let tbl = &mut self.tables[slot];
if !tbl.schema.columns.iter().any(|c| c.name == col.name) {
let old_schema = tbl.schema.clone();
let has_rows = tbl.heap.scan().next().is_some();
tbl.schema.columns.push(col);
tbl.refresh_layout();
if has_rows {
let fill = vec![Value::Empty; tbl.schema.columns.len()];
let data_dir = self.data_dir.clone();
let _ = tbl.rewrite_rows_for_schema_change(
&old_schema,
&fill,
&data_dir,
);
}
}
if rec.lsn > 0 {
let _ = tbl.heap.stamp_all_pages_min_lsn(rec.lsn);
}
}
}
}
WalRecordType::DdlDropColumn => {
saw_ddl = true;
if let Some((table_name, col_name)) = decode_ddl_alter_drop_column(&rec.data) {
if let Some(&slot) = self.name_to_slot.get(&table_name) {
let tbl = &mut self.tables[slot];
if let Some(idx) =
tbl.schema.columns.iter().position(|c| c.name == col_name)
{
let old_schema = tbl.schema.clone();
let has_rows = tbl.heap.scan().next().is_some();
tbl.schema.columns.remove(idx);
for (i, c) in tbl.schema.columns.iter_mut().enumerate() {
c.position = i as u16;
}
tbl.refresh_layout();
if has_rows {
let fill = vec![Value::Empty; tbl.schema.columns.len()];
let data_dir = self.data_dir.clone();
let _ = tbl.rewrite_rows_for_schema_change(
&old_schema,
&fill,
&data_dir,
);
}
}
if rec.lsn > 0 {
let _ = tbl.heap.stamp_all_pages_min_lsn(rec.lsn);
}
}
}
}
}
}
info!(
inserts = replayed_inserts,
updates = replayed_updates,
deletes = replayed_deletes,
skipped = skipped,
skipped_uncommitted = skipped_uncommitted,
"WAL record apply complete (commit-boundary + LSN idempotent)"
);
if saw_ddl {
self.persist()?;
}
for tbl in &mut self.tables {
tbl.heap.flush_all_dirty()?;
tbl.heap.flush()?;
tbl.rebuild_indexes_from_heap()?;
tbl.save_dirty_indexes()?;
}
if let Some(max_lsn) = max_record_lsn(records) {
self.record_durable_lsn_at_least(max_lsn)?;
self.wal.set_next_lsn_at_least(max_lsn.saturating_add(1));
}
Ok(())
}
pub fn checkpoint(&mut self) -> io::Result<()> {
self.ensure_no_active_transaction_for_checkpoint()?;
self.ensure_plain_checkpoint_allowed_before_flush()?;
self.flush_checkpoint_state()?;
self.wal.flush()?;
self.record_durable_lsn_at_least(self.wal.last_appended_lsn())?;
self.wal.truncate()?;
self.checkpointed = true;
Ok(())
}
pub fn checkpoint_with_wal_archive<F>(&mut self, mut archive: F) -> io::Result<()>
where
F: FnMut(&Path, &[WalRecord]) -> io::Result<()>,
{
self.ensure_no_active_transaction_for_checkpoint()?;
self.commit_autocommit()?;
self.flush_checkpoint_state()?;
self.wal.flush()?;
let records = self.wal.read_all()?;
let archive: WalArchiveCallback<'_> = &mut archive;
archive(&self.data_dir, &records)?;
if let Some(max_lsn) = max_record_lsn(&records) {
self.record_durable_lsn_at_least(max_lsn)?;
} else {
self.record_durable_lsn_at_least(self.wal.last_appended_lsn())?;
}
self.wal.truncate()?;
self.checkpointed = true;
Ok(())
}
fn ensure_no_active_transaction_for_checkpoint(&self) -> io::Result<()> {
if self.active_tx_id.is_some() {
return Err(io::Error::other(
"cannot checkpoint while an explicit transaction is active",
));
}
Ok(())
}
fn flush_checkpoint_state(&mut self) -> io::Result<()> {
for tbl in &mut self.tables {
tbl.heap.flush_all_dirty()?;
tbl.heap.flush()?;
tbl.save_dirty_indexes()?;
}
Ok(())
}
fn ensure_plain_checkpoint_allowed_before_flush(&self) -> io::Result<()> {
if !self.sync_identity_file_exists() {
return Ok(());
}
if self.wal.has_pending() {
return Err(io::Error::other(
"sync identity exists but checkpoint/recovery was called without a WAL archive hook; refusing to truncate retained history",
));
}
let records = self.wal.read_all()?;
self.ensure_plain_wal_truncate_allowed(&records)
}
fn ensure_plain_wal_truncate_allowed(&self, records: &[WalRecord]) -> io::Result<()> {
if records.is_empty() {
return Ok(());
}
if self.sync_identity_file_exists() {
return Err(io::Error::other(
"sync identity exists but checkpoint/recovery was called without a WAL archive hook; refusing to truncate retained history",
));
}
Ok(())
}
fn sync_identity_file_exists(&self) -> bool {
self.data_dir
.join(SYNC_STATE_DIR)
.join(SYNC_IDENTITY_FILE)
.exists()
}
fn record_durable_lsn_at_least(&mut self, lsn: u64) -> io::Result<()> {
if lsn <= self.durable_lsn {
return Ok(());
}
self.durable_lsn = lsn;
write_durable_lsn(&self.data_dir, lsn)
}
#[inline]
fn free_overflow_chain(&mut self, slot: usize, pages: Vec<u32>) {
if pages.is_empty() {
return;
}
if self.active_tx_id.is_some() {
self.pending_free_overflow.push((slot, pages));
} else {
self.tables[slot].release_overflow_pages(&pages);
}
}
fn next_tx(&mut self) -> u64 {
if let Some(id) = self.active_tx_id {
return id;
}
let id = self.next_tx_id;
self.next_tx_id = self.next_tx_id.wrapping_add(1);
id
}
pub fn begin_transaction(&mut self) -> io::Result<()> {
if self.active_tx_id.is_some() {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"explicit transaction is already active",
));
}
let start_len = self.wal.synced_len()?;
let id = self.next_tx_id;
self.next_tx_id = self.next_tx_id.wrapping_add(1);
self.active_tx_id = Some(id);
self.tx_start_len = Some(start_len);
self.pending_autocommit_tx_ids.clear();
if !self.wal.is_off() {
self.wal.append(id, WalRecordType::Begin, &[])?;
self.wal.flush()?;
}
Ok(())
}
pub fn commit_transaction(&mut self) -> io::Result<()> {
if let Some(id) = self.active_tx_id.take() {
if !self.wal.is_off() {
self.wal.append(id, WalRecordType::Commit, &[])?;
self.wal.flush()?;
}
}
self.tx_start_len = None;
for (slot, pages) in std::mem::take(&mut self.pending_free_overflow) {
self.tables[slot].release_overflow_pages(&pages);
}
Ok(())
}
pub fn commit_autocommit(&mut self) -> io::Result<()> {
if !self.wal.is_off() && !self.pending_autocommit_tx_ids.is_empty() {
self.pending_autocommit_tx_ids.sort_unstable();
self.pending_autocommit_tx_ids.dedup();
for id in self.pending_autocommit_tx_ids.drain(..) {
self.wal.append(id, WalRecordType::Commit, &[])?;
}
}
self.wal.flush()
}
fn wal_log(
&mut self,
tx_id: u64,
record_type: WalRecordType,
table: &str,
rid: RowId,
row_bytes: &[u8],
) -> io::Result<()> {
if self.wal.is_off() {
return Ok(());
}
let payload = encode_wal_payload(table, rid, row_bytes);
self.wal.append(tx_id, record_type, &payload)?;
if self.active_tx_id.is_none() {
self.pending_autocommit_tx_ids.push(tx_id);
}
Ok(())
}
#[inline]
pub fn sync_wal(&mut self) -> io::Result<()> {
self.wal.flush()
}
pub fn set_wal_sync_mode(&mut self, mode: WalSyncMode) {
self.wal.set_sync_mode(mode);
}
pub fn set_wal_sync_deferred(&mut self, defer: bool) {
self.wal.set_defer_sync(defer);
}
pub fn take_wal_durability_ticket(&mut self) -> Option<WalDurabilityTicket> {
self.wal.take_durability_ticket()
}
pub fn wal_fsync_count(&self) -> u64 {
self.wal.fsync_count()
}
pub fn rollback_to_last_sync(&mut self) -> io::Result<()> {
self.rollback_to_last_sync_inner(None)
}
pub fn rollback_to_last_sync_with_wal_archive<F>(&mut self, mut archive: F) -> io::Result<()>
where
F: FnMut(&Path, &[WalRecord]) -> io::Result<()>,
{
let archive: WalArchiveCallback<'_> = &mut archive;
self.rollback_to_last_sync_inner(Some(archive))
}
fn rollback_to_last_sync_inner(
&mut self,
mut archive: Option<WalArchiveCallback<'_>>,
) -> io::Result<()> {
let start_len = self.tx_start_len.unwrap_or(0);
let prearchived = if let Some(archive) = archive.as_mut() {
let records = self.wal.read_through_len(start_len)?;
if !records.is_empty() {
archive(&self.data_dir, &records)?;
}
true
} else {
false
};
let start_len = self.tx_start_len.take().unwrap_or(0);
if let Some(id) = self.active_tx_id.take() {
if !self.wal.is_off() {
let _ = self.wal.append(id, WalRecordType::Rollback, &[]);
}
}
self.wal.discard_and_truncate_to(start_len)?;
for tbl in &mut self.tables {
tbl.heap.discard_dirty();
tbl.discard_dirty_indexes();
}
self.wal.discard_pending()?;
let data_dir = self.data_dir.clone();
let sync_mode = self.wal.sync_mode();
let restored = if prearchived {
let mut already_archived = |_dir: &Path, _records: &[WalRecord]| Ok(());
let archive: WalArchiveCallback<'_> = &mut already_archived;
Self::open_inner(&data_dir, Some(archive))?
} else {
Self::open_inner(&data_dir, archive)?
};
*self = restored;
self.wal.set_sync_mode(sync_mode);
Ok(())
}
fn abandon_active_transaction_for_drop(&mut self) -> io::Result<()> {
for tbl in &mut self.tables {
tbl.heap.discard_dirty();
}
self.pending_autocommit_tx_ids.clear();
let truncate_result = match self.tx_start_len.take() {
Some(start_len) => self.wal.discard_and_truncate_to(start_len),
None => self.wal.discard_pending(),
};
self.active_tx_id = None;
truncate_result
}
pub fn data_dir(&self) -> &Path {
&self.data_dir
}
pub fn max_lsn(&self) -> u64 {
let max_page_lsn = self
.tables
.iter()
.map(|t| t.heap.max_page_lsn())
.max()
.unwrap_or(0);
max_page_lsn
.max(self.durable_lsn)
.max(self.wal.last_appended_lsn())
}
pub fn create_table(&mut self, schema: Schema) -> io::Result<()> {
self.create_table_full(schema, Vec::new(), Vec::new())
}
pub fn create_table_with_defaults(
&mut self,
schema: Schema,
defaults: Vec<Option<Value>>,
) -> io::Result<()> {
self.create_table_full(schema, defaults, Vec::new())
}
pub fn create_table_full(
&mut self,
schema: Schema,
defaults: Vec<Option<Value>>,
auto_cols: Vec<bool>,
) -> io::Result<()> {
validate_table_name(&schema.table_name)?;
for col in &schema.columns {
validate_column_name(&col.name)?;
}
let name = schema.table_name.clone();
if self.name_to_slot.contains_key(&name) {
return Err(io::Error::new(
io::ErrorKind::AlreadyExists,
format!("table '{name}' already exists"),
));
}
if !self.wal.is_off() {
let payload = encode_ddl_create_table(&schema, &defaults, &auto_cols);
self.wal
.append(0, WalRecordType::DdlCreateTable, &payload)?;
self.wal.flush()?;
}
let mut table = Table::create(schema, &self.data_dir)?;
table.set_defaults(defaults);
table.set_auto_cols(auto_cols);
let slot = self.tables.len();
self.tables.push(table);
self.name_to_slot.insert(name, slot);
self.persist()?;
Ok(())
}
pub fn column_defaults(&self, table: &str) -> Option<&[Option<Value>]> {
let slot = *self.name_to_slot.get(table)?;
Some(self.tables[slot].defaults())
}
pub fn auto_columns(&self, table: &str) -> Option<&[bool]> {
let slot = *self.name_to_slot.get(table)?;
Some(self.tables[slot].auto_cols())
}
pub fn assign_auto_columns(&mut self, table: &str, values: &mut [Value]) {
if let Some(&slot) = self.name_to_slot.get(table) {
self.tables[slot].assign_auto(values);
}
}
fn persist(&self) -> io::Result<()> {
let cat_path = self.data_dir.join(CATALOG_FILE);
let tmp_path = self.data_dir.join(format!("{CATALOG_FILE}.tmp"));
let entries: Vec<CatalogEntryRef<'_>> = self
.tables
.iter()
.map(|t| CatalogEntryRef {
schema: &t.schema,
indexed_cols: t.indexed_column_metas(),
defaults: t.defaults(),
auto_cols: t.auto_cols(),
})
.collect();
write_catalog_file(&tmp_path, &entries)?;
fs::rename(&tmp_path, &cat_path)?;
Ok(())
}
#[inline]
pub fn table_slot(&self, name: &str) -> Option<usize> {
self.name_to_slot.get(name).copied()
}
#[inline]
pub fn table_by_slot(&self, slot: usize) -> &Table {
&self.tables[slot]
}
#[inline]
pub fn table_by_slot_mut(&mut self, slot: usize) -> &mut Table {
&mut self.tables[slot]
}
pub fn get_table(&self, name: &str) -> Option<&Table> {
let slot = *self.name_to_slot.get(name)?;
Some(&self.tables[slot])
}
pub fn get_table_mut(&mut self, name: &str) -> Option<&mut Table> {
let slot = *self.name_to_slot.get(name)?;
Some(&mut self.tables[slot])
}
#[inline]
pub fn table_has_overflow(&self, table: &str) -> bool {
self.get_table(table)
.map(|t| t.has_overflow_rows())
.unwrap_or(false)
}
#[inline]
fn by_name(&self, table: &str) -> io::Result<&Table> {
let slot = *self.name_to_slot.get(table).ok_or_else(|| {
io::Error::new(
io::ErrorKind::NotFound,
format!("table '{table}' not found"),
)
})?;
Ok(&self.tables[slot])
}
#[inline]
fn by_name_mut(&mut self, table: &str) -> io::Result<&mut Table> {
let slot = self.slot_of(table)?;
Ok(&mut self.tables[slot])
}
pub fn sweep(&mut self, table: &str) -> io::Result<usize> {
let slot = self.slot_of(table)?;
let reclaimed = self.tables[slot].sweep_overflow()?;
if !reclaimed.is_empty() && !self.wal.is_off() {
let payload = encode_overflow_free_payload(table, &reclaimed);
self.wal.append(0, WalRecordType::OverflowFree, &payload)?;
self.wal.flush()?;
}
Ok(reclaimed.len())
}
pub fn sweep_all(&mut self) -> io::Result<usize> {
let names: Vec<String> = self
.tables
.iter()
.map(|t| t.schema.table_name.clone())
.collect();
let mut total = 0;
for name in names {
total += self.sweep(&name)?;
}
Ok(total)
}
fn slot_of(&self, table: &str) -> io::Result<usize> {
self.name_to_slot.get(table).copied().ok_or_else(|| {
io::Error::new(
io::ErrorKind::NotFound,
format!("table '{table}' not found"),
)
})
}
pub fn insert(&mut self, table: &str, values: &Row) -> io::Result<RowId> {
if self.wal.is_off() {
return self.by_name_mut(table)?.insert(values);
}
let tx_id = self.next_tx();
let slot = self.slot_of(table)?;
let row_bytes = {
let Catalog { tables, wal, .. } = self;
encode_row_with_spill_logged(&mut tables[slot], wal, tx_id, values)?
};
let new_rid = self.tables[slot].insert_encoded(values, &row_bytes)?;
self.wal_log(tx_id, WalRecordType::Insert, table, new_rid, &row_bytes)?;
let lsn = self.wal.last_appended_lsn();
if lsn > 0 {
self.tables[slot].heap.set_page_lsn(new_rid.page_id, lsn)?;
}
Ok(new_rid)
}
pub fn insert_by_slot(&mut self, slot: usize, values: &Row) -> io::Result<RowId> {
if self.wal.is_off() {
return self.tables[slot].insert(values);
}
let tx_id = self.next_tx();
let autocommit = self.active_tx_id.is_none();
let Catalog { tables, wal, .. } = self;
let tbl = &mut tables[slot];
let row_bytes = encode_row_with_spill_logged(tbl, wal, tx_id, values)?;
let new_rid = tbl.insert_encoded(values, &row_bytes)?;
let payload = encode_wal_payload(&tbl.schema.table_name, new_rid, &row_bytes);
wal.append(tx_id, WalRecordType::Insert, &payload)?;
if autocommit {
self.pending_autocommit_tx_ids.push(tx_id);
}
let lsn = wal.last_appended_lsn();
if lsn > 0 {
tbl.heap.set_page_lsn(new_rid.page_id, lsn)?;
}
Ok(new_rid)
}
pub fn get(&self, table: &str, rid: RowId) -> Option<Row> {
self.get_table(table)?.get(rid)
}
pub fn delete(&mut self, table: &str, rid: RowId) -> io::Result<()> {
let slot = self.slot_of(table)?;
let old_pages = self.tables[slot].overflow_chain_pages_at(rid)?;
if self.wal.is_off() {
self.tables[slot].delete(rid)?;
self.free_overflow_chain(slot, old_pages);
return Ok(());
}
let tx_id = self.next_tx();
self.wal_log(tx_id, WalRecordType::Delete, table, rid, &[])?;
self.tables[slot].delete(rid)?;
self.free_overflow_chain(slot, old_pages);
Ok(())
}
pub fn delete_many(&mut self, table: &str, rids: &[RowId]) -> io::Result<u64> {
let slot = self.slot_of(table)?;
let old_pages = self.collect_overflow_pages(slot, rids)?;
if self.wal.is_off() {
let count = self.tables[slot].delete_many(rids)?;
self.free_overflow_chain(slot, old_pages);
return Ok(count);
}
let tx_id = self.next_tx();
for &rid in rids {
let payload = encode_wal_payload(table, rid, &[]);
self.wal.append(tx_id, WalRecordType::Delete, &payload)?;
}
if self.active_tx_id.is_none() && !rids.is_empty() {
self.pending_autocommit_tx_ids.push(tx_id);
}
let count = self.tables[slot].delete_many(rids)?;
self.free_overflow_chain(slot, old_pages);
Ok(count)
}
fn collect_overflow_pages(&self, slot: usize, rids: &[RowId]) -> io::Result<Vec<u32>> {
if !self.tables[slot].has_overflow_rows() {
return Ok(Vec::new());
}
let mut pages = Vec::new();
for &rid in rids {
pages.extend(self.tables[slot].overflow_chain_pages_at(rid)?);
}
Ok(pages)
}
pub fn scan_delete_matching<P>(&mut self, table: &str, pred: P) -> io::Result<u64>
where
P: FnMut(&[u8]) -> bool,
{
self.by_name_mut(table)?.scan_delete_matching(pred)
}
pub fn scan_delete_matching_logged<P>(&mut self, table: &str, pred: P) -> io::Result<u64>
where
P: FnMut(&[u8]) -> bool,
{
if self.wal.is_off() {
return self.by_name_mut(table)?.scan_delete_matching(pred);
}
let slot = *self.name_to_slot.get(table).ok_or_else(|| {
io::Error::new(
io::ErrorKind::NotFound,
format!("table '{table}' not found"),
)
})?;
let tx_id = self.next_tx();
let autocommit = self.active_tx_id.is_none();
let Catalog { tables, wal, .. } = self;
let tbl = &mut tables[slot];
let name_bytes = table.as_bytes();
let count = tbl.scan_delete_matching_with_hook(pred, |rid, row_bytes| {
let mut payload: Vec<u8> =
Vec::with_capacity(4 + name_bytes.len() + 10 + row_bytes.len());
payload.extend_from_slice(&(name_bytes.len() as u32).to_le_bytes());
payload.extend_from_slice(name_bytes);
payload.extend_from_slice(&rid.page_id.to_le_bytes());
payload.extend_from_slice(&rid.slot_index.to_le_bytes());
payload.extend_from_slice(&0u32.to_le_bytes());
let _ = wal.append(tx_id, WalRecordType::Delete, &payload);
})?;
if autocommit && count > 0 {
self.pending_autocommit_tx_ids.push(tx_id);
}
Ok(count)
}
pub fn scan_patch_matching_logged<P, M>(
&mut self,
table: &str,
pred: P,
try_mutate: M,
) -> io::Result<(u64, Vec<RowId>)>
where
P: FnMut(&[u8]) -> bool,
M: FnMut(&mut [u8]) -> Option<u16>,
{
if self.wal.is_off() {
return self.by_name_mut(table)?.scan_patch_matching_with_hook(
pred,
try_mutate,
|_, _| {},
);
}
let slot = *self.name_to_slot.get(table).ok_or_else(|| {
io::Error::new(
io::ErrorKind::NotFound,
format!("table '{table}' not found"),
)
})?;
let tx_id = self.next_tx();
let autocommit = self.active_tx_id.is_none();
let Catalog { tables, wal, .. } = self;
let tbl = &mut tables[slot];
let name_bytes = table.as_bytes();
let result = tbl.scan_patch_matching_with_hook(pred, try_mutate, |rid, row_bytes| {
let mut payload: Vec<u8> =
Vec::with_capacity(4 + name_bytes.len() + 10 + row_bytes.len());
payload.extend_from_slice(&(name_bytes.len() as u32).to_le_bytes());
payload.extend_from_slice(name_bytes);
payload.extend_from_slice(&rid.page_id.to_le_bytes());
payload.extend_from_slice(&rid.slot_index.to_le_bytes());
payload.extend_from_slice(&(row_bytes.len() as u32).to_le_bytes());
payload.extend_from_slice(row_bytes);
let _ = wal.append(tx_id, WalRecordType::Update, &payload);
})?;
if autocommit && result.0 > 0 {
self.pending_autocommit_tx_ids.push(tx_id);
}
Ok(result)
}
pub fn update(&mut self, table: &str, rid: RowId, values: &Row) -> io::Result<RowId> {
if self.wal.is_off() {
let slot = self.slot_of(table)?;
let old_pages = self.tables[slot].overflow_chain_pages_at(rid)?;
let new_rid = self.tables[slot].update(rid, values)?;
self.free_overflow_chain(slot, old_pages);
return Ok(new_rid);
}
let tx_id = self.next_tx();
let slot = self.slot_of(table)?;
let old_pages = self.tables[slot].overflow_chain_pages_at(rid)?;
let row_bytes = {
let Catalog { tables, wal, .. } = self;
encode_row_with_spill_logged(&mut tables[slot], wal, tx_id, values)?
};
check_encoded_row_size(&row_bytes)?;
self.wal_log(tx_id, WalRecordType::Update, table, rid, &row_bytes)?;
let new_rid = self.tables[slot].update_encoded(rid, values, &row_bytes, None)?;
self.free_overflow_chain(slot, old_pages);
Ok(new_rid)
}
pub fn update_hinted(
&mut self,
table: &str,
rid: RowId,
values: &Row,
changed_col_indices: Option<&[usize]>,
) -> io::Result<RowId> {
if self.wal.is_off() {
let slot = self.slot_of(table)?;
let old_pages = self.tables[slot].overflow_chain_pages_at(rid)?;
let new_rid = self.tables[slot].update_hinted(rid, values, changed_col_indices)?;
self.free_overflow_chain(slot, old_pages);
return Ok(new_rid);
}
let tx_id = self.next_tx();
let slot = self.slot_of(table)?;
let old_pages = self.tables[slot].overflow_chain_pages_at(rid)?;
let row_bytes = {
let Catalog { tables, wal, .. } = self;
encode_row_with_spill_logged(&mut tables[slot], wal, tx_id, values)?
};
check_encoded_row_size(&row_bytes)?;
self.wal_log(tx_id, WalRecordType::Update, table, rid, &row_bytes)?;
let new_rid =
self.tables[slot].update_encoded(rid, values, &row_bytes, changed_col_indices)?;
self.free_overflow_chain(slot, old_pages);
Ok(new_rid)
}
#[inline]
pub fn with_row_bytes_mut<F>(&mut self, table: &str, rid: RowId, f: F) -> io::Result<bool>
where
F: FnOnce(&mut [u8]),
{
self.by_name_mut(table)?.with_row_bytes_mut(rid, f)
}
#[inline]
pub fn update_row_bytes_logged<F>(&mut self, table: &str, rid: RowId, f: F) -> io::Result<bool>
where
F: FnOnce(&mut [u8]),
{
let slot = *self.name_to_slot.get(table).ok_or_else(|| {
io::Error::new(
io::ErrorKind::NotFound,
format!("table '{table}' not found"),
)
})?;
self.update_row_bytes_logged_by_slot(slot, rid, f)
}
#[inline]
pub fn update_row_bytes_logged_by_slot<F>(
&mut self,
slot: usize,
rid: RowId,
f: F,
) -> io::Result<bool>
where
F: FnOnce(&mut [u8]),
{
let tbl = &mut self.tables[slot];
let ok = tbl.with_row_bytes_mut(rid, f)?;
if !ok {
return Ok(false);
}
if self.wal.is_off() {
return Ok(true);
}
let new_bytes = match tbl.heap.get(rid) {
Some(b) => b,
None => return Ok(false),
};
let table_name = tbl.schema.table_name.clone();
let tx_id = self.next_tx();
self.wal_log(tx_id, WalRecordType::Update, &table_name, rid, &new_bytes)?;
Ok(true)
}
#[inline]
pub fn patch_var_col_in_place(
&mut self,
table: &str,
rid: RowId,
col_idx: usize,
new_value: Option<&[u8]>,
) -> io::Result<bool> {
self.by_name_mut(table)?
.patch_var_col_in_place(rid, col_idx, new_value)
}
pub fn patch_var_col_logged(
&mut self,
table: &str,
rid: RowId,
col_idx: usize,
new_value: Option<&[u8]>,
) -> io::Result<bool> {
let slot = *self.name_to_slot.get(table).ok_or_else(|| {
io::Error::new(
io::ErrorKind::NotFound,
format!("table '{table}' not found"),
)
})?;
let tbl = &mut self.tables[slot];
let ok = tbl.patch_var_col_in_place(rid, col_idx, new_value)?;
if !ok {
return Ok(false);
}
if self.wal.is_off() {
return Ok(true);
}
let new_bytes = match tbl.heap.get(rid) {
Some(b) => b,
None => return Ok(false),
};
let table_name = tbl.schema.table_name.clone();
let tx_id = self.next_tx();
self.wal_log(tx_id, WalRecordType::Update, &table_name, rid, &new_bytes)?;
Ok(true)
}
pub fn scan(&self, table: &str) -> io::Result<impl Iterator<Item = (RowId, Row)> + '_> {
Ok(self.by_name(table)?.scan())
}
pub fn for_each_row_raw<F>(&self, table: &str, f: F) -> io::Result<()>
where
F: FnMut(RowId, &[u8]),
{
self.by_name(table)?.for_each_row_raw(f);
Ok(())
}
pub fn try_for_each_row_raw<F>(&self, table: &str, f: F) -> io::Result<()>
where
F: FnMut(RowId, &[u8]) -> std::ops::ControlFlow<()>,
{
self.by_name(table)?.try_for_each_row_raw(f);
Ok(())
}
pub fn create_index(&mut self, table: &str, column: &str) -> io::Result<()> {
self.create_index_unique(table, column, false)
}
pub fn create_index_unique(
&mut self,
table: &str,
column: &str,
unique: bool,
) -> io::Result<()> {
let data_dir = self.data_dir.clone();
self.by_name_mut(table)?
.create_index_with_unique(column, &data_dir, unique)?;
self.persist()
}
pub fn is_index_unique(&self, table: &str, column: &str) -> Option<bool> {
self.get_table(table)?.is_index_unique(column)
}
pub fn has_index(&self, table: &str, column: &str) -> bool {
self.get_table(table)
.map(|t| t.has_index(column))
.unwrap_or(false)
}
pub fn index_lookup(&self, table: &str, column: &str, key: &Value) -> io::Result<Option<Row>> {
Ok(self
.by_name(table)?
.index_lookup(column, key)
.map(|(_, row)| row))
}
pub fn list_tables(&self) -> Vec<&str> {
self.tables
.iter()
.map(|t| t.schema.table_name.as_str())
.collect()
}
pub fn schema(&self, table: &str) -> Option<&Schema> {
let slot = *self.name_to_slot.get(table)?;
Some(&self.tables[slot].schema)
}
pub fn drop_table(&mut self, name: &str) -> io::Result<()> {
validate_table_name(name)?;
let slot = *self.name_to_slot.get(name).ok_or_else(|| {
io::Error::new(io::ErrorKind::NotFound, format!("table '{name}' not found"))
})?;
if !self.wal.is_off() {
let payload = encode_ddl_drop_table(name);
self.wal.append(0, WalRecordType::DdlDropTable, &payload)?;
self.wal.flush()?;
}
let table = &self.tables[slot];
let heap_path = self
.data_dir
.join(format!("{}.heap", table.schema.table_name));
if heap_path.exists() {
fs::remove_file(&heap_path)?;
}
for col_name in table.indexed_column_names() {
let idx_path = self.data_dir.join(format!("{name}_{col_name}.idx"));
if idx_path.exists() {
let _ = fs::remove_file(&idx_path);
}
}
self.name_to_slot.remove(name);
let last = self.tables.len() - 1;
if slot != last {
let moved_name = self.tables[last].schema.table_name.clone();
self.tables.swap(slot, last);
self.name_to_slot.insert(moved_name, slot);
}
self.tables.pop();
self.persist()?;
Ok(())
}
pub fn alter_table_add_column(&mut self, table: &str, col: ColumnDef) -> io::Result<()> {
let data_dir = self.data_dir.clone();
{
let tbl = self.by_name_mut(table)?;
if tbl.schema.columns.iter().any(|c| c.name == col.name) {
return Err(io::Error::new(
io::ErrorKind::AlreadyExists,
format!("column '{}' already exists in table '{table}'", col.name),
));
}
}
let barrier_lsn = if !self.wal.is_off() {
let payload = encode_ddl_alter_add_column(table, &col);
self.wal.append(0, WalRecordType::DdlAddColumn, &payload)?;
self.wal.flush()?;
self.wal.last_appended_lsn()
} else {
0
};
let tbl = self.by_name_mut(table)?;
let old_schema = tbl.schema.clone();
let has_rows = tbl.heap.scan().next().is_some();
if has_rows && col.required {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!(
"cannot add required column '{}' to non-empty table '{table}': \
no default value to backfill existing rows with",
col.name
),
));
}
tbl.schema.columns.push(col);
tbl.refresh_layout();
if has_rows {
let fill: Vec<Value> = vec![Value::Empty; tbl.schema.columns.len()];
tbl.rewrite_rows_for_schema_change(&old_schema, &fill, &data_dir)?;
}
if barrier_lsn > 0 {
tbl.heap.stamp_all_pages_min_lsn(barrier_lsn)?;
tbl.heap.flush()?;
}
self.persist()?;
Ok(())
}
pub fn alter_table_drop_column(&mut self, table: &str, col_name: &str) -> io::Result<()> {
let data_dir = self.data_dir.clone();
{
let tbl = self.by_name_mut(table)?;
tbl.schema
.columns
.iter()
.position(|c| c.name == col_name)
.ok_or_else(|| {
io::Error::new(
io::ErrorKind::NotFound,
format!("column '{col_name}' not found in table '{table}'"),
)
})?;
}
let barrier_lsn = if !self.wal.is_off() {
let payload = encode_ddl_alter_drop_column(table, col_name);
self.wal.append(0, WalRecordType::DdlDropColumn, &payload)?;
self.wal.flush()?;
self.wal.last_appended_lsn()
} else {
0
};
let tbl = self.by_name_mut(table)?;
let idx = tbl
.schema
.columns
.iter()
.position(|c| c.name == col_name)
.ok_or_else(|| {
io::Error::new(
io::ErrorKind::NotFound,
format!("column '{col_name}' not found in table '{table}'"),
)
})?;
let old_schema = tbl.schema.clone();
let has_rows = tbl.heap.scan().next().is_some();
tbl.schema.columns.remove(idx);
for (i, col) in tbl.schema.columns.iter_mut().enumerate() {
col.position = i as u16;
}
tbl.refresh_layout();
if has_rows {
let fill: Vec<Value> = vec![Value::Empty; tbl.schema.columns.len()];
tbl.rewrite_rows_for_schema_change(&old_schema, &fill, &data_dir)?;
}
if barrier_lsn > 0 {
tbl.heap.stamp_all_pages_min_lsn(barrier_lsn)?;
tbl.heap.flush()?;
}
self.persist()?;
Ok(())
}
}
impl Drop for Catalog {
fn drop(&mut self) {
if self.active_tx_id.is_some() {
if let Err(e) = self.abandon_active_transaction_for_drop() {
warn!(error = %e, "catalog drop active transaction cleanup failed");
}
return;
}
if let Err(e) = self.checkpoint() {
warn!(error = %e, "catalog drop checkpoint failed");
}
}
}
fn encode_wal_payload(table: &str, rid: RowId, row_bytes: &[u8]) -> Vec<u8> {
let name = table.as_bytes();
let mut out = Vec::with_capacity(4 + name.len() + 4 + 2 + 4 + row_bytes.len());
out.extend_from_slice(&(name.len() as u32).to_le_bytes());
out.extend_from_slice(name);
out.extend_from_slice(&rid.page_id.to_le_bytes());
out.extend_from_slice(&rid.slot_index.to_le_bytes());
out.extend_from_slice(&(row_bytes.len() as u32).to_le_bytes());
out.extend_from_slice(row_bytes);
out
}
fn decode_wal_payload(data: &[u8]) -> Option<(String, RowId, Vec<u8>)> {
let mut pos = 0usize;
if data.len() < 4 {
return None;
}
let name_len = u32::from_le_bytes(data[pos..pos + 4].try_into().ok()?) as usize;
pos += 4;
if pos + name_len > data.len() {
return None;
}
let name = std::str::from_utf8(&data[pos..pos + name_len])
.ok()?
.to_string();
pos += name_len;
if pos + 4 + 2 + 4 > data.len() {
return None;
}
let page_id = u32::from_le_bytes(data[pos..pos + 4].try_into().ok()?);
pos += 4;
let slot_index = u16::from_le_bytes(data[pos..pos + 2].try_into().ok()?);
pos += 2;
let row_len = u32::from_le_bytes(data[pos..pos + 4].try_into().ok()?) as usize;
pos += 4;
if pos + row_len > data.len() {
return None;
}
let row_bytes = data[pos..pos + row_len].to_vec();
Some((
name,
RowId {
page_id,
slot_index,
},
row_bytes,
))
}
fn write_overflow_chain_logged(
heap: &mut HeapFile,
wal: &mut Wal,
table: &str,
tx_id: u64,
value: &[u8],
) -> io::Result<OverflowStub> {
if value.len() > MAX_VALUE_SIZE {
return Err(StorageError::ValueTooLarge {
size: value.len(),
max: MAX_VALUE_SIZE,
}
.into());
}
let n = value.len().div_ceil(OVERFLOW_PAYLOAD_CAP).max(1);
let mut pages = Vec::with_capacity(n);
for _ in 0..n {
pages.push(heap.allocate_overflow_page()?);
}
for i in 0..n {
let start = i * OVERFLOW_PAYLOAD_CAP;
let end = (start + OVERFLOW_PAYLOAD_CAP).min(value.len());
let chunk = &value[start..end];
let next = if i + 1 < n {
pages[i + 1]
} else {
OVERFLOW_CHAIN_END
};
let payload = encode_overflow_write_payload(table, pages[i], next, chunk);
wal.append(tx_id, WalRecordType::OverflowWrite, &payload)?;
let lsn = wal.last_appended_lsn();
heap.write_overflow_page(pages[i], next, chunk, lsn)?;
}
Ok(OverflowStub::new(
value.len() as u64,
pages[0],
crc32fast::hash(value),
))
}
fn encode_row_with_spill_logged(
tbl: &mut Table,
wal: &mut Wal,
tx_id: u64,
values: &Row,
) -> io::Result<Vec<u8>> {
let v1_len = crate::row::v1_encoded_len(tbl.row_layout(), values);
let is_indexed = tbl.indexed_col_mask();
let chosen = plan_spill(tbl.row_layout(), values, v1_len, &is_indexed);
if chosen.is_empty() {
let mut v1 = Vec::new();
encode_row_into(&tbl.schema, values, &mut v1);
return Ok(v1);
}
let table_name = tbl.schema.table_name.clone();
let n_var = tbl.row_layout().n_var();
let mut spilled: Vec<Option<OverflowStub>> = vec![None; n_var];
for col_idx in chosen {
let var_idx = tbl
.row_layout()
.var_index(col_idx)
.expect("plan_spill only returns var columns");
let bytes: Vec<u8> = match &values[col_idx] {
Value::Str(s) => s.as_bytes().to_vec(),
Value::Bytes(b) => b.to_vec(),
Value::Json(b) => b.to_vec(),
_ => continue,
};
let stub = write_overflow_chain_logged(&mut tbl.heap, wal, &table_name, tx_id, &bytes)?;
spilled[var_idx] = Some(stub);
}
let mut out = Vec::new();
encode_row_v2_into(&tbl.schema, tbl.row_layout(), values, &spilled, &mut out);
Ok(out)
}
fn encode_overflow_write_payload(
table: &str,
page_id: u32,
next_page: u32,
chunk: &[u8],
) -> Vec<u8> {
let name = table.as_bytes();
let mut out = Vec::with_capacity(2 + name.len() + 4 + 4 + 2 + chunk.len());
out.extend_from_slice(&(name.len() as u16).to_le_bytes());
out.extend_from_slice(name);
out.extend_from_slice(&page_id.to_le_bytes());
out.extend_from_slice(&next_page.to_le_bytes());
out.extend_from_slice(&(chunk.len() as u16).to_le_bytes());
out.extend_from_slice(chunk);
out
}
fn decode_overflow_write_payload(data: &[u8]) -> Option<(String, u32, u32, Vec<u8>)> {
let mut pos = 0usize;
if data.len() < 2 {
return None;
}
let name_len = u16::from_le_bytes(data[pos..pos + 2].try_into().ok()?) as usize;
pos += 2;
if pos + name_len + 4 + 4 + 2 > data.len() {
return None;
}
let name = std::str::from_utf8(&data[pos..pos + name_len])
.ok()?
.to_string();
pos += name_len;
let page_id = u32::from_le_bytes(data[pos..pos + 4].try_into().ok()?);
pos += 4;
let next_page = u32::from_le_bytes(data[pos..pos + 4].try_into().ok()?);
pos += 4;
let chunk_len = u16::from_le_bytes(data[pos..pos + 2].try_into().ok()?) as usize;
pos += 2;
if pos + chunk_len > data.len() {
return None;
}
Some((
name,
page_id,
next_page,
data[pos..pos + chunk_len].to_vec(),
))
}
fn encode_overflow_free_payload(table: &str, pages: &[u32]) -> Vec<u8> {
let name = table.as_bytes();
let mut out = Vec::with_capacity(2 + name.len() + 4 + pages.len() * 4);
out.extend_from_slice(&(name.len() as u16).to_le_bytes());
out.extend_from_slice(name);
out.extend_from_slice(&(pages.len() as u32).to_le_bytes());
for p in pages {
out.extend_from_slice(&p.to_le_bytes());
}
out
}
fn decode_overflow_free_payload(data: &[u8]) -> Option<(String, Vec<u32>)> {
let mut pos = 0usize;
if data.len() < 2 {
return None;
}
let name_len = u16::from_le_bytes(data[pos..pos + 2].try_into().ok()?) as usize;
pos += 2;
if pos + name_len + 4 > data.len() {
return None;
}
let name = std::str::from_utf8(&data[pos..pos + name_len])
.ok()?
.to_string();
pos += name_len;
let count = u32::from_le_bytes(data[pos..pos + 4].try_into().ok()?) as usize;
pos += 4;
if pos + count * 4 > data.len() {
return None;
}
let mut pages = Vec::with_capacity(count);
for _ in 0..count {
pages.push(u32::from_le_bytes(data[pos..pos + 4].try_into().ok()?));
pos += 4;
}
Some((name, pages))
}
fn encode_ddl_create_table(
schema: &Schema,
defaults: &[Option<Value>],
auto_cols: &[bool],
) -> Vec<u8> {
let name = schema.table_name.as_bytes();
let mut out = Vec::new();
out.extend_from_slice(&(name.len() as u32).to_le_bytes());
out.extend_from_slice(name);
out.extend_from_slice(&(schema.columns.len() as u16).to_le_bytes());
for col in &schema.columns {
let cn = col.name.as_bytes();
out.extend_from_slice(&(cn.len() as u32).to_le_bytes());
out.extend_from_slice(cn);
out.push(col.type_id as u8);
out.push(col.required as u8);
out.extend_from_slice(&col.position.to_le_bytes());
}
encode_defaults_section(&mut out, defaults);
encode_auto_section(&mut out, auto_cols);
out
}
fn decode_ddl_create_table(data: &[u8]) -> Option<(Schema, Vec<Option<Value>>, Vec<bool>)> {
let mut pos = 0usize;
if data.len() < 4 {
return None;
}
let name_len = u32::from_le_bytes(data[pos..pos + 4].try_into().ok()?) as usize;
pos += 4;
if pos + name_len > data.len() {
return None;
}
let table_name = std::str::from_utf8(&data[pos..pos + name_len])
.ok()?
.to_string();
pos += name_len;
if pos + 2 > data.len() {
return None;
}
let n_cols = u16::from_le_bytes(data[pos..pos + 2].try_into().ok()?) as usize;
pos += 2;
let mut columns = Vec::with_capacity(n_cols);
for _ in 0..n_cols {
if pos + 4 > data.len() {
return None;
}
let cn_len = u32::from_le_bytes(data[pos..pos + 4].try_into().ok()?) as usize;
pos += 4;
if pos + cn_len + 4 > data.len() {
return None;
}
let col_name = std::str::from_utf8(&data[pos..pos + cn_len])
.ok()?
.to_string();
pos += cn_len;
let type_id = TypeId::from_u8(data[pos])?;
pos += 1;
let required = data[pos] != 0;
pos += 1;
if pos + 2 > data.len() {
return None;
}
let position = u16::from_le_bytes(data[pos..pos + 2].try_into().ok()?);
pos += 2;
columns.push(ColumnDef {
name: col_name,
type_id,
required,
position,
});
}
let defaults = if pos < data.len() {
decode_defaults_section(data, &mut pos, columns.len())?
} else {
Vec::new()
};
let auto_cols = if pos < data.len() {
decode_auto_section(data, &mut pos, columns.len())?
} else {
Vec::new()
};
Some((
Schema {
table_name,
columns,
},
defaults,
auto_cols,
))
}
fn encode_ddl_drop_table(table_name: &str) -> Vec<u8> {
let name = table_name.as_bytes();
let mut out = Vec::with_capacity(4 + name.len());
out.extend_from_slice(&(name.len() as u32).to_le_bytes());
out.extend_from_slice(name);
out
}
fn encode_ddl_alter_add_column(table_name: &str, col: &ColumnDef) -> Vec<u8> {
let name = table_name.as_bytes();
let cn = col.name.as_bytes();
let mut out = Vec::with_capacity(4 + name.len() + 4 + cn.len() + 4);
out.extend_from_slice(&(name.len() as u32).to_le_bytes());
out.extend_from_slice(name);
out.extend_from_slice(&(cn.len() as u32).to_le_bytes());
out.extend_from_slice(cn);
out.push(col.type_id as u8);
out.push(col.required as u8);
out.extend_from_slice(&col.position.to_le_bytes());
out
}
fn encode_ddl_alter_drop_column(table_name: &str, col_name: &str) -> Vec<u8> {
let name = table_name.as_bytes();
let cn = col_name.as_bytes();
let mut out = Vec::with_capacity(4 + name.len() + 4 + cn.len());
out.extend_from_slice(&(name.len() as u32).to_le_bytes());
out.extend_from_slice(name);
out.extend_from_slice(&(cn.len() as u32).to_le_bytes());
out.extend_from_slice(cn);
out
}
fn decode_ddl_table_name(data: &[u8]) -> Option<(String, usize)> {
if data.len() < 4 {
return None;
}
let name_len = u32::from_le_bytes(data[0..4].try_into().ok()?) as usize;
if 4 + name_len > data.len() {
return None;
}
let name = std::str::from_utf8(&data[4..4 + name_len])
.ok()?
.to_string();
Some((name, 4 + name_len))
}
fn decode_ddl_alter_add_column(data: &[u8]) -> Option<(String, ColumnDef)> {
let (table_name, mut pos) = decode_ddl_table_name(data)?;
if pos + 4 > data.len() {
return None;
}
let cn_len = u32::from_le_bytes(data[pos..pos + 4].try_into().ok()?) as usize;
pos += 4;
if pos + cn_len + 4 > data.len() {
return None;
}
let col_name = std::str::from_utf8(&data[pos..pos + cn_len])
.ok()?
.to_string();
pos += cn_len;
let type_id = TypeId::from_u8(data[pos])?;
pos += 1;
let required = data[pos] != 0;
pos += 1;
if pos + 2 > data.len() {
return None;
}
let position = u16::from_le_bytes(data[pos..pos + 2].try_into().ok()?);
Some((
table_name,
ColumnDef {
name: col_name,
type_id,
required,
position,
},
))
}
fn decode_ddl_alter_drop_column(data: &[u8]) -> Option<(String, String)> {
let (table_name, pos) = decode_ddl_table_name(data)?;
if pos + 4 > data.len() {
return None;
}
let cn_len = u32::from_le_bytes(data[pos..pos + 4].try_into().ok()?) as usize;
if pos + 4 + cn_len > data.len() {
return None;
}
let col_name = std::str::from_utf8(&data[pos + 4..pos + 4 + cn_len])
.ok()?
.to_string();
Some((table_name, col_name))
}
pub(crate) struct IndexedColMeta {
pub name: String,
pub unique: bool,
}
pub(crate) struct CatalogEntry {
pub schema: Schema,
pub indexed_cols: Vec<IndexedColMeta>,
pub defaults: Vec<Option<Value>>,
pub auto_cols: Vec<bool>,
}
pub(crate) struct CatalogEntryRef<'a> {
pub schema: &'a Schema,
pub indexed_cols: Vec<IndexedColMeta>,
pub defaults: &'a [Option<Value>],
pub auto_cols: &'a [bool],
}
fn encode_value_blob(out: &mut Vec<u8>, v: &Value) {
out.push(v.type_id() as u8);
match v {
Value::Int(n) => out.extend_from_slice(&n.to_le_bytes()),
Value::Float(f) => out.extend_from_slice(&f.to_bits().to_le_bytes()),
Value::Bool(b) => out.push(*b as u8),
Value::Str(s) => {
out.extend_from_slice(&(s.len() as u32).to_le_bytes());
out.extend_from_slice(s.as_bytes());
}
Value::DateTime(n) => out.extend_from_slice(&n.to_le_bytes()),
Value::Uuid(u) => out.extend_from_slice(u),
Value::Bytes(b) => {
out.extend_from_slice(&(b.len() as u32).to_le_bytes());
out.extend_from_slice(b);
}
Value::Json(b) => {
out.extend_from_slice(&(b.len() as u32).to_le_bytes());
out.extend_from_slice(b);
}
Value::Empty => {}
}
}
fn decode_value_blob(data: &[u8], pos: &mut usize) -> Option<Value> {
let tag = *data.get(*pos)?;
*pos += 1;
let type_id = TypeId::from_u8(tag)?;
let take_fixed = |pos: &mut usize, n: usize| -> Option<Vec<u8>> {
if *pos + n > data.len() {
return None;
}
let slice = data[*pos..*pos + n].to_vec();
*pos += n;
Some(slice)
};
match type_id {
TypeId::Empty => Some(Value::Empty),
TypeId::Int => Some(Value::Int(i64::from_le_bytes(
take_fixed(pos, 8)?.try_into().ok()?,
))),
TypeId::Float => Some(Value::Float(f64::from_bits(u64::from_le_bytes(
take_fixed(pos, 8)?.try_into().ok()?,
)))),
TypeId::Bool => Some(Value::Bool(take_fixed(pos, 1)?[0] != 0)),
TypeId::DateTime => Some(Value::DateTime(i64::from_le_bytes(
take_fixed(pos, 8)?.try_into().ok()?,
))),
TypeId::Uuid => Some(Value::Uuid(take_fixed(pos, 16)?.try_into().ok()?)),
TypeId::Str => {
let len = u32::from_le_bytes(take_fixed(pos, 4)?.try_into().ok()?) as usize;
Some(Value::Str(String::from_utf8(take_fixed(pos, len)?).ok()?))
}
TypeId::Bytes => {
let len = u32::from_le_bytes(take_fixed(pos, 4)?.try_into().ok()?) as usize;
Some(Value::Bytes(take_fixed(pos, len)?))
}
TypeId::Json => {
let len = u32::from_le_bytes(take_fixed(pos, 4)?.try_into().ok()?) as usize;
Some(Value::Json(take_fixed(pos, len)?.into()))
}
}
}
fn encode_defaults_section(out: &mut Vec<u8>, defaults: &[Option<Value>]) {
let present: Vec<(u16, &Value)> = defaults
.iter()
.enumerate()
.filter_map(|(i, d)| d.as_ref().map(|v| (i as u16, v)))
.collect();
out.extend_from_slice(&(present.len() as u16).to_le_bytes());
for (pos, v) in present {
out.extend_from_slice(&pos.to_le_bytes());
encode_value_blob(out, v);
}
}
fn decode_defaults_section(
data: &[u8],
pos: &mut usize,
n_cols: usize,
) -> Option<Vec<Option<Value>>> {
if *pos + 2 > data.len() {
return None;
}
let count = u16::from_le_bytes(data[*pos..*pos + 2].try_into().ok()?) as usize;
*pos += 2;
let mut out = vec![None; n_cols];
for _ in 0..count {
if *pos + 2 > data.len() {
return None;
}
let col = u16::from_le_bytes(data[*pos..*pos + 2].try_into().ok()?) as usize;
*pos += 2;
let value = decode_value_blob(data, pos)?;
if col < n_cols {
out[col] = Some(value);
}
}
Some(out)
}
fn encode_auto_section(out: &mut Vec<u8>, auto_cols: &[bool]) {
let present: Vec<u16> = auto_cols
.iter()
.enumerate()
.filter_map(|(i, &a)| if a { Some(i as u16) } else { None })
.collect();
out.extend_from_slice(&(present.len() as u16).to_le_bytes());
for pos in present {
out.extend_from_slice(&pos.to_le_bytes());
}
}
fn decode_auto_section(data: &[u8], pos: &mut usize, n_cols: usize) -> Option<Vec<bool>> {
if *pos + 2 > data.len() {
return None;
}
let count = u16::from_le_bytes(data[*pos..*pos + 2].try_into().ok()?) as usize;
*pos += 2;
let mut out = vec![false; n_cols];
for _ in 0..count {
if *pos + 2 > data.len() {
return None;
}
let col = u16::from_le_bytes(data[*pos..*pos + 2].try_into().ok()?) as usize;
*pos += 2;
if col < n_cols {
out[col] = true;
}
}
Some(out)
}
fn write_catalog_file(path: &Path, entries: &[CatalogEntryRef<'_>]) -> io::Result<()> {
let mut buf: Vec<u8> = Vec::with_capacity(64);
buf.extend_from_slice(CATALOG_MAGIC);
buf.extend_from_slice(&CATALOG_VERSION.to_le_bytes());
buf.extend_from_slice(&(entries.len() as u32).to_le_bytes());
for entry in entries {
let schema = entry.schema;
let name = schema.table_name.as_bytes();
buf.extend_from_slice(&(name.len() as u32).to_le_bytes());
buf.extend_from_slice(name);
buf.extend_from_slice(&(schema.columns.len() as u16).to_le_bytes());
for col in &schema.columns {
let cn = col.name.as_bytes();
buf.extend_from_slice(&(cn.len() as u32).to_le_bytes());
buf.extend_from_slice(cn);
buf.push(col.type_id as u8);
buf.push(if col.required { 1 } else { 0 });
buf.extend_from_slice(&col.position.to_le_bytes());
}
buf.extend_from_slice(&(entry.indexed_cols.len() as u16).to_le_bytes());
for meta in &entry.indexed_cols {
let cn = meta.name.as_bytes();
buf.extend_from_slice(&(cn.len() as u32).to_le_bytes());
buf.extend_from_slice(cn);
buf.push(if meta.unique { 1 } else { 0 });
}
encode_defaults_section(&mut buf, entry.defaults);
encode_auto_section(&mut buf, entry.auto_cols);
}
let crc = crc32fast::hash(&buf);
buf.extend_from_slice(&crc.to_le_bytes());
let mut f = fs::OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.open(path)?;
f.write_all(&buf)?;
f.sync_data()?;
Ok(())
}
fn read_catalog_file(path: &Path) -> io::Result<Vec<CatalogEntry>> {
let mut f = fs::File::open(path)?;
let mut buf = Vec::new();
f.read_to_end(&mut buf)?;
let mut pos = 0usize;
if buf.len() < 14 || &buf[0..4] != CATALOG_MAGIC {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"bad catalog magic",
));
}
let payload = &buf[..buf.len() - 4];
let stored_crc = u32::from_le_bytes(
buf[buf.len() - 4..]
.try_into()
.map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "truncated catalog CRC"))?,
);
let computed_crc = crc32fast::hash(payload);
if stored_crc != computed_crc {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!(
"catalog CRC32 mismatch: expected {stored_crc:#010x}, got {computed_crc:#010x}"
),
));
}
let buf = &buf[..buf.len() - 4];
pos += 4;
let version = u16::from_le_bytes(
buf[pos..pos + 2]
.try_into()
.map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "truncated catalog header"))?,
);
pos += 2;
if version == 0 || version > CATALOG_VERSION {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!("unsupported catalog version: {version}"),
));
}
let n_tables = u32::from_le_bytes(
buf[pos..pos + 4]
.try_into()
.map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "truncated catalog header"))?,
) as usize;
pos += 4;
if n_tables > buf.len() {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!("catalog file corrupt: implausible table count {n_tables}"),
));
}
let mut entries = Vec::with_capacity(n_tables);
for _ in 0..n_tables {
let name_len = read_u32(buf, &mut pos)? as usize;
let table_name = read_string(buf, &mut pos, name_len)?;
let n_cols = read_u16(buf, &mut pos)? as usize;
let mut columns = Vec::with_capacity(n_cols);
for _ in 0..n_cols {
let cname_len = read_u32(buf, &mut pos)? as usize;
let name = read_string(buf, &mut pos, cname_len)?;
let type_id_raw = read_u8(buf, &mut pos)?;
let type_id = type_id_from_u8(type_id_raw)?;
let required = read_u8(buf, &mut pos)? != 0;
let position = read_u16(buf, &mut pos)?;
columns.push(ColumnDef {
name,
type_id,
required,
position,
});
}
let indexed_cols: Vec<IndexedColMeta> = if version >= 3 {
let n = read_u16(buf, &mut pos)? as usize;
let mut v = Vec::with_capacity(n);
for _ in 0..n {
let l = read_u32(buf, &mut pos)? as usize;
let name = read_string(buf, &mut pos, l)?;
let unique = read_u8(buf, &mut pos)? != 0;
v.push(IndexedColMeta { name, unique });
}
v
} else if version >= 2 {
let n = read_u16(buf, &mut pos)? as usize;
let mut v = Vec::with_capacity(n);
for _ in 0..n {
let l = read_u32(buf, &mut pos)? as usize;
let name = read_string(buf, &mut pos, l)?;
v.push(IndexedColMeta {
name,
unique: false,
});
}
v
} else {
Vec::new()
};
let defaults = if version >= 4 {
decode_defaults_section(buf, &mut pos, columns.len()).ok_or_else(|| {
io::Error::new(io::ErrorKind::InvalidData, "truncated catalog defaults")
})?
} else {
Vec::new()
};
let auto_cols = if version >= 5 {
decode_auto_section(buf, &mut pos, columns.len()).ok_or_else(|| {
io::Error::new(io::ErrorKind::InvalidData, "truncated catalog auto columns")
})?
} else {
Vec::new()
};
entries.push(CatalogEntry {
schema: Schema {
table_name,
columns,
},
indexed_cols,
defaults,
auto_cols,
});
}
Ok(entries)
}
fn read_u8(buf: &[u8], pos: &mut usize) -> io::Result<u8> {
if *pos >= buf.len() {
return Err(io::Error::new(
io::ErrorKind::UnexpectedEof,
"truncated catalog",
));
}
let v = buf[*pos];
*pos += 1;
Ok(v)
}
fn read_u16(buf: &[u8], pos: &mut usize) -> io::Result<u16> {
if *pos + 2 > buf.len() {
return Err(io::Error::new(
io::ErrorKind::UnexpectedEof,
"truncated catalog",
));
}
let v = u16::from_le_bytes(
buf[*pos..*pos + 2]
.try_into()
.expect("bounds checked above"),
);
*pos += 2;
Ok(v)
}
fn read_u32(buf: &[u8], pos: &mut usize) -> io::Result<u32> {
if *pos + 4 > buf.len() {
return Err(io::Error::new(
io::ErrorKind::UnexpectedEof,
"truncated catalog",
));
}
let v = u32::from_le_bytes(
buf[*pos..*pos + 4]
.try_into()
.expect("bounds checked above"),
);
*pos += 4;
Ok(v)
}
fn read_string(buf: &[u8], pos: &mut usize, len: usize) -> io::Result<String> {
if *pos + len > buf.len() {
return Err(io::Error::new(
io::ErrorKind::UnexpectedEof,
"truncated catalog string",
));
}
let s = std::str::from_utf8(&buf[*pos..*pos + len])
.map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "non-utf8 in catalog"))?
.to_string();
*pos += len;
Ok(s)
}
fn type_id_from_u8(v: u8) -> io::Result<TypeId> {
match v {
0 => Ok(TypeId::Empty),
1 => Ok(TypeId::Int),
2 => Ok(TypeId::Float),
3 => Ok(TypeId::Bool),
4 => Ok(TypeId::Str),
5 => Ok(TypeId::DateTime),
6 => Ok(TypeId::Uuid),
7 => Ok(TypeId::Bytes),
8 => Ok(TypeId::Json),
_ => Err(io::Error::new(
io::ErrorKind::InvalidData,
format!("unknown type id: {v}"),
)),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn temp_catalog(name: &str) -> Catalog {
let dir = std::env::temp_dir().join(format!("powdb_cat_{name}_{}", std::process::id()));
Catalog::create(&dir).unwrap()
}
fn schema_two_cols() -> Schema {
Schema {
table_name: "T".into(),
columns: vec![
ColumnDef {
name: "id".into(),
type_id: TypeId::Int,
required: true,
position: 0,
},
ColumnDef {
name: "status".into(),
type_id: TypeId::Str,
required: false,
position: 1,
},
],
}
}
#[test]
fn replay_records_treats_reused_tx_ids_as_ordered_spans() {
let mut cat = temp_catalog("reused_tx_ids");
let schema = schema_two_cols();
cat.create_table(schema.clone()).unwrap();
cat.checkpoint().unwrap();
let mut committed_row = Vec::new();
encode_row_into(
&schema,
&[Value::Int(1), Value::Str("committed".into())],
&mut committed_row,
);
let mut incomplete_row = Vec::new();
encode_row_into(
&schema,
&[Value::Int(2), Value::Str("incomplete".into())],
&mut incomplete_row,
);
let records = vec![
WalRecord {
tx_id: 1,
record_type: WalRecordType::Begin,
lsn: 1,
data: Vec::new(),
},
WalRecord {
tx_id: 1,
record_type: WalRecordType::Insert,
lsn: 2,
data: encode_wal_payload(
"T",
RowId {
page_id: 1,
slot_index: 0,
},
&committed_row,
),
},
WalRecord {
tx_id: 1,
record_type: WalRecordType::Commit,
lsn: 3,
data: Vec::new(),
},
WalRecord {
tx_id: 1,
record_type: WalRecordType::Begin,
lsn: 4,
data: Vec::new(),
},
WalRecord {
tx_id: 1,
record_type: WalRecordType::Insert,
lsn: 5,
data: encode_wal_payload(
"T",
RowId {
page_id: 1,
slot_index: 1,
},
&incomplete_row,
),
},
];
cat.apply_wal_records(&records).unwrap();
let rows: Vec<_> = cat.scan("T").unwrap().collect();
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].1[0], Value::Int(1));
assert_eq!(rows[0].1[1], Value::Str("committed".into()));
}
#[test]
fn ddl_create_table_codec_roundtrips_defaults_and_auto() {
let schema = schema_two_cols();
let defaults = vec![None, Some(Value::Str("active".into()))];
let auto_cols = vec![true, false];
let encoded = encode_ddl_create_table(&schema, &defaults, &auto_cols);
let (decoded_schema, decoded_defaults, decoded_auto) =
decode_ddl_create_table(&encoded).unwrap();
assert_eq!(decoded_schema.columns.len(), 2);
assert_eq!(decoded_defaults, defaults);
assert_eq!(decoded_auto, auto_cols);
}
#[test]
fn ddl_create_table_codec_back_compat_without_trailing_sections() {
let schema = schema_two_cols();
let full = encode_ddl_create_table(&schema, &[], &[]);
let legacy = &full[..full.len() - 4];
let (decoded_schema, decoded_defaults, decoded_auto) =
decode_ddl_create_table(legacy).unwrap();
assert_eq!(decoded_schema.columns.len(), 2);
assert!(decoded_defaults.is_empty(), "no defaults section -> empty");
assert!(decoded_auto.is_empty(), "no auto section -> empty");
}
#[test]
fn ddl_create_table_codec_back_compat_defaults_but_no_auto() {
let schema = schema_two_cols();
let defaults = vec![None, Some(Value::Str("active".into()))];
let full = encode_ddl_create_table(&schema, &defaults, &[]);
let legacy = &full[..full.len() - 2];
let (_schema, decoded_defaults, decoded_auto) = decode_ddl_create_table(legacy).unwrap();
assert_eq!(decoded_defaults, defaults);
assert!(decoded_auto.is_empty());
}
#[test]
fn read_catalog_file_accepts_intermediate_versions_3_and_4() {
use std::io::Write as _;
fn write_legacy_catalog(path: &std::path::Path, version: u16) {
let mut buf: Vec<u8> = Vec::new();
buf.extend_from_slice(CATALOG_MAGIC);
buf.extend_from_slice(&version.to_le_bytes());
buf.extend_from_slice(&1u32.to_le_bytes()); buf.extend_from_slice(&1u32.to_le_bytes());
buf.extend_from_slice(b"T");
buf.extend_from_slice(&2u16.to_le_bytes()); buf.extend_from_slice(&2u32.to_le_bytes());
buf.extend_from_slice(b"id");
buf.push(TypeId::Int as u8);
buf.push(1);
buf.extend_from_slice(&0u16.to_le_bytes());
buf.extend_from_slice(&6u32.to_le_bytes());
buf.extend_from_slice(b"status");
buf.push(TypeId::Str as u8);
buf.push(0);
buf.extend_from_slice(&1u16.to_le_bytes());
buf.extend_from_slice(&0u16.to_le_bytes());
if version >= 4 {
encode_defaults_section(&mut buf, &[None, None]);
}
let crc = crc32fast::hash(&buf);
buf.extend_from_slice(&crc.to_le_bytes());
let mut f = fs::File::create(path).unwrap();
f.write_all(&buf).unwrap();
}
for version in [3u16, 4u16] {
let path = std::env::temp_dir().join(format!(
"powdb_cat_v{version}_compat_{}.bin",
std::process::id()
));
write_legacy_catalog(&path, version);
let entries = read_catalog_file(&path)
.unwrap_or_else(|e| panic!("version {version} catalog must load, got: {e}"));
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].schema.table_name, "T");
assert_eq!(entries[0].schema.columns.len(), 2);
assert!(
entries[0].auto_cols.is_empty(),
"v{version} has no auto cols"
);
fs::remove_file(&path).ok();
}
}
#[test]
fn read_catalog_file_rejects_implausible_table_count() {
use std::io::Write as _;
let mut buf: Vec<u8> = Vec::new();
buf.extend_from_slice(CATALOG_MAGIC);
buf.extend_from_slice(&CATALOG_VERSION.to_le_bytes());
buf.extend_from_slice(&1000u32.to_le_bytes()); let crc = crc32fast::hash(&buf);
buf.extend_from_slice(&crc.to_le_bytes());
let path =
std::env::temp_dir().join(format!("powdb_cat_badcount_{}.bin", std::process::id()));
fs::File::create(&path).unwrap().write_all(&buf).unwrap();
let msg = match read_catalog_file(&path) {
Ok(_) => panic!("implausible table count must be rejected, got Ok"),
Err(e) => e.to_string(),
};
assert!(
msg.contains("implausible table count"),
"expected an implausible-table-count error, got: {msg}"
);
fs::remove_file(&path).ok();
}
#[test]
fn data_dir_and_max_lsn_accessors() {
let dir = std::env::temp_dir().join(format!("powdb_cat_maxlsn_{}", std::process::id()));
let mut cat = Catalog::create(&dir).unwrap();
assert_eq!(cat.data_dir(), dir.as_path());
assert_eq!(cat.max_lsn(), 0);
let schema = Schema {
table_name: "users".into(),
columns: vec![ColumnDef {
name: "name".into(),
type_id: TypeId::Str,
required: true,
position: 0,
}],
};
cat.create_table(schema).unwrap();
cat.insert("users", &vec![Value::Str("Alice".into())])
.unwrap();
cat.sync_wal().unwrap();
assert!(cat.max_lsn() > 0);
}
#[test]
fn test_create_table_and_insert() {
let mut cat = temp_catalog("basic");
let schema = Schema {
table_name: "users".into(),
columns: vec![
ColumnDef {
name: "name".into(),
type_id: TypeId::Str,
required: true,
position: 0,
},
ColumnDef {
name: "age".into(),
type_id: TypeId::Int,
required: false,
position: 1,
},
],
};
cat.create_table(schema).unwrap();
let row = vec![Value::Str("Alice".into()), Value::Int(30)];
let rid = cat.insert("users", &row).unwrap();
let result = cat.get("users", rid).unwrap();
assert_eq!(result[0], Value::Str("Alice".into()));
assert_eq!(result[1], Value::Int(30));
}
#[test]
fn test_scan_table() {
let mut cat = temp_catalog("scan");
let schema = Schema {
table_name: "items".into(),
columns: vec![
ColumnDef {
name: "name".into(),
type_id: TypeId::Str,
required: true,
position: 0,
},
ColumnDef {
name: "price".into(),
type_id: TypeId::Float,
required: true,
position: 1,
},
],
};
cat.create_table(schema).unwrap();
for i in 0..50 {
cat.insert(
"items",
&vec![
Value::Str(format!("item_{i}")),
Value::Float(i as f64 * 1.5),
],
)
.unwrap();
}
let rows: Vec<_> = cat.scan("items").unwrap().collect();
assert_eq!(rows.len(), 50);
}
#[test]
fn test_index_lookup() {
let mut cat = temp_catalog("idx");
let schema = Schema {
table_name: "users".into(),
columns: vec![
ColumnDef {
name: "email".into(),
type_id: TypeId::Str,
required: true,
position: 0,
},
ColumnDef {
name: "name".into(),
type_id: TypeId::Str,
required: true,
position: 1,
},
],
};
cat.create_table(schema).unwrap();
cat.create_index("users", "email").unwrap();
cat.insert(
"users",
&vec![
Value::Str("alice@example.com".into()),
Value::Str("Alice".into()),
],
)
.unwrap();
cat.insert(
"users",
&vec![
Value::Str("bob@example.com".into()),
Value::Str("Bob".into()),
],
)
.unwrap();
let result = cat
.index_lookup("users", "email", &Value::Str("bob@example.com".into()))
.unwrap();
assert!(result.is_some());
let row = result.unwrap();
assert_eq!(row[1], Value::Str("Bob".into()));
}
#[test]
fn test_delete_row() {
let mut cat = temp_catalog("delete");
let schema = Schema {
table_name: "t".into(),
columns: vec![ColumnDef {
name: "v".into(),
type_id: TypeId::Int,
required: true,
position: 0,
}],
};
cat.create_table(schema).unwrap();
let r1 = cat.insert("t", &vec![Value::Int(1)]).unwrap();
let r2 = cat.insert("t", &vec![Value::Int(2)]).unwrap();
cat.delete("t", r1).unwrap();
assert!(cat.get("t", r1).is_none());
assert!(cat.get("t", r2).is_some());
}
#[test]
fn test_update_row() {
let mut cat = temp_catalog("update");
let schema = Schema {
table_name: "t".into(),
columns: vec![ColumnDef {
name: "v".into(),
type_id: TypeId::Int,
required: true,
position: 0,
}],
};
cat.create_table(schema).unwrap();
let rid = cat.insert("t", &vec![Value::Int(1)]).unwrap();
let new_rid = cat.update("t", rid, &vec![Value::Int(99)]).unwrap();
let row = cat.get("t", new_rid).unwrap();
assert_eq!(row[0], Value::Int(99));
}
#[test]
fn test_persist_and_reopen() {
let dir = std::env::temp_dir().join(format!("powdb_cat_persist_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
{
let mut cat = Catalog::create(&dir).unwrap();
cat.create_table(Schema {
table_name: "users".into(),
columns: vec![
ColumnDef {
name: "name".into(),
type_id: TypeId::Str,
required: true,
position: 0,
},
ColumnDef {
name: "age".into(),
type_id: TypeId::Int,
required: false,
position: 1,
},
],
})
.unwrap();
cat.insert("users", &vec![Value::Str("Alice".into()), Value::Int(30)])
.unwrap();
cat.insert("users", &vec![Value::Str("Bob".into()), Value::Int(25)])
.unwrap();
}
let cat = Catalog::open(&dir).unwrap();
let schema = cat.schema("users").unwrap();
assert_eq!(schema.columns.len(), 2);
assert_eq!(schema.columns[0].name, "name");
assert_eq!(schema.columns[0].type_id, TypeId::Str);
assert_eq!(schema.columns[1].type_id, TypeId::Int);
let rows: Vec<_> = cat.scan("users").unwrap().collect();
assert_eq!(rows.len(), 2);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn test_open_missing_dir_errors() {
let dir = std::env::temp_dir().join(format!("powdb_cat_missing_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
assert!(Catalog::open(&dir).is_err());
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn test_list_tables() {
let mut cat = temp_catalog("list");
cat.create_table(Schema {
table_name: "a".into(),
columns: vec![ColumnDef {
name: "x".into(),
type_id: TypeId::Int,
required: true,
position: 0,
}],
})
.unwrap();
cat.create_table(Schema {
table_name: "b".into(),
columns: vec![ColumnDef {
name: "y".into(),
type_id: TypeId::Int,
required: true,
position: 0,
}],
})
.unwrap();
let mut tables = cat.list_tables();
tables.sort();
assert_eq!(tables, vec!["a", "b"]);
}
#[test]
fn test_path_traversal_table_name_rejected() {
let mut cat = temp_catalog("path_trav");
let bad_names = vec![
"../etc/passwd",
"foo/bar",
"table\0name",
"",
"123starts_with_digit",
"has-dashes",
"has spaces",
"has.dots",
];
for name in bad_names {
let schema = Schema {
table_name: name.into(),
columns: vec![ColumnDef {
name: "x".into(),
type_id: TypeId::Int,
required: true,
position: 0,
}],
};
let result = cat.create_table(schema);
assert!(result.is_err(), "expected error for table name '{name}'");
assert_eq!(result.unwrap_err().kind(), io::ErrorKind::InvalidInput);
}
let good_names = vec!["users", "_private", "Table_123", "_"];
for name in good_names {
let schema = Schema {
table_name: name.into(),
columns: vec![ColumnDef {
name: "x".into(),
type_id: TypeId::Int,
required: true,
position: 0,
}],
};
assert!(
cat.create_table(schema).is_ok(),
"expected ok for table name '{name}'"
);
}
}
#[test]
fn test_path_traversal_column_name_rejected() {
let mut cat = temp_catalog("col_path_trav");
let schema = Schema {
table_name: "valid_table".into(),
columns: vec![ColumnDef {
name: "../bad".into(),
type_id: TypeId::Int,
required: true,
position: 0,
}],
};
let result = cat.create_table(schema);
assert!(result.is_err());
assert_eq!(result.unwrap_err().kind(), io::ErrorKind::InvalidInput);
}
#[test]
fn test_drop_table_validates_name() {
let mut cat = temp_catalog("drop_trav");
let result = cat.drop_table("../etc/passwd");
assert!(result.is_err());
assert_eq!(result.unwrap_err().kind(), io::ErrorKind::InvalidInput);
}
}