use crate::btree::BTree;
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::stored_json_path::{StoredJsonPathSegmentV1, StoredJsonPathV1};
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 std::sync::atomic::{AtomicU64, Ordering};
use tracing::{info, warn};
static NEXT_STRUCTURE_GENERATION: AtomicU64 = AtomicU64::new(1);
fn next_structure_generation() -> u64 {
NEXT_STRUCTURE_GENERATION.fetch_add(1, Ordering::Relaxed)
}
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 LEGACY_CATALOG_VERSION: u16 = 5;
pub const CATALOG_VERSION: u16 = 6;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ExpressionIndexMeta {
pub index_id: u64,
pub unique: bool,
pub canonical_version: u16,
pub canonical_text: String,
pub json_path: StoredJsonPathV1,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum IndexKeySource {
Column {
column: String,
},
Expression {
index_id: u64,
canonical_version: u16,
canonical_text: String,
json_path: StoredJsonPathV1,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct IndexMetadata {
pub unique: bool,
pub source: IndexKeySource,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum IndexOrderDirection {
Asc,
Desc,
}
pub fn expression_index_file_name(table: &str, index_id: u64) -> String {
format!("{table}_{index_id}.eidx")
}
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(())
}
#[cfg(test)]
thread_local! {
static CATALOG_PERSIST_FAILPOINT: std::cell::Cell<u8> = const { std::cell::Cell::new(0) };
}
#[cfg(test)]
fn take_catalog_persist_failpoint(stage: u8) -> bool {
CATALOG_PERSIST_FAILPOINT.with(|failpoint| {
if failpoint.get() == stage {
failpoint.set(0);
true
} else {
false
}
})
}
enum CatalogPersistError {
BeforeActivation(io::Error),
AfterActivation(io::Error),
}
impl CatalogPersistError {
fn into_io_error(self) -> io::Error {
match self {
Self::BeforeActivation(error) | Self::AfterActivation(error) => error,
}
}
}
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>)>,
active_catalog_version: u16,
next_index_id: u64,
structure_generation: u64,
}
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,
active_catalog_version: LEGACY_CATALOG_VERSION,
next_index_id: 1,
structure_generation: next_structure_generation(),
};
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 catalog_file = read_catalog_file(&cat_path)?;
let active_catalog_version = catalog_file.version;
let next_index_id = catalog_file.next_index_id;
let entries = catalog_file.entries;
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,
expression_indexes: expression_metas,
defaults,
auto_cols,
} in entries
{
let name = schema.table_name.clone();
let mut table =
Table::open_with_indexes(schema, data_dir, &indexed_cols, &expression_metas)?;
table.set_defaults(defaults);
table.set_auto_cols(auto_cols);
name_to_slot.insert(name.clone(), 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,
active_catalog_version,
next_index_id,
structure_generation: next_structure_generation(),
};
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);
}
}
for index_id in self.tables[slot].expression_index_ids() {
let idx_path = self
.data_dir
.join(expression_index_file_name(&table_name, index_id));
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);
}
}
let removed_ids =
self.tables[slot].remove_expression_indexes_for_root(&col_name);
for index_id in removed_ids {
let idx_path = self
.data_dir
.join(expression_index_file_name(&table_name, index_id));
let _ = fs::remove_file(idx_path);
}
}
}
}
}
}
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 mut 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)?
};
if self.has_same_prepared_structure(&restored) {
restored.structure_generation = self.structure_generation;
}
*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<()> {
self.invalidate_structure();
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_at_activation_boundary(&self) -> Result<(), CatalogPersistError> {
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(),
expression_indexes: t.expression_index_metas(),
defaults: t.defaults(),
auto_cols: t.auto_cols(),
})
.collect();
write_catalog_file(
&tmp_path,
self.active_catalog_version,
self.next_index_id,
&entries,
)
.map_err(CatalogPersistError::BeforeActivation)?;
#[cfg(test)]
if take_catalog_persist_failpoint(1) {
return Err(CatalogPersistError::BeforeActivation(io::Error::other(
"injected catalog failure before rename",
)));
}
fs::rename(&tmp_path, &cat_path).map_err(CatalogPersistError::BeforeActivation)?;
#[cfg(test)]
let directory_sync = if take_catalog_persist_failpoint(2) {
Err(io::Error::other(
"injected catalog directory sync failure after rename",
))
} else {
sync_directory(&self.data_dir)
};
#[cfg(not(test))]
let directory_sync = sync_directory(&self.data_dir);
directory_sync.map_err(CatalogPersistError::AfterActivation)
}
fn persist(&self) -> io::Result<()> {
self.persist_at_activation_boundary()
.map_err(CatalogPersistError::into_io_error)
}
#[inline]
pub fn table_slot(&self, name: &str) -> Option<usize> {
self.name_to_slot.get(name).copied()
}
#[inline]
pub fn structure_generation(&self) -> u64 {
self.structure_generation
}
#[inline]
fn invalidate_structure(&mut self) {
self.structure_generation = next_structure_generation();
}
fn has_same_prepared_structure(&self, other: &Self) -> bool {
self.tables.len() == other.tables.len()
&& self.tables.iter().zip(&other.tables).all(|(left, right)| {
let left_schema = &left.schema;
let right_schema = &right.schema;
left_schema.table_name == right_schema.table_name
&& left_schema.columns.len() == right_schema.columns.len()
&& left_schema.columns.iter().zip(&right_schema.columns).all(
|(left_col, right_col)| {
left_col.name == right_col.name
&& left_col.type_id == right_col.type_id
&& left_col.required == right_col.required
&& left_col.position == right_col.position
},
)
&& left.defaults() == right.defaults()
&& left.auto_cols() == right.auto_cols()
&& {
let left_indexes = left.indexed_column_metas();
let right_indexes = right.indexed_column_metas();
left_indexes.len() == right_indexes.len()
&& left_indexes.iter().zip(&right_indexes).all(
|(left_index, right_index)| {
left_index.name == right_index.name
&& left_index.unique == right_index.unique
},
)
}
&& left.expression_index_metas() == right.expression_index_metas()
})
}
#[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 slot = self.slot_of(table)?;
let _ = self.tables[slot].preflight_insert(values)?;
let tx_id = self.next_tx();
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 _ = self.tables[slot].preflight_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 get_projected(
&self,
table: &str,
rid: RowId,
column_indices: &[usize],
) -> io::Result<Option<Vec<Value>>> {
self.by_name(table)?.get_projected(rid, column_indices)
}
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 slot = self.slot_of(table)?;
self.tables[slot].preflight_update(rid, values)?;
let tx_id = self.next_tx();
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 slot = self.slot_of(table)?;
self.tables[slot].preflight_update(rid, values)?;
let tx_id = self.next_tx();
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<()> {
self.invalidate_structure();
let data_dir = self.data_dir.clone();
self.by_name_mut(table)?
.create_index_with_unique(column, &data_dir, unique)?;
self.persist()
}
pub fn active_catalog_version(&self) -> u16 {
self.active_catalog_version
}
pub fn next_index_id(&self) -> u64 {
self.next_index_id
}
pub fn index_metadata(&self, table: &str) -> Option<Vec<IndexMetadata>> {
let table_ref = self.get_table(table)?;
let mut metadata = table_ref
.indexed_column_metas()
.into_iter()
.map(|index| IndexMetadata {
unique: index.unique,
source: IndexKeySource::Column { column: index.name },
})
.collect::<Vec<_>>();
metadata.extend(table_ref.expression_index_metas().into_iter().map(|index| {
IndexMetadata {
unique: index.unique,
source: IndexKeySource::Expression {
index_id: index.index_id,
canonical_version: index.canonical_version,
canonical_text: index.canonical_text,
json_path: index.json_path,
},
}
}));
Some(metadata)
}
pub fn expression_index_metadata(&self, table: &str) -> Option<Vec<ExpressionIndexMeta>> {
Some(self.get_table(table)?.expression_index_metas())
}
pub fn expression_index_btree(&self, table: &str, index_id: u64) -> Option<&BTree> {
self.get_table(table)?.expression_index_btree(index_id)
}
pub fn expression_index_btree_mut(&mut self, table: &str, index_id: u64) -> Option<&mut BTree> {
self.get_table_mut(table)?
.expression_index_btree_mut(index_id)
}
pub fn expression_index_lookup_all(
&self,
table: &str,
index_id: u64,
key: &Value,
) -> io::Result<Vec<RowId>> {
let tree = self
.by_name(table)?
.expression_index_btree(index_id)
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "expression index not found"))?;
Ok(tree.lookup_all(key))
}
pub fn expression_index_range_rids(
&self,
table: &str,
index_id: u64,
start: Option<&Value>,
end: Option<&Value>,
) -> io::Result<Vec<RowId>> {
let tree = self
.by_name(table)?
.expression_index_btree(index_id)
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "expression index not found"))?;
Ok(tree.raw_range_rids(start, end))
}
pub fn expression_index_ordered_rids(
&self,
table: &str,
index_id: u64,
) -> io::Result<Vec<RowId>> {
let tree = self
.by_name(table)?
.expression_index_btree(index_id)
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "expression index not found"))?;
Ok(tree.ordered_rids_nulls_last())
}
pub fn expression_index_ordered_rids_bounded(
&self,
table: &str,
index_id: u64,
direction: IndexOrderDirection,
offset: usize,
limit: usize,
) -> io::Result<Vec<RowId>> {
let tree = self
.by_name(table)?
.expression_index_btree(index_id)
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "expression index not found"))?;
Ok(tree.bounded_ordered_rids_nulls_last(
direction == IndexOrderDirection::Desc,
offset,
limit,
))
}
pub fn drop_expression_index(&mut self, table: &str, index_id: u64) -> io::Result<()> {
self.invalidate_structure();
validate_table_name(table)?;
let removed = self
.by_name_mut(table)?
.take_expression_index(index_id)
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "expression index not found"))?;
match self.persist_at_activation_boundary() {
Ok(()) => {}
Err(CatalogPersistError::BeforeActivation(error)) => {
self.by_name_mut(table)?.restore_expression_index(removed);
return Err(error);
}
Err(CatalogPersistError::AfterActivation(error)) => {
warn!(
path = %self.data_dir.display(),
error = %error,
"expression index drop committed but catalog directory sync failed"
);
}
}
let index_path = self
.data_dir
.join(expression_index_file_name(table, index_id));
if let Err(error) = fs::remove_file(&index_path) {
if error.kind() != io::ErrorKind::NotFound {
warn!(path = %index_path.display(), error = %error, "failed to remove dropped expression index file");
}
} else if let Err(error) = sync_directory(&self.data_dir) {
warn!(path = %self.data_dir.display(), error = %error, "failed to sync expression index deletion");
}
Ok(())
}
pub fn create_expression_index_metadata(
&mut self,
table: &str,
canonical_version: u16,
canonical_text: impl Into<String>,
json_path: StoredJsonPathV1,
unique: bool,
) -> io::Result<u64> {
self.invalidate_structure();
validate_table_name(table)?;
validate_column_name(&json_path.column)?;
if canonical_version == 0 {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"expression canonical version must be non-zero",
));
}
let canonical_text = canonical_text.into();
if canonical_text.is_empty() {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"expression canonical text must not be empty",
));
}
if canonical_version == 1 && canonical_text != json_path.canonical_text() {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"expression canonical text does not match its stored JSON path",
));
}
let table_ref = self.by_name(table)?;
let root_index = table_ref
.schema
.column_index(&json_path.column)
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "JSON root column not found"))?;
if table_ref.schema.columns[root_index].type_id != TypeId::Json {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"expression index root column must have type json",
));
}
if table_ref.expression_index_metas().iter().any(|index| {
index.canonical_version == canonical_version && index.canonical_text == canonical_text
}) {
return Err(io::Error::new(
io::ErrorKind::AlreadyExists,
"expression index already exists",
));
}
let index_id = self.next_index_id;
let next_index_id = index_id
.checked_add(1)
.ok_or_else(|| io::Error::other("expression index id space exhausted"))?;
let index_path = self
.data_dir
.join(expression_index_file_name(table, index_id));
if index_path.exists() {
fs::remove_file(&index_path)?;
sync_directory(&self.data_dir)?;
}
let meta = ExpressionIndexMeta {
index_id,
unique,
canonical_version,
canonical_text,
json_path,
};
self.by_name_mut(table)?
.install_expression_index(meta, &index_path)?;
if let Err(error) = sync_directory(&self.data_dir) {
self.by_name_mut(table)?
.remove_expression_index_by_id(index_id);
let _ = fs::remove_file(&index_path);
return Err(error);
}
let previous_version = self.active_catalog_version;
let previous_next_id = self.next_index_id;
self.active_catalog_version = CATALOG_VERSION;
self.next_index_id = next_index_id;
match self.persist_at_activation_boundary() {
Ok(()) => {}
Err(CatalogPersistError::BeforeActivation(error)) => {
self.by_name_mut(table)?
.remove_expression_index_by_id(index_id);
self.active_catalog_version = previous_version;
self.next_index_id = previous_next_id;
let _ = fs::remove_file(&index_path);
let _ = sync_directory(&self.data_dir);
return Err(error);
}
Err(CatalogPersistError::AfterActivation(error)) => {
warn!(
path = %self.data_dir.display(),
error = %error,
"expression index creation committed but catalog directory sync failed"
);
}
}
Ok(index_id)
}
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<()> {
self.invalidate_structure();
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);
}
}
let expression_index_ids = table.expression_index_ids();
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()?;
for index_id in expression_index_ids {
let idx_path = self
.data_dir
.join(expression_index_file_name(name, index_id));
let _ = fs::remove_file(idx_path);
}
Ok(())
}
pub fn alter_table_add_column(&mut self, table: &str, col: ColumnDef) -> io::Result<()> {
self.invalidate_structure();
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<()> {
self.invalidate_structure();
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 removed_expression_index_ids = self
.by_name_mut(table)?
.remove_expression_indexes_for_root(col_name);
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()?;
for index_id in removed_expression_index_ids {
let idx_path = self
.data_dir
.join(expression_index_file_name(table, index_id));
let _ = fs::remove_file(idx_path);
}
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 expression_indexes: Vec<ExpressionIndexMeta>,
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 expression_indexes: Vec<ExpressionIndexMeta>,
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 push_catalog_string(out: &mut Vec<u8>, value: &str) -> io::Result<()> {
let len = u32::try_from(value.len())
.map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "catalog string is too large"))?;
out.extend_from_slice(&len.to_le_bytes());
out.extend_from_slice(value.as_bytes());
Ok(())
}
fn encode_expression_indexes(out: &mut Vec<u8>, indexes: &[ExpressionIndexMeta]) -> io::Result<()> {
let count = u16::try_from(indexes.len()).map_err(|_| {
io::Error::new(
io::ErrorKind::InvalidInput,
"too many expression indexes on one table",
)
})?;
out.extend_from_slice(&count.to_le_bytes());
for index in indexes {
out.extend_from_slice(&index.index_id.to_le_bytes());
out.push(u8::from(index.unique));
out.extend_from_slice(&index.canonical_version.to_le_bytes());
push_catalog_string(out, &index.canonical_text)?;
push_catalog_string(out, &index.json_path.column)?;
let segment_count = u16::try_from(index.json_path.segments.len()).map_err(|_| {
io::Error::new(
io::ErrorKind::InvalidInput,
"JSON path has too many segments",
)
})?;
out.extend_from_slice(&segment_count.to_le_bytes());
for segment in &index.json_path.segments {
match segment {
StoredJsonPathSegmentV1::Key(key) => {
out.push(1);
push_catalog_string(out, key)?;
}
StoredJsonPathSegmentV1::Index(position) => {
out.push(2);
out.extend_from_slice(&position.to_le_bytes());
}
}
}
}
Ok(())
}
fn decode_expression_indexes(data: &[u8], pos: &mut usize) -> io::Result<Vec<ExpressionIndexMeta>> {
let count = read_u16(data, pos)? as usize;
let mut indexes = Vec::with_capacity(count);
for _ in 0..count {
let index_id = read_u64(data, pos)?;
if index_id == 0 {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"expression index id must be non-zero",
));
}
let unique = read_u8(data, pos)? != 0;
let canonical_version = read_u16(data, pos)?;
let canonical_len = read_u32(data, pos)? as usize;
let canonical_text = read_string(data, pos, canonical_len)?;
let column_len = read_u32(data, pos)? as usize;
let column = read_string(data, pos, column_len)?;
let segment_count = read_u16(data, pos)? as usize;
let mut segments = Vec::with_capacity(segment_count);
for _ in 0..segment_count {
match read_u8(data, pos)? {
1 => {
let len = read_u32(data, pos)? as usize;
segments.push(StoredJsonPathSegmentV1::Key(read_string(data, pos, len)?));
}
2 => segments.push(StoredJsonPathSegmentV1::Index(read_u32(data, pos)?)),
tag => {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!("unknown stored JSON path segment tag: {tag}"),
));
}
}
}
indexes.push(ExpressionIndexMeta {
index_id,
unique,
canonical_version,
canonical_text,
json_path: StoredJsonPathV1 { column, segments },
});
}
Ok(indexes)
}
fn write_catalog_file(
path: &Path,
version: u16,
next_index_id: u64,
entries: &[CatalogEntryRef<'_>],
) -> io::Result<()> {
if !(1..=CATALOG_VERSION).contains(&version) {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("unsupported catalog write version: {version}"),
));
}
let mut buf: Vec<u8> = Vec::with_capacity(64);
buf.extend_from_slice(CATALOG_MAGIC);
buf.extend_from_slice(&version.to_le_bytes());
buf.extend_from_slice(&(entries.len() as u32).to_le_bytes());
if version >= 6 {
buf.extend_from_slice(&next_index_id.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);
if version >= 6 {
encode_expression_indexes(&mut buf, &entry.expression_indexes)?;
}
}
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(())
}
struct CatalogFile {
version: u16,
next_index_id: u64,
entries: Vec<CatalogEntry>,
}
fn read_catalog_file(path: &Path) -> io::Result<CatalogFile> {
read_catalog_file_with_max_version(path, CATALOG_VERSION)
}
pub fn read_active_catalog_version(data_dir: &Path) -> io::Result<u16> {
let cat_path = data_dir.join(CATALOG_FILE);
Ok(read_catalog_file(&cat_path)?.version)
}
fn read_catalog_file_with_max_version(
path: &Path,
max_supported_version: u16,
) -> io::Result<CatalogFile> {
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 > max_supported_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;
let next_index_id = if version >= 6 {
let id = read_u64(buf, &mut pos)?;
if id == 0 {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"catalog next index id must be non-zero",
));
}
id
} else {
1
};
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()
};
let expression_indexes = if version >= 6 {
decode_expression_indexes(buf, &mut pos)?
} else {
Vec::new()
};
entries.push(CatalogEntry {
schema: Schema {
table_name,
columns,
},
indexed_cols,
expression_indexes,
defaults,
auto_cols,
});
}
let mut seen_index_ids = FxHashMap::default();
let mut max_index_id = 0;
for entry in &entries {
for index in &entry.expression_indexes {
if index.canonical_version == 0 || index.canonical_text.is_empty() {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"expression index has invalid canonical identity",
));
}
if index.canonical_version == 1
&& index.canonical_text != index.json_path.canonical_text()
{
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"expression index canonical identity does not match its JSON path",
));
}
let Some(root) = entry
.schema
.columns
.iter()
.find(|column| column.name == index.json_path.column)
else {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"expression index JSON root is absent from its table",
));
};
if root.type_id != TypeId::Json {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"expression index root column is not JSON",
));
}
if seen_index_ids.insert(index.index_id, ()).is_some() {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"duplicate expression index id in catalog",
));
}
max_index_id = max_index_id.max(index.index_id);
}
}
if next_index_id <= max_index_id {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"catalog next index id does not exceed persisted index ids",
));
}
Ok(CatalogFile {
version,
next_index_id,
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_u64(buf: &[u8], pos: &mut usize) -> io::Result<u64> {
if *pos + 8 > buf.len() {
return Err(io::Error::new(
io::ErrorKind::UnexpectedEof,
"truncated catalog",
));
}
let value = u64::from_le_bytes(
buf[*pos..*pos + 8]
.try_into()
.expect("bounds checked above"),
);
*pos += 8;
Ok(value)
}
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 fail_next_catalog_persist_at(stage: u8) {
CATALOG_PERSIST_FAILPOINT.with(|failpoint| failpoint.set(stage));
}
fn temp_catalog(name: &str) -> Catalog {
let dir = std::env::temp_dir().join(format!("powdb_cat_{name}_{}", std::process::id()));
Catalog::create(&dir).unwrap()
}
#[test]
fn v5_reader_rejects_v6_catalog() {
let dir = tempfile::tempdir().unwrap();
let mut catalog = Catalog::create(dir.path()).unwrap();
catalog
.create_table(Schema {
table_name: "Doc".into(),
columns: vec![ColumnDef {
name: "data".into(),
type_id: TypeId::Json,
required: false,
position: 0,
}],
})
.unwrap();
let path =
StoredJsonPathV1::new("data", vec![StoredJsonPathSegmentV1::Key("author".into())]);
catalog
.create_expression_index_metadata("Doc", 1, path.canonical_text(), path, false)
.unwrap();
let result = read_catalog_file_with_max_version(
&dir.path().join(CATALOG_FILE),
LEGACY_CATALOG_VERSION,
);
let error = match result {
Ok(_) => panic!("a v5 reader must reject v6 before decoding its payload"),
Err(error) => error,
};
assert!(error.to_string().contains("unsupported catalog version: 6"));
}
#[test]
fn expression_index_rolls_back_only_before_catalog_rename() {
let before_dir = tempfile::tempdir().unwrap();
let mut before = Catalog::create(before_dir.path()).unwrap();
before
.create_table(Schema {
table_name: "Doc".into(),
columns: vec![ColumnDef {
name: "data".into(),
type_id: TypeId::Json,
required: false,
position: 0,
}],
})
.unwrap();
let path =
StoredJsonPathV1::new("data", vec![StoredJsonPathSegmentV1::Key("score".into())]);
fail_next_catalog_persist_at(1);
let error = before
.create_expression_index_metadata("Doc", 1, path.canonical_text(), path.clone(), false)
.unwrap_err();
assert!(error.to_string().contains("before rename"));
assert_eq!(before.active_catalog_version(), LEGACY_CATALOG_VERSION);
assert_eq!(before.next_index_id(), 1);
assert!(before.expression_index_metadata("Doc").unwrap().is_empty());
assert!(!before_dir
.path()
.join(expression_index_file_name("Doc", 1))
.exists());
let before_index_id = before
.create_expression_index_metadata("Doc", 1, path.canonical_text(), path.clone(), false)
.unwrap();
fail_next_catalog_persist_at(1);
let error = before
.drop_expression_index("Doc", before_index_id)
.unwrap_err();
assert!(error.to_string().contains("before rename"));
assert!(before
.expression_index_btree("Doc", before_index_id)
.is_some());
assert!(before_dir
.path()
.join(expression_index_file_name("Doc", 1))
.exists());
std::mem::forget(before);
let before_reopened = Catalog::open(before_dir.path()).unwrap();
assert!(before_reopened
.expression_index_btree("Doc", before_index_id)
.is_some());
let after_dir = tempfile::tempdir().unwrap();
let mut after = Catalog::create(after_dir.path()).unwrap();
after
.create_table(Schema {
table_name: "Doc".into(),
columns: vec![ColumnDef {
name: "data".into(),
type_id: TypeId::Json,
required: false,
position: 0,
}],
})
.unwrap();
fail_next_catalog_persist_at(2);
let index_id = after
.create_expression_index_metadata("Doc", 1, path.canonical_text(), path.clone(), false)
.unwrap();
assert_eq!(index_id, 1);
assert_eq!(after.active_catalog_version(), CATALOG_VERSION);
assert_eq!(after.next_index_id(), 2);
assert!(after.expression_index_btree("Doc", index_id).is_some());
assert!(after_dir
.path()
.join(expression_index_file_name("Doc", 1))
.exists());
std::mem::forget(after);
let mut reopened = Catalog::open(after_dir.path()).unwrap();
assert!(reopened.expression_index_btree("Doc", index_id).is_some());
fail_next_catalog_persist_at(2);
reopened.drop_expression_index("Doc", index_id).unwrap();
assert!(reopened
.expression_index_metadata("Doc")
.unwrap()
.is_empty());
assert!(!after_dir
.path()
.join(expression_index_file_name("Doc", 1))
.exists());
std::mem::forget(reopened);
let final_open = Catalog::open(after_dir.path()).unwrap();
assert!(final_open
.expression_index_metadata("Doc")
.unwrap()
.is_empty());
}
#[test]
fn ordinary_catalog_persist_reports_post_rename_directory_sync_failure() {
let dir = tempfile::tempdir().unwrap();
let mut catalog = Catalog::create(dir.path()).unwrap();
fail_next_catalog_persist_at(2);
let error = catalog
.create_table(Schema {
table_name: "VisibleAfterRename".into(),
columns: vec![ColumnDef {
name: "id".into(),
type_id: TypeId::Int,
required: true,
position: 0,
}],
})
.unwrap_err();
assert!(error.to_string().contains("after rename"));
assert!(catalog.schema("VisibleAfterRename").is_some());
std::mem::forget(catalog);
let reopened = Catalog::open(dir.path()).unwrap();
assert!(reopened.schema("VisibleAfterRename").is_some());
}
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 catalog_file = read_catalog_file(&path)
.unwrap_or_else(|e| panic!("version {version} catalog must load, got: {e}"));
let entries = catalog_file.entries;
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()); buf.extend_from_slice(&1u64.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);
}
}