use crate::catalog::{self, Catalog, CatalogEntry, TableState, META_DEK_LEN};
use crate::engine::{SharedCtx, Table};
use crate::epoch::{Epoch, EpochAuthority, Snapshot};
use crate::error::{MongrelError, Result};
use crate::external_table::ExternalTableEntry;
use crate::memtable::Value;
use crate::procedure::{
ProcedureCallOutput, ProcedureCallResult, ProcedureCallRow, ProcedureCondition, ProcedureEntry,
ProcedureStep, ProcedureValue, StoredProcedure,
};
use crate::retention::{OwnedSnapshotGuard, SnapshotGuard, SnapshotRegistry};
use crate::rowid::RowId;
use crate::schema::{AlterColumn, ColumnDef, Schema};
use crate::trigger::{
StoredTrigger, TriggerCondition, TriggerConfig, TriggerEntry, TriggerEvent, TriggerExpr,
TriggerRaiseAction, TriggerStep, TriggerTarget, TriggerTiming, TriggerValue,
};
use parking_lot::{Mutex, RwLock};
use std::collections::HashMap;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, AtomicU32};
use std::sync::Arc;
pub const TABLES_DIR: &str = "tables";
pub const VTAB_DIR: &str = "_vtab";
pub const META_DIR: &str = "_meta";
pub const KEYS_FILENAME: &str = "keys";
pub const WAL_TABLE_ID: u64 = u64::MAX;
pub const EXTERNAL_TABLE_ID: u64 = u64::MAX - 1;
#[derive(Debug, Clone)]
pub enum ExternalTriggerWrite {
Insert {
table: String,
cells: Vec<(u16, Value)>,
},
UpdateByPk {
table: String,
pk: Value,
cells: Vec<(u16, Value)>,
},
DeleteByPk {
table: String,
pk: Value,
},
}
impl ExternalTriggerWrite {
fn table(&self) -> &str {
match self {
Self::Insert { table, .. }
| Self::UpdateByPk { table, .. }
| Self::DeleteByPk { table, .. } => table,
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum ExternalTriggerBaseWrite {
Put {
table: String,
cells: Vec<(u16, Value)>,
},
Delete {
table: String,
row_id: RowId,
},
}
#[derive(Debug, Clone, PartialEq)]
pub struct ExternalTriggerWriteResult {
pub state: Vec<u8>,
pub base_writes: Vec<ExternalTriggerBaseWrite>,
}
impl ExternalTriggerWriteResult {
pub fn new(state: Vec<u8>) -> Self {
Self {
state,
base_writes: Vec::new(),
}
}
}
pub trait ExternalTriggerBridge {
fn apply_trigger_external_write(
&self,
entry: &ExternalTableEntry,
base_state: Vec<u8>,
op: ExternalTriggerWrite,
) -> Result<ExternalTriggerWriteResult>;
}
struct SpilledRun {
table_id: u64,
run_id: u128,
pending_path: PathBuf,
rows: Vec<crate::memtable::Row>,
row_count: u64,
min_rid: u64,
max_rid: u64,
}
#[derive(Debug, Clone)]
struct TriggerRowImage {
columns: HashMap<u16, Value>,
}
impl TriggerRowImage {
fn from_row(row: crate::memtable::Row) -> Self {
Self {
columns: row.columns,
}
}
fn from_cells(cells: &[(u16, Value)]) -> Self {
Self {
columns: cells.iter().cloned().collect(),
}
}
}
#[derive(Debug, Clone)]
struct WriteEvent {
table: String,
kind: TriggerEvent,
old: Option<TriggerRowImage>,
new: Option<TriggerRowImage>,
changed_columns: Vec<u16>,
op_indices: Vec<usize>,
put_idx: Option<usize>,
trigger_stack: Vec<String>,
}
#[derive(Default)]
struct TriggerExpansion {
before: Vec<(u64, crate::txn::Staged)>,
before_stacks: Vec<Vec<String>>,
before_external: Vec<ExternalTriggerWrite>,
after: Vec<(u64, crate::txn::Staged)>,
after_stacks: Vec<Vec<String>>,
after_external: Vec<ExternalTriggerWrite>,
ignored_indices: std::collections::BTreeSet<usize>,
}
struct TriggerProgramOutput<'a> {
added: &'a mut Vec<(u64, crate::txn::Staged)>,
added_stacks: &'a mut Vec<Vec<String>>,
added_external: &'a mut Vec<ExternalTriggerWrite>,
ignored_indices: &'a mut std::collections::BTreeSet<usize>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum TriggerProgramOutcome {
Continue,
Ignore,
}
#[derive(Debug, Clone)]
pub struct CheckIssue {
pub table_id: u64,
pub table_name: String,
pub severity: String,
pub description: String,
}
pub type TableHandle = Arc<Mutex<Table>>;
pub struct Database {
root: PathBuf,
catalog: RwLock<Catalog>,
epoch: Arc<EpochAuthority>,
snapshots: Arc<SnapshotRegistry>,
page_cache: Arc<crate::cache::Sharded<crate::cache::PageCache>>,
decoded_cache: Arc<crate::cache::Sharded<crate::cache::DecodedPageCache>>,
commit_lock: Arc<Mutex<()>>,
shared_wal: Arc<Mutex<crate::wal::SharedWal>>,
next_txn_id: Arc<Mutex<u64>>,
tables: RwLock<HashMap<u64, TableHandle>>,
kek: Option<Arc<crate::encryption::Kek>>,
ddl_lock: Mutex<()>,
meta_dek: Option<[u8; META_DEK_LEN]>,
spill_threshold: std::sync::atomic::AtomicU64,
conflicts: crate::txn::ConflictIndex,
active_txns: crate::txn::ActiveTxns,
poisoned: Arc<std::sync::atomic::AtomicBool>,
group: Arc<crate::txn::GroupCommit>,
active_spills: Arc<crate::retention::ActiveSpills>,
#[doc(hidden)]
spill_hook: Mutex<Option<Box<dyn Fn() + Send + Sync>>>,
trigger_recursive: AtomicBool,
trigger_max_depth: AtomicU32,
trigger_max_loop_iterations: AtomicU32,
_lock: Option<std::fs::File>,
notify: tokio::sync::broadcast::Sender<ChangeEvent>,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct ChangeEvent {
pub channel: String,
pub table: String,
pub op: String,
pub epoch: u64,
pub message: Option<String>,
}
impl Database {
pub fn create(root: impl AsRef<Path>) -> Result<Self> {
Self::create_inner(root, None)
}
#[cfg(feature = "encryption")]
pub fn create_encrypted(root: impl AsRef<Path>, passphrase: &str) -> Result<Self> {
let root = root.as_ref();
std::fs::create_dir_all(root)?;
std::fs::create_dir_all(root.join(META_DIR))?;
let salt = crate::encryption::random_salt();
std::fs::write(root.join(META_DIR).join(KEYS_FILENAME), salt)?;
let kek = Arc::new(crate::encryption::Kek::derive(passphrase, &salt)?);
Self::create_inner(root, Some(kek))
}
#[cfg(feature = "encryption")]
pub fn create_with_key(root: impl AsRef<Path>, key: &[u8]) -> Result<Self> {
let root = root.as_ref();
std::fs::create_dir_all(root)?;
std::fs::create_dir_all(root.join(META_DIR))?;
let salt = crate::encryption::random_salt();
std::fs::write(root.join(META_DIR).join(KEYS_FILENAME), salt)?;
let kek = Arc::new(crate::encryption::Kek::from_raw_key(key, &salt)?);
Self::create_inner(root, Some(kek))
}
fn create_inner(
root: impl AsRef<Path>,
kek: Option<Arc<crate::encryption::Kek>>,
) -> Result<Self> {
let root = root.as_ref().to_path_buf();
std::fs::create_dir_all(&root)?;
std::fs::create_dir_all(root.join(TABLES_DIR))?;
let meta_dek = crate::encryption::meta_dek_for(kek.as_deref());
let cat = Catalog::empty();
catalog::write_atomic(&root, &cat, meta_dek.as_ref())?;
Self::finish_open(root, cat, kek, meta_dek, false)
}
pub fn open(root: impl AsRef<Path>) -> Result<Self> {
Self::open_inner(root, None, None)
}
#[cfg(feature = "encryption")]
pub fn open_encrypted(root: impl AsRef<Path>, passphrase: &str) -> Result<Self> {
let root = root.as_ref();
let salt_bytes = std::fs::read(root.join(META_DIR).join(KEYS_FILENAME))
.map_err(|e| MongrelError::NotFound(format!("encryption salt file: {e}")))?;
let mut salt = [0u8; crate::encryption::SALT_LEN];
salt.copy_from_slice(&salt_bytes);
let kek = Arc::new(crate::encryption::Kek::derive(passphrase, &salt)?);
Self::open_inner(root, Some(kek), None)
}
#[cfg(feature = "encryption")]
pub fn open_with_key(root: impl AsRef<Path>, key: &[u8]) -> Result<Self> {
let root = root.as_ref();
let salt_path = root.join(META_DIR).join(KEYS_FILENAME);
let salt_bytes = std::fs::read(&salt_path).map_err(|e| {
MongrelError::NotFound(format!(
"encryption salt file {:?}: {e} (database not encrypted, or corrupted)",
salt_path
))
})?;
if salt_bytes.len() != crate::encryption::SALT_LEN {
return Err(MongrelError::InvalidArgument(format!(
"salt file is {} bytes, expected {}",
salt_bytes.len(),
crate::encryption::SALT_LEN
)));
}
let mut salt = [0u8; crate::encryption::SALT_LEN];
salt.copy_from_slice(&salt_bytes);
let kek = Arc::new(crate::encryption::Kek::from_raw_key(key, &salt)?);
Self::open_inner(root, Some(kek), None)
}
fn open_inner(
root: impl AsRef<Path>,
kek: Option<Arc<crate::encryption::Kek>>,
_meta_dek_override: Option<[u8; META_DEK_LEN]>,
) -> Result<Self> {
let root = root.as_ref().to_path_buf();
let meta_dek = crate::encryption::meta_dek_for(kek.as_deref());
let cat = catalog::read(&root, meta_dek.as_ref())?
.ok_or_else(|| MongrelError::NotFound(format!("no catalog found at {:?}", root)))?;
Self::finish_open(root, cat, kek, meta_dek, true)
}
fn finish_open(
root: PathBuf,
cat: Catalog,
kek: Option<Arc<crate::encryption::Kek>>,
meta_dek: Option<[u8; META_DEK_LEN]>,
existing: bool,
) -> Result<Self> {
std::fs::create_dir_all(root.join("_meta")).ok();
let lock_path = root.join("_meta").join(".lock");
let canonical = lock_path.canonicalize().unwrap_or(lock_path.clone());
let lock_file = {
static LOCKED_PATHS: std::sync::OnceLock<
std::sync::Mutex<std::collections::HashSet<PathBuf>>,
> = std::sync::OnceLock::new();
let locked = LOCKED_PATHS
.get_or_init(|| std::sync::Mutex::new(std::collections::HashSet::new()));
let mut guard = locked.lock().unwrap();
if guard.contains(&canonical) {
None
} else {
let f = std::fs::OpenOptions::new()
.create(true)
.truncate(false)
.write(true)
.open(&lock_path)?;
use fs2::FileExt;
f.try_lock_exclusive().map_err(|e| {
MongrelError::Io(std::io::Error::other(format!(
"database at {} is locked by another process: {e}",
root.display()
)))
})?;
guard.insert(canonical.clone());
Some(f)
}
};
let epoch = Arc::new(EpochAuthority::new(cat.db_epoch));
let snapshots = Arc::new(SnapshotRegistry::new());
let page_cache = Arc::new(crate::cache::Sharded::new(
crate::cache::CACHE_SHARDS,
|| {
crate::cache::PageCache::new(
crate::engine::PAGE_CACHE_CAPACITY / crate::cache::CACHE_SHARDS as u64,
)
},
));
let decoded_cache = Arc::new(crate::cache::Sharded::new(
crate::cache::CACHE_SHARDS,
|| {
crate::cache::DecodedPageCache::new(
crate::engine::DECODED_CACHE_CAPACITY / crate::cache::CACHE_SHARDS as u64,
)
},
));
let commit_lock = Arc::new(Mutex::new(()));
let wal_dek = crate::encryption::wal_dek_for(kek.as_deref());
let shared_wal = Arc::new(Mutex::new(if existing {
crate::wal::SharedWal::open(&root, Epoch(cat.db_epoch), wal_dek.clone())?
} else {
crate::wal::SharedWal::create_with_dek(&root, Epoch(cat.db_epoch), wal_dek.clone())?
}));
let poisoned = Arc::new(std::sync::atomic::AtomicBool::new(false));
let group = Arc::new(crate::txn::GroupCommit::new(
shared_wal.lock().durable_seq(),
));
let txn_ids = Arc::new(Mutex::new(1u64));
let mut cat = cat;
if existing {
recover_ddl_from_wal(&root, &mut cat, meta_dek.as_ref(), wal_dek.as_ref())?;
}
let mut tables: HashMap<u64, TableHandle> = HashMap::new();
for entry in &cat.tables {
if !matches!(entry.state, TableState::Live) {
continue;
}
let tdir = root.join(TABLES_DIR).join(entry.table_id.to_string());
let ctx = SharedCtx {
epoch: Arc::clone(&epoch),
page_cache: Arc::clone(&page_cache),
decoded_cache: Arc::clone(&decoded_cache),
snapshots: Arc::clone(&snapshots),
kek: kek.clone(),
commit_lock: Arc::clone(&commit_lock),
shared: Some(crate::engine::SharedWalCtx {
wal: Arc::clone(&shared_wal),
group: Arc::clone(&group),
poisoned: Arc::clone(&poisoned),
txn_ids: Arc::clone(&txn_ids),
}),
};
let t = Table::open_in(&tdir, ctx)?;
tables.insert(entry.table_id, Arc::new(Mutex::new(t)));
}
if existing {
recover_shared_wal(&root, &tables, &epoch, wal_dek.as_ref())?;
sweep_pending_txn_dirs(&root, &cat);
}
if existing {
cat.open_generation = cat.open_generation.wrapping_add(1);
catalog::write_atomic(&root, &cat, meta_dek.as_ref())?;
}
let next_txn_id = (cat.open_generation << 32) | 1;
*txn_ids.lock() = next_txn_id;
Ok(Self {
root,
catalog: RwLock::new(cat),
epoch,
snapshots,
page_cache,
decoded_cache,
commit_lock,
shared_wal,
next_txn_id: txn_ids,
tables: RwLock::new(tables),
kek,
ddl_lock: Mutex::new(()),
meta_dek,
conflicts: crate::txn::ConflictIndex::new(),
active_txns: crate::txn::ActiveTxns::new(),
poisoned,
group,
spill_threshold: std::sync::atomic::AtomicU64::new(64 * 1024 * 1024),
active_spills: Arc::new(crate::retention::ActiveSpills::new()),
spill_hook: Mutex::new(None),
trigger_recursive: AtomicBool::new(TriggerConfig::default().recursive_triggers),
trigger_max_depth: AtomicU32::new(TriggerConfig::default().max_depth),
trigger_max_loop_iterations: AtomicU32::new(
TriggerConfig::default().max_loop_iterations,
),
_lock: lock_file,
notify: {
let (tx, _rx) = tokio::sync::broadcast::channel(256);
tx
},
})
}
pub fn visible_epoch(&self) -> Epoch {
self.epoch.visible()
}
pub fn catalog_snapshot(&self) -> Catalog {
self.catalog.read().clone()
}
pub fn root(&self) -> &Path {
&self.root
}
pub fn table_id(&self, name: &str) -> Result<u64> {
let cat = self.catalog.read();
cat.live(name)
.map(|e| e.table_id)
.ok_or_else(|| MongrelError::NotFound(format!("table {name:?} not found")))
}
pub fn procedures(&self) -> Vec<StoredProcedure> {
self.catalog
.read()
.procedures
.iter()
.map(|p| p.procedure.clone())
.collect()
}
pub fn procedure(&self, name: &str) -> Option<StoredProcedure> {
self.catalog
.read()
.procedures
.iter()
.find(|p| p.procedure.name == name)
.map(|p| p.procedure.clone())
}
pub fn create_procedure(&self, mut procedure: StoredProcedure) -> Result<StoredProcedure> {
let _g = self.ddl_lock.lock();
procedure.validate()?;
self.validate_procedure_references(&procedure)?;
{
let cat = self.catalog.read();
if cat
.procedures
.iter()
.any(|p| p.procedure.name == procedure.name)
{
return Err(MongrelError::InvalidArgument(format!(
"procedure {:?} already exists",
procedure.name
)));
}
}
let commit_lock = Arc::clone(&self.commit_lock);
let _c = commit_lock.lock();
let epoch = self.epoch.bump_assigned();
procedure.created_epoch = epoch.0;
procedure.updated_epoch = epoch.0;
{
let mut cat = self.catalog.write();
cat.procedures.push(ProcedureEntry::from(procedure.clone()));
cat.db_epoch = epoch.0;
}
catalog::write_atomic(&self.root, &self.catalog.read(), self.meta_dek.as_ref())?;
self.advance_visible(epoch);
Ok(procedure)
}
pub fn create_or_replace_procedure(
&self,
procedure: StoredProcedure,
) -> Result<StoredProcedure> {
let _g = self.ddl_lock.lock();
procedure.validate()?;
self.validate_procedure_references(&procedure)?;
let commit_lock = Arc::clone(&self.commit_lock);
let _c = commit_lock.lock();
let epoch = self.epoch.bump_assigned();
let replaced = {
let mut cat = self.catalog.write();
let next = match cat
.procedures
.iter()
.position(|p| p.procedure.name == procedure.name)
{
Some(idx) => {
let next = cat.procedures[idx]
.procedure
.replaced(procedure.clone(), epoch.0)?;
cat.procedures[idx] = ProcedureEntry::from(next.clone());
next
}
None => {
let mut next = procedure;
next.created_epoch = epoch.0;
next.updated_epoch = epoch.0;
cat.procedures.push(ProcedureEntry::from(next.clone()));
next
}
};
cat.db_epoch = epoch.0;
next
};
catalog::write_atomic(&self.root, &self.catalog.read(), self.meta_dek.as_ref())?;
self.advance_visible(epoch);
Ok(replaced)
}
pub fn drop_procedure(&self, name: &str) -> Result<()> {
let _g = self.ddl_lock.lock();
let commit_lock = Arc::clone(&self.commit_lock);
let _c = commit_lock.lock();
let epoch = self.epoch.bump_assigned();
{
let mut cat = self.catalog.write();
let before = cat.procedures.len();
cat.procedures.retain(|p| p.procedure.name != name);
if cat.procedures.len() == before {
return Err(MongrelError::NotFound(format!(
"procedure {name:?} not found"
)));
}
cat.db_epoch = epoch.0;
}
catalog::write_atomic(&self.root, &self.catalog.read(), self.meta_dek.as_ref())?;
self.advance_visible(epoch);
Ok(())
}
pub fn triggers(&self) -> Vec<StoredTrigger> {
self.catalog
.read()
.triggers
.iter()
.map(|t| t.trigger.clone())
.collect()
}
pub fn trigger(&self, name: &str) -> Option<StoredTrigger> {
self.catalog
.read()
.triggers
.iter()
.find(|t| t.trigger.name == name)
.map(|t| t.trigger.clone())
}
pub fn create_trigger(&self, mut trigger: StoredTrigger) -> Result<StoredTrigger> {
let _g = self.ddl_lock.lock();
trigger.validate()?;
self.validate_trigger_references(&trigger)?;
{
let cat = self.catalog.read();
if cat.triggers.iter().any(|t| t.trigger.name == trigger.name) {
return Err(MongrelError::InvalidArgument(format!(
"trigger {:?} already exists",
trigger.name
)));
}
}
let commit_lock = Arc::clone(&self.commit_lock);
let _c = commit_lock.lock();
let epoch = self.epoch.bump_assigned();
trigger.created_epoch = epoch.0;
trigger.updated_epoch = epoch.0;
{
let mut cat = self.catalog.write();
cat.triggers.push(TriggerEntry::from(trigger.clone()));
cat.db_epoch = epoch.0;
}
catalog::write_atomic(&self.root, &self.catalog.read(), self.meta_dek.as_ref())?;
self.advance_visible(epoch);
Ok(trigger)
}
pub fn create_or_replace_trigger(&self, trigger: StoredTrigger) -> Result<StoredTrigger> {
let _g = self.ddl_lock.lock();
trigger.validate()?;
self.validate_trigger_references(&trigger)?;
let commit_lock = Arc::clone(&self.commit_lock);
let _c = commit_lock.lock();
let epoch = self.epoch.bump_assigned();
let replaced = {
let mut cat = self.catalog.write();
let next = match cat
.triggers
.iter()
.position(|t| t.trigger.name == trigger.name)
{
Some(idx) => {
let next = cat.triggers[idx]
.trigger
.replaced(trigger.clone(), epoch.0)?;
cat.triggers[idx] = TriggerEntry::from(next.clone());
next
}
None => {
let mut next = trigger;
next.created_epoch = epoch.0;
next.updated_epoch = epoch.0;
cat.triggers.push(TriggerEntry::from(next.clone()));
next
}
};
cat.db_epoch = epoch.0;
next
};
catalog::write_atomic(&self.root, &self.catalog.read(), self.meta_dek.as_ref())?;
self.advance_visible(epoch);
Ok(replaced)
}
pub fn drop_trigger(&self, name: &str) -> Result<()> {
let _g = self.ddl_lock.lock();
let commit_lock = Arc::clone(&self.commit_lock);
let _c = commit_lock.lock();
let epoch = self.epoch.bump_assigned();
{
let mut cat = self.catalog.write();
let before = cat.triggers.len();
cat.triggers.retain(|t| t.trigger.name != name);
if cat.triggers.len() == before {
return Err(MongrelError::NotFound(format!(
"trigger {name:?} not found"
)));
}
cat.db_epoch = epoch.0;
}
catalog::write_atomic(&self.root, &self.catalog.read(), self.meta_dek.as_ref())?;
self.advance_visible(epoch);
Ok(())
}
pub fn external_tables(&self) -> Vec<ExternalTableEntry> {
self.catalog.read().external_tables.clone()
}
pub fn external_table(&self, name: &str) -> Option<ExternalTableEntry> {
self.catalog
.read()
.external_tables
.iter()
.find(|t| t.name == name)
.cloned()
}
pub fn create_external_table(
&self,
mut entry: ExternalTableEntry,
) -> Result<ExternalTableEntry> {
let _g = self.ddl_lock.lock();
entry.validate()?;
{
let cat = self.catalog.read();
if cat.live(&entry.name).is_some()
|| cat.external_tables.iter().any(|t| t.name == entry.name)
{
return Err(MongrelError::InvalidArgument(format!(
"table {:?} already exists",
entry.name
)));
}
}
let commit_lock = Arc::clone(&self.commit_lock);
let _c = commit_lock.lock();
let epoch = self.epoch.bump_assigned();
entry.created_epoch = epoch.0;
{
let mut cat = self.catalog.write();
cat.external_tables.push(entry.clone());
cat.db_epoch = epoch.0;
}
catalog::write_atomic(&self.root, &self.catalog.read(), self.meta_dek.as_ref())?;
self.advance_visible(epoch);
Ok(entry)
}
pub fn drop_external_table(&self, name: &str) -> Result<()> {
let _g = self.ddl_lock.lock();
let commit_lock = Arc::clone(&self.commit_lock);
let _c = commit_lock.lock();
let epoch = self.epoch.bump_assigned();
{
let mut cat = self.catalog.write();
let before = cat.external_tables.len();
cat.external_tables.retain(|t| t.name != name);
if cat.external_tables.len() == before {
return Err(MongrelError::NotFound(format!(
"external table {name:?} not found"
)));
}
cat.db_epoch = epoch.0;
}
let state_dir = self.root.join(VTAB_DIR).join(name);
if state_dir.exists() {
std::fs::remove_dir_all(state_dir)?;
}
catalog::write_atomic(&self.root, &self.catalog.read(), self.meta_dek.as_ref())?;
self.advance_visible(epoch);
Ok(())
}
pub fn commit_external_table_state(&self, name: &str, state: &[u8]) -> Result<Epoch> {
let txn_id = self.alloc_txn_id();
self.commit_transaction_with_external_states(
txn_id,
self.epoch.visible(),
Vec::new(),
vec![(name.to_string(), state.to_vec())],
None,
)
}
pub fn trigger_config(&self) -> TriggerConfig {
use std::sync::atomic::Ordering;
TriggerConfig {
recursive_triggers: self.trigger_recursive.load(Ordering::Relaxed),
max_depth: self.trigger_max_depth.load(Ordering::Relaxed),
max_loop_iterations: self.trigger_max_loop_iterations.load(Ordering::Relaxed),
}
}
pub fn set_trigger_config(&self, config: TriggerConfig) -> Result<()> {
use std::sync::atomic::Ordering;
if config.max_depth == 0 {
return Err(MongrelError::InvalidArgument(
"trigger max_depth must be greater than 0".into(),
));
}
self.trigger_recursive
.store(config.recursive_triggers, Ordering::Relaxed);
self.trigger_max_depth
.store(config.max_depth, Ordering::Relaxed);
self.trigger_max_loop_iterations
.store(config.max_loop_iterations, Ordering::Relaxed);
Ok(())
}
pub fn set_recursive_triggers(&self, recursive: bool) {
use std::sync::atomic::Ordering;
self.trigger_recursive.store(recursive, Ordering::Relaxed);
}
pub fn subscribe_changes(&self) -> tokio::sync::broadcast::Receiver<ChangeEvent> {
self.notify.subscribe()
}
pub fn notify(&self, channel: &str, message: Option<String>) {
let _ = self.notify.send(ChangeEvent {
channel: channel.to_string(),
table: String::new(),
op: "notify".into(),
epoch: self.epoch.visible().0,
message,
});
}
pub fn call_procedure(
&self,
name: &str,
args: HashMap<String, crate::Value>,
) -> Result<ProcedureCallResult> {
let procedure = self
.procedure(name)
.ok_or_else(|| MongrelError::NotFound(format!("procedure {name:?} not found")))?;
let args = bind_procedure_args(&procedure, args)?;
let has_writes = procedure.body.steps.iter().any(ProcedureStep::is_write);
let mut outputs: HashMap<String, ProcedureCallOutput> = HashMap::new();
if has_writes {
let mut tx = self.begin();
let run = (|| {
for step in &procedure.body.steps {
let output =
self.execute_procedure_step(step, &args, &outputs, Some(&mut tx))?;
outputs.insert(step.id().to_string(), output);
}
eval_return_output(&procedure.body.return_value, &args, &outputs)
})();
match run {
Ok(output) => {
let epoch = tx.commit()?.0;
Ok(ProcedureCallResult {
epoch: Some(epoch),
output,
})
}
Err(e) => {
tx.rollback();
Err(e)
}
}
} else {
for step in &procedure.body.steps {
let output = self.execute_procedure_step(step, &args, &outputs, None)?;
outputs.insert(step.id().to_string(), output);
}
Ok(ProcedureCallResult {
epoch: None,
output: eval_return_output(&procedure.body.return_value, &args, &outputs)?,
})
}
}
fn execute_procedure_step(
&self,
step: &ProcedureStep,
args: &HashMap<String, crate::Value>,
outputs: &HashMap<String, ProcedureCallOutput>,
tx: Option<&mut crate::txn::Transaction<'_>>,
) -> Result<ProcedureCallOutput> {
match step {
ProcedureStep::NativeQuery {
table,
conditions,
projection,
limit,
..
} => {
let mut q = crate::Query::new();
for condition in conditions {
q = q.and(eval_condition(condition, args, outputs)?);
}
let handle = self.table(table)?;
let mut rows = handle.lock().query(&q)?;
if let Some(limit) = limit {
rows.truncate(*limit);
}
let projection = projection.as_ref();
Ok(ProcedureCallOutput::Rows(
rows.into_iter()
.map(|row| ProcedureCallRow {
row_id: Some(row.row_id),
columns: match projection {
Some(ids) => row
.columns
.into_iter()
.filter(|(id, _)| ids.contains(id))
.collect(),
None => row.columns,
},
})
.collect(),
))
}
ProcedureStep::Put {
table,
cells,
returning,
..
} => {
let tx = tx.ok_or_else(|| {
MongrelError::InvalidArgument(
"write procedure step requires a transaction".into(),
)
})?;
let cells = eval_cells(cells, args, outputs)?;
if *returning {
let out = tx.put_returning(table, cells)?;
Ok(ProcedureCallOutput::Row(ProcedureCallRow {
row_id: None,
columns: out.row.columns.into_iter().collect(),
}))
} else {
tx.put(table, cells)?;
Ok(ProcedureCallOutput::Null)
}
}
ProcedureStep::Upsert {
table,
cells,
update_cells,
returning,
..
} => {
let tx = tx.ok_or_else(|| {
MongrelError::InvalidArgument(
"write procedure step requires a transaction".into(),
)
})?;
let cells = eval_cells(cells, args, outputs)?;
let action = match update_cells {
Some(update_cells) => {
crate::UpsertAction::DoUpdate(eval_cells(update_cells, args, outputs)?)
}
None => crate::UpsertAction::DoNothing,
};
let out = tx.upsert(table, cells, action)?;
if *returning {
Ok(ProcedureCallOutput::Row(ProcedureCallRow {
row_id: None,
columns: out.row.columns.into_iter().collect(),
}))
} else {
Ok(ProcedureCallOutput::Null)
}
}
ProcedureStep::DeleteByPk { table, pk, .. } => {
let tx = tx.ok_or_else(|| {
MongrelError::InvalidArgument(
"write procedure step requires a transaction".into(),
)
})?;
let pk = eval_value(pk, args, outputs)?;
let handle = self.table(table)?;
let row_id = handle.lock().lookup_pk(&pk.encode_key()).ok_or_else(|| {
MongrelError::NotFound("procedure delete_by_pk target not found".into())
})?;
tx.delete(table, row_id)?;
Ok(ProcedureCallOutput::Scalar(crate::Value::Bool(true)))
}
ProcedureStep::DeleteRows { .. } => Err(MongrelError::InvalidArgument(
"DeleteRows procedure step is not supported by the core executor yet".into(),
)),
ProcedureStep::SqlQuery { .. } => Err(MongrelError::InvalidArgument(
"SqlQuery procedure step must be executed by mongreldb-query".into(),
)),
}
}
fn validate_procedure_references(&self, procedure: &StoredProcedure) -> Result<()> {
let cat = self.catalog.read();
for step in &procedure.body.steps {
let Some(table_name) = step.table() else {
continue;
};
let schema = &cat
.live(table_name)
.ok_or_else(|| {
MongrelError::InvalidArgument(format!(
"procedure {:?} references unknown table {table_name:?}",
procedure.name
))
})?
.schema;
match step {
ProcedureStep::NativeQuery {
conditions,
projection,
..
} => {
for condition in conditions {
validate_condition_columns(condition, schema)?;
}
if let Some(projection) = projection {
for id in projection {
validate_column_id(*id, schema)?;
}
}
}
ProcedureStep::Put { cells, .. } => {
for cell in cells {
validate_column_id(cell.column_id, schema)?;
}
}
ProcedureStep::Upsert {
cells,
update_cells,
..
} => {
for cell in cells {
validate_column_id(cell.column_id, schema)?;
}
if let Some(update_cells) = update_cells {
for cell in update_cells {
validate_column_id(cell.column_id, schema)?;
}
}
}
ProcedureStep::DeleteByPk { .. } => {
if schema.primary_key().is_none() {
return Err(MongrelError::InvalidArgument(format!(
"procedure {:?} references DeleteByPk on table {table_name:?} without a primary key",
procedure.name
)));
}
}
ProcedureStep::DeleteRows { .. } | ProcedureStep::SqlQuery { .. } => {}
}
}
Ok(())
}
fn validate_trigger_references(&self, trigger: &StoredTrigger) -> Result<()> {
let cat = self.catalog.read();
let target_schema = match &trigger.target {
TriggerTarget::Table(target_name) => cat
.live(target_name)
.ok_or_else(|| {
MongrelError::InvalidArgument(format!(
"trigger {:?} references unknown target table {target_name:?}",
trigger.name
))
})?
.schema
.clone(),
TriggerTarget::View(_) => Schema {
columns: trigger.target_columns.clone(),
..Schema::default()
},
};
for col in &trigger.update_of {
if target_schema.column(col).is_none() {
return Err(MongrelError::InvalidArgument(format!(
"trigger {:?} UPDATE OF references unknown column {col:?}",
trigger.name
)));
}
}
if let Some(expr) = &trigger.when {
validate_trigger_expr(expr, &target_schema, trigger.event)?;
}
let mut select_schemas: HashMap<String, &Schema> = HashMap::new();
for step in &trigger.program.steps {
if matches!(step, TriggerStep::SetNew { .. }) && trigger.timing != TriggerTiming::Before
{
return Err(MongrelError::InvalidArgument(
"SetNew trigger steps are only valid in BEFORE triggers".into(),
));
}
validate_trigger_step(
step,
&cat,
&target_schema,
trigger.event,
&mut select_schemas,
)?;
}
Ok(())
}
pub fn begin(&self) -> crate::txn::Transaction<'_> {
self.begin_with_isolation(crate::txn::IsolationLevel::default())
}
pub fn begin_with_isolation(
&self,
level: crate::txn::IsolationLevel,
) -> crate::txn::Transaction<'_> {
let txn_id = self.alloc_txn_id();
let epoch = match level {
crate::txn::IsolationLevel::ReadCommitted => self.epoch.visible(),
_ => self.epoch.visible(),
};
let read = Snapshot::at(epoch);
crate::txn::Transaction::new(self, txn_id, read)
}
pub fn begin_with_external_trigger_bridge<'a>(
&'a self,
bridge: &'a dyn ExternalTriggerBridge,
) -> crate::txn::Transaction<'a> {
let txn_id = self.alloc_txn_id();
let read = Snapshot::at(self.epoch.visible());
crate::txn::Transaction::new(self, txn_id, read).with_external_trigger_bridge(bridge)
}
pub fn transaction<T>(
&self,
f: impl FnOnce(&mut crate::txn::Transaction) -> Result<T>,
) -> Result<T> {
let mut tx = self.begin();
match f(&mut tx) {
Ok(out) => {
tx.commit()?;
Ok(out)
}
Err(e) => {
tx.rollback();
Err(e)
}
}
}
pub fn transaction_with_external_trigger_bridge<'a, T>(
&'a self,
bridge: &'a dyn ExternalTriggerBridge,
f: impl FnOnce(&mut crate::txn::Transaction) -> Result<T>,
) -> Result<T> {
let mut tx = self.begin_with_external_trigger_bridge(bridge);
match f(&mut tx) {
Ok(out) => {
tx.commit()?;
Ok(out)
}
Err(e) => {
tx.rollback();
Err(e)
}
}
}
pub(crate) fn register_active(&self, epoch: Epoch) -> crate::txn::ActiveTxnGuard<'_> {
self.active_txns.register(epoch)
}
fn fill_auto_increment_for_staging(
&self,
staging: &mut [(u64, crate::txn::Staged)],
) -> Result<()> {
let tables = self.tables.read();
for (table_id, staged) in staging {
if let crate::txn::Staged::Put(cells) = staged {
if let Some(handle) = tables.get(table_id) {
let mut t = handle.lock();
t.fill_auto_inc(cells)?;
}
}
}
Ok(())
}
fn expand_table_triggers(
&self,
staging: &mut Vec<(u64, crate::txn::Staged)>,
read_epoch: Epoch,
external_trigger_bridge: Option<&dyn ExternalTriggerBridge>,
external_states: &mut Vec<(String, Vec<u8>)>,
) -> Result<()> {
let mut external_writes = Vec::new();
let config = self.trigger_config();
if config.recursive_triggers {
let chunk = std::mem::take(staging);
let stacks = vec![Vec::new(); chunk.len()];
*staging = self.expand_trigger_chunk(
chunk,
stacks,
read_epoch,
0,
config.max_depth,
&mut external_writes,
&config,
)?;
self.apply_external_trigger_writes(
external_writes,
external_trigger_bridge,
external_states,
staging,
)?;
return Ok(());
}
let mut expansion = self.expand_table_triggers_once(staging, read_epoch, None, &config)?;
if !expansion.before.is_empty() {
let mut final_staging = expansion.before;
final_staging.extend(filter_ignored_staging(
std::mem::take(staging),
&expansion.ignored_indices,
));
*staging = final_staging;
} else if !expansion.ignored_indices.is_empty() {
*staging = filter_ignored_staging(std::mem::take(staging), &expansion.ignored_indices);
}
staging.append(&mut expansion.after);
external_writes.append(&mut expansion.before_external);
external_writes.append(&mut expansion.after_external);
self.apply_external_trigger_writes(
external_writes,
external_trigger_bridge,
external_states,
staging,
)?;
Ok(())
}
#[allow(clippy::too_many_arguments)]
fn expand_trigger_chunk(
&self,
mut chunk: Vec<(u64, crate::txn::Staged)>,
stacks: Vec<Vec<String>>,
read_epoch: Epoch,
depth: u32,
max_depth: u32,
external_writes: &mut Vec<ExternalTriggerWrite>,
config: &TriggerConfig,
) -> Result<Vec<(u64, crate::txn::Staged)>> {
if chunk.is_empty() {
return Ok(Vec::new());
}
self.fill_auto_increment_for_staging(&mut chunk)?;
let expansion =
self.expand_table_triggers_once(&mut chunk, read_epoch, Some(&stacks), config)?;
if depth >= max_depth && (!expansion.before.is_empty() || !expansion.after.is_empty()) {
let stack = expansion
.before_stacks
.first()
.or_else(|| expansion.after_stacks.first())
.cloned()
.unwrap_or_default();
return Err(MongrelError::Conflict(format!(
"trigger recursion exceeded max depth {max_depth}; trigger stack: {}",
Self::format_trigger_stack(&stack)
)));
}
let mut out = Vec::new();
external_writes.extend(expansion.before_external);
out.extend(self.expand_trigger_chunk(
expansion.before,
expansion.before_stacks,
read_epoch,
depth + 1,
max_depth,
external_writes,
config,
)?);
out.extend(filter_ignored_staging(chunk, &expansion.ignored_indices));
external_writes.extend(expansion.after_external);
out.extend(self.expand_trigger_chunk(
expansion.after,
expansion.after_stacks,
read_epoch,
depth + 1,
max_depth,
external_writes,
config,
)?);
Ok(out)
}
fn apply_external_trigger_writes(
&self,
writes: Vec<ExternalTriggerWrite>,
bridge: Option<&dyn ExternalTriggerBridge>,
external_states: &mut Vec<(String, Vec<u8>)>,
staging: &mut Vec<(u64, crate::txn::Staged)>,
) -> Result<()> {
if writes.is_empty() {
return Ok(());
}
let bridge = bridge.ok_or_else(|| {
MongrelError::InvalidArgument(
"trigger program wrote an external table, but this transaction has no external trigger bridge".into(),
)
})?;
for write in writes {
let table = write.table().to_string();
let entry = self.external_table(&table).ok_or_else(|| {
MongrelError::NotFound(format!("external table {table:?} not found"))
})?;
let base_state = current_external_state_bytes(&self.root, external_states, &table)?;
let result = bridge.apply_trigger_external_write(&entry, base_state, write)?;
external_states.push((table, result.state));
for base_write in result.base_writes {
match base_write {
ExternalTriggerBaseWrite::Put { table, cells } => {
let table_id = self.table_id(&table)?;
staging.push((table_id, crate::txn::Staged::Put(cells)));
}
ExternalTriggerBaseWrite::Delete { table, row_id } => {
let table_id = self.table_id(&table)?;
staging.push((table_id, crate::txn::Staged::Delete(row_id)));
}
}
}
}
dedup_external_states_in_place(external_states);
Ok(())
}
fn expand_table_triggers_once(
&self,
staging: &mut Vec<(u64, crate::txn::Staged)>,
read_epoch: Epoch,
trigger_stacks: Option<&[Vec<String>]>,
config: &TriggerConfig,
) -> Result<TriggerExpansion> {
let triggers: Vec<StoredTrigger> = self
.catalog
.read()
.triggers
.iter()
.filter(|entry| {
entry.trigger.enabled
&& matches!(
entry.trigger.timing,
TriggerTiming::Before | TriggerTiming::After
)
&& matches!(entry.trigger.target, TriggerTarget::Table(_))
})
.map(|entry| entry.trigger.clone())
.collect();
if triggers.is_empty() || staging.is_empty() {
return Ok(TriggerExpansion::default());
}
let before_triggers = triggers
.iter()
.filter(|trigger| trigger.timing == TriggerTiming::Before)
.cloned()
.collect::<Vec<_>>();
let after_triggers = triggers
.iter()
.filter(|trigger| trigger.timing == TriggerTiming::After)
.cloned()
.collect::<Vec<_>>();
let mut before_added = Vec::new();
let mut before_stacks = Vec::new();
let mut before_external = Vec::new();
let mut ignored_indices = std::collections::BTreeSet::new();
if !before_triggers.is_empty() {
let before_events =
self.trigger_events_for_staging(staging, read_epoch, trigger_stacks)?;
let mut out = TriggerProgramOutput {
added: &mut before_added,
added_stacks: &mut before_stacks,
added_external: &mut before_external,
ignored_indices: &mut ignored_indices,
};
self.execute_triggers_for_events(
&before_triggers,
&before_events,
Some(staging),
&mut out,
config,
read_epoch,
)?;
}
let after_events = if after_triggers.is_empty() {
Vec::new()
} else {
self.trigger_events_for_staging(staging, read_epoch, trigger_stacks)?
.into_iter()
.filter(|event| {
!event
.op_indices
.iter()
.any(|idx| ignored_indices.contains(idx))
})
.collect()
};
let mut after_added = Vec::new();
let mut after_stacks = Vec::new();
let mut after_external = Vec::new();
let mut out = TriggerProgramOutput {
added: &mut after_added,
added_stacks: &mut after_stacks,
added_external: &mut after_external,
ignored_indices: &mut ignored_indices,
};
self.execute_triggers_for_events(
&after_triggers,
&after_events,
None,
&mut out,
config,
read_epoch,
)?;
Ok(TriggerExpansion {
before: before_added,
before_stacks,
before_external,
after: after_added,
after_stacks,
after_external,
ignored_indices,
})
}
fn execute_triggers_for_events(
&self,
triggers: &[StoredTrigger],
events: &[WriteEvent],
mut staging: Option<&mut Vec<(u64, crate::txn::Staged)>>,
out: &mut TriggerProgramOutput<'_>,
config: &TriggerConfig,
read_epoch: Epoch,
) -> Result<()> {
for event in events {
for trigger in triggers {
if event
.op_indices
.iter()
.any(|idx| out.ignored_indices.contains(idx))
{
break;
}
let matches = {
let cat = self.catalog.read();
trigger_matches_event(trigger, event, &cat)?
};
if !matches {
continue;
}
if let Some(when) = &trigger.when {
if !eval_trigger_expr(when, event)? {
continue;
}
}
let trigger_stack = Self::trigger_stack_with(&event.trigger_stack, &trigger.name);
if event.trigger_stack.iter().any(|name| name == &trigger.name) {
return Err(MongrelError::Conflict(format!(
"trigger recursion cycle detected; trigger stack: {}",
Self::format_trigger_stack(&trigger_stack)
)));
}
let outcome = match staging.as_mut() {
Some(staging) => self.execute_trigger_program(
trigger,
event,
Some(&mut **staging),
out,
&trigger_stack,
config,
read_epoch,
)?,
None => self.execute_trigger_program(
trigger,
event,
None,
out,
&trigger_stack,
config,
read_epoch,
)?,
};
if outcome == TriggerProgramOutcome::Ignore {
out.ignored_indices.extend(event.op_indices.iter().copied());
break;
}
}
}
Ok(())
}
fn trigger_events_for_staging(
&self,
staging: &[(u64, crate::txn::Staged)],
read_epoch: Epoch,
trigger_stacks: Option<&[Vec<String>]>,
) -> Result<Vec<WriteEvent>> {
use crate::txn::Staged;
use std::collections::{HashMap, VecDeque};
let snapshot = Snapshot::at(read_epoch);
let cat = self.catalog.read();
let mut table_names = HashMap::new();
let mut table_schemas = HashMap::new();
for entry in cat
.tables
.iter()
.filter(|entry| matches!(entry.state, TableState::Live))
{
table_names.insert(entry.table_id, entry.name.clone());
table_schemas.insert(entry.table_id, entry.schema.clone());
}
drop(cat);
let mut old_rows: HashMap<usize, TriggerRowImage> = HashMap::new();
let mut delete_by_key: HashMap<(u64, Vec<u8>), VecDeque<usize>> = HashMap::new();
let mut put_by_key: HashMap<(u64, Vec<u8>), VecDeque<usize>> = HashMap::new();
for (idx, (table_id, staged)) in staging.iter().enumerate() {
let Some(schema) = table_schemas.get(table_id) else {
continue;
};
let Some(pk) = schema.primary_key() else {
continue;
};
match staged {
Staged::Delete(row_id) => {
let handle = self.table_by_id(*table_id)?;
let Some(row) = handle.lock().get(*row_id, snapshot) else {
continue;
};
let Some(pk_value) = row.columns.get(&pk.id) else {
continue;
};
old_rows.insert(idx, TriggerRowImage::from_row(row.clone()));
delete_by_key
.entry((*table_id, pk_value.encode_key()))
.or_default()
.push_back(idx);
}
Staged::Put(cells) => {
if let Some((_, value)) = cells.iter().find(|(id, _)| *id == pk.id) {
put_by_key
.entry((*table_id, value.encode_key()))
.or_default()
.push_back(idx);
}
}
Staged::Truncate => {}
}
}
let mut paired_delete = std::collections::HashSet::new();
let mut paired_put = std::collections::HashSet::new();
let mut events = Vec::new();
for (key, deletes) in delete_by_key.iter_mut() {
let Some(puts) = put_by_key.get_mut(key) else {
continue;
};
while let (Some(delete_idx), Some(put_idx)) = (deletes.pop_front(), puts.pop_front()) {
paired_delete.insert(delete_idx);
paired_put.insert(put_idx);
let (table_id, _) = &staging[put_idx];
let Some(table_name) = table_names.get(table_id).cloned() else {
continue;
};
let old = old_rows.get(&delete_idx).cloned();
let new = match &staging[put_idx].1 {
Staged::Put(cells) => Some(TriggerRowImage::from_cells(cells)),
_ => None,
};
let changed_columns = changed_columns(old.as_ref(), new.as_ref());
events.push(WriteEvent {
table: table_name,
kind: TriggerEvent::Update,
old,
new,
changed_columns,
op_indices: vec![delete_idx, put_idx],
put_idx: Some(put_idx),
trigger_stack: Self::trigger_stack_for_indices(
trigger_stacks,
&[delete_idx, put_idx],
),
});
}
}
for (idx, (table_id, staged)) in staging.iter().enumerate() {
let Some(table_name) = table_names.get(table_id).cloned() else {
continue;
};
match staged {
Staged::Put(cells) if !paired_put.contains(&idx) => {
let new = Some(TriggerRowImage::from_cells(cells));
let changed_columns = cells.iter().map(|(id, _)| *id).collect();
events.push(WriteEvent {
table: table_name,
kind: TriggerEvent::Insert,
old: None,
new,
changed_columns,
op_indices: vec![idx],
put_idx: Some(idx),
trigger_stack: Self::trigger_stack_for_indices(trigger_stacks, &[idx]),
});
}
Staged::Delete(row_id) if !paired_delete.contains(&idx) => {
let old = match old_rows.get(&idx).cloned() {
Some(old) => Some(old),
None => {
let handle = self.table_by_id(*table_id)?;
let row = handle.lock().get(*row_id, snapshot);
row.map(TriggerRowImage::from_row)
}
};
let Some(old) = old else {
continue;
};
let changed_columns = old.columns.keys().copied().collect();
events.push(WriteEvent {
table: table_name,
kind: TriggerEvent::Delete,
old: Some(old),
new: None,
changed_columns,
op_indices: vec![idx],
put_idx: None,
trigger_stack: Self::trigger_stack_for_indices(trigger_stacks, &[idx]),
});
}
Staged::Truncate => {}
_ => {}
}
}
Ok(events)
}
#[allow(clippy::too_many_arguments)]
fn execute_trigger_program(
&self,
trigger: &StoredTrigger,
event: &WriteEvent,
staging: Option<&mut Vec<(u64, crate::txn::Staged)>>,
out: &mut TriggerProgramOutput<'_>,
trigger_stack: &[String],
config: &TriggerConfig,
read_epoch: Epoch,
) -> Result<TriggerProgramOutcome> {
let mut event = event.clone();
let mut select_results: HashMap<String, Vec<TriggerRowImage>> = HashMap::new();
self.execute_trigger_steps(
trigger,
&trigger.program.steps,
&mut event,
staging,
out,
trigger_stack,
config,
&mut select_results,
0,
None,
read_epoch,
)
}
#[allow(clippy::too_many_arguments)]
fn execute_trigger_steps(
&self,
trigger: &StoredTrigger,
steps: &[TriggerStep],
event: &mut WriteEvent,
mut staging: Option<&mut Vec<(u64, crate::txn::Staged)>>,
out: &mut TriggerProgramOutput<'_>,
trigger_stack: &[String],
config: &TriggerConfig,
select_results: &mut HashMap<String, Vec<TriggerRowImage>>,
depth: u32,
selected: Option<&TriggerRowImage>,
read_epoch: Epoch,
) -> Result<TriggerProgramOutcome> {
let _ = depth;
for step in steps {
match step {
TriggerStep::SetNew { cells } => {
if trigger.timing != TriggerTiming::Before {
return Err(MongrelError::InvalidArgument(
"SetNew trigger step is only valid in BEFORE triggers".into(),
));
}
let put_idx = event.put_idx.ok_or_else(|| {
MongrelError::InvalidArgument(
"SetNew trigger step requires INSERT or UPDATE NEW row".into(),
)
})?;
let staging = staging.as_deref_mut().ok_or_else(|| {
MongrelError::InvalidArgument(
"SetNew trigger step requires mutable trigger staging".into(),
)
})?;
let Some((_, crate::txn::Staged::Put(row_cells))) = staging.get_mut(put_idx)
else {
return Err(MongrelError::InvalidArgument(
"SetNew trigger step target row is not mutable".into(),
));
};
for (column_id, value) in eval_trigger_cells(cells, event, selected)? {
row_cells.retain(|(id, _)| *id != column_id);
row_cells.push((column_id, value.clone()));
if let Some(new) = &mut event.new {
new.columns.insert(column_id, value);
}
}
row_cells.sort_by_key(|(id, _)| *id);
}
TriggerStep::Insert { table, cells } => {
let cells = eval_trigger_cells(cells, event, selected)?;
if let Ok(table_id) = self.table_id(table) {
out.added.push((table_id, crate::txn::Staged::Put(cells)));
out.added_stacks.push(trigger_stack.to_vec());
} else if self.external_table(table).is_some() {
out.added_external.push(ExternalTriggerWrite::Insert {
table: table.clone(),
cells,
});
} else {
return Err(MongrelError::NotFound(format!(
"trigger {:?} insert target {table:?} not found",
trigger.name
)));
}
}
TriggerStep::UpdateByPk { table, pk, cells } => {
let pk = eval_trigger_value(pk, event, selected)?;
let cells = eval_trigger_cells(cells, event, selected)?;
if self.external_table(table).is_some() {
out.added_external.push(ExternalTriggerWrite::UpdateByPk {
table: table.clone(),
pk,
cells,
});
} else {
let row_id = self
.table(table)?
.lock()
.lookup_pk(&pk.encode_key())
.ok_or_else(|| {
MongrelError::NotFound(format!(
"trigger {:?} update target not found",
trigger.name
))
})?;
let handle = self.table(table)?;
let snapshot = Snapshot::at(self.epoch.visible());
let old = handle.lock().get(row_id, snapshot).ok_or_else(|| {
MongrelError::NotFound(format!(
"trigger {:?} update target not visible",
trigger.name
))
})?;
let mut merged = old.columns;
for (column_id, value) in cells {
merged.insert(column_id, value);
}
out.added
.push((self.table_id(table)?, crate::txn::Staged::Delete(row_id)));
out.added_stacks.push(trigger_stack.to_vec());
out.added.push((
self.table_id(table)?,
crate::txn::Staged::Put(merged.into_iter().collect()),
));
out.added_stacks.push(trigger_stack.to_vec());
}
}
TriggerStep::DeleteByPk { table, pk } => {
let pk = eval_trigger_value(pk, event, selected)?;
if self.external_table(table).is_some() {
out.added_external.push(ExternalTriggerWrite::DeleteByPk {
table: table.clone(),
pk,
});
} else {
let row_id = self
.table(table)?
.lock()
.lookup_pk(&pk.encode_key())
.ok_or_else(|| {
MongrelError::NotFound(format!(
"trigger {:?} delete target not found",
trigger.name
))
})?;
out.added
.push((self.table_id(table)?, crate::txn::Staged::Delete(row_id)));
out.added_stacks.push(trigger_stack.to_vec());
}
}
TriggerStep::Select {
id,
table,
conditions,
} => {
let schema = self.table(table)?.lock().schema().clone();
let snapshot = Snapshot::at(read_epoch);
let rows = self.table(table)?.lock().visible_rows(snapshot)?;
let mut matched = Vec::new();
for row in rows {
let image = TriggerRowImage::from_row(row);
let passes = conditions
.iter()
.map(|cond| eval_trigger_condition(cond, event, &image, &schema))
.collect::<Result<Vec<_>>>()?
.into_iter()
.all(|b| b);
if passes {
matched.push(image);
}
}
if let Some(pk) = schema.primary_key() {
matched.sort_by(|a, b| {
let av = a.columns.get(&pk.id).unwrap_or(&Value::Null);
let bv = b.columns.get(&pk.id).unwrap_or(&Value::Null);
value_order(av, bv).unwrap_or(std::cmp::Ordering::Equal)
});
}
select_results.insert(id.clone(), matched);
}
TriggerStep::Foreach { id, steps } => {
let rows = select_results.get(id).ok_or_else(|| {
MongrelError::InvalidArgument(format!(
"trigger {:?} foreach references unknown select id {id:?}",
trigger.name
))
})?;
if rows.len() > config.max_loop_iterations as usize {
return Err(MongrelError::InvalidArgument(format!(
"trigger {:?} foreach exceeded max_loop_iterations ({})",
trigger.name, config.max_loop_iterations
)));
}
for row in rows.clone() {
let result = self.execute_trigger_steps(
trigger,
steps,
event,
staging.as_deref_mut(),
out,
trigger_stack,
config,
select_results,
depth + 1,
Some(&row),
read_epoch,
)?;
if result == TriggerProgramOutcome::Ignore {
return Ok(TriggerProgramOutcome::Ignore);
}
}
}
TriggerStep::DeleteWhere { table, conditions } => {
let schema = self.table(table)?.lock().schema().clone();
let snapshot = Snapshot::at(read_epoch);
let rows = self.table(table)?.lock().visible_rows(snapshot)?;
let table_id = self.table_id(table)?;
let mut to_delete = Vec::new();
for row in rows {
let image = TriggerRowImage::from_row(row.clone());
let passes = conditions
.iter()
.map(|cond| eval_trigger_condition(cond, event, &image, &schema))
.collect::<Result<Vec<_>>>()?
.into_iter()
.all(|b| b);
if passes {
to_delete.push((table_id, row.row_id));
}
}
for (table_id, row_id) in to_delete {
out.added
.push((table_id, crate::txn::Staged::Delete(row_id)));
out.added_stacks.push(trigger_stack.to_vec());
}
}
TriggerStep::UpdateWhere {
table,
conditions,
cells,
} => {
let schema = self.table(table)?.lock().schema().clone();
let snapshot = Snapshot::at(read_epoch);
let rows = self.table(table)?.lock().visible_rows(snapshot)?;
let table_id = self.table_id(table)?;
let mut to_update = Vec::new();
for row in rows {
let image = TriggerRowImage::from_row(row.clone());
let passes = conditions
.iter()
.map(|cond| eval_trigger_condition(cond, event, &image, &schema))
.collect::<Result<Vec<_>>>()?
.into_iter()
.all(|b| b);
if passes {
let new_cells = cells
.iter()
.map(|cell| {
Ok((
cell.column_id,
eval_trigger_value(&cell.value, event, Some(&image))?,
))
})
.collect::<Result<Vec<_>>>()?;
let mut merged = row.columns.clone();
for (column_id, value) in new_cells {
merged.insert(column_id, value);
}
to_update.push((table_id, row.row_id, merged));
}
}
for (table_id, row_id, merged) in to_update {
out.added
.push((table_id, crate::txn::Staged::Delete(row_id)));
out.added_stacks.push(trigger_stack.to_vec());
out.added.push((
table_id,
crate::txn::Staged::Put(merged.into_iter().collect()),
));
out.added_stacks.push(trigger_stack.to_vec());
}
}
TriggerStep::Raise { action, message } => match action {
TriggerRaiseAction::Ignore => return Ok(TriggerProgramOutcome::Ignore),
TriggerRaiseAction::Abort
| TriggerRaiseAction::Fail
| TriggerRaiseAction::Rollback => {
let message = eval_trigger_value(message, event, selected)?;
return Err(MongrelError::Conflict(format!(
"trigger {:?} raised: {}; trigger stack: {}",
trigger.name,
trigger_message(message),
Self::format_trigger_stack(trigger_stack)
)));
}
},
}
}
Ok(TriggerProgramOutcome::Continue)
}
fn trigger_stack_for_indices(stacks: Option<&[Vec<String>]>, indices: &[usize]) -> Vec<String> {
let Some(stacks) = stacks else {
return Vec::new();
};
let mut out = Vec::new();
for idx in indices {
let Some(stack) = stacks.get(*idx) else {
continue;
};
for name in stack {
if !out.iter().any(|existing| existing == name) {
out.push(name.clone());
}
}
}
out
}
fn trigger_stack_with(stack: &[String], trigger_name: &str) -> Vec<String> {
let mut out = stack.to_vec();
out.push(trigger_name.to_string());
out
}
fn format_trigger_stack(stack: &[String]) -> String {
if stack.is_empty() {
"<root>".into()
} else {
stack.join(" -> ")
}
}
fn validate_constraints(
&self,
staging: &mut Vec<(u64, crate::txn::Staged)>,
read_epoch: Epoch,
) -> Result<()> {
use crate::constraint::{encode_composite_key, validate_checks, FkAction};
use crate::memtable::Row;
use crate::txn::Staged;
use std::collections::HashSet;
let snapshot = Snapshot::at(read_epoch);
let cat = self.catalog.read();
let live: Vec<(u64, &str, &crate::schema::Schema)> = cat
.tables
.iter()
.filter(|e| matches!(e.state, TableState::Live))
.map(|e| (e.table_id, e.name.as_str(), &e.schema))
.collect();
let any_constraints = live.iter().any(|(_, _, s)| !s.constraints.is_empty());
if !any_constraints {
return Ok(());
}
let mut rows_cache: HashMap<u64, Vec<Row>> = HashMap::new();
let mut load_rows = |table_id: u64| -> Result<Vec<Row>> {
if let Some(r) = rows_cache.get(&table_id) {
return Ok(r.clone());
}
let handle = self.table_by_id(table_id)?;
let rows = handle.lock().visible_rows(snapshot)?;
rows_cache.insert(table_id, rows.clone());
Ok(rows)
};
let mut cascaded: HashSet<(u64, u64)> = HashSet::new();
loop {
let mut new_ops: Vec<(u64, Staged)> = Vec::new();
let deletes: Vec<(u64, crate::rowid::RowId)> = staging
.iter()
.filter_map(|(t, op)| match op {
Staged::Delete(rid) => Some((*t, *rid)),
_ => None,
})
.collect();
for (table_id, rid) in deletes {
if !cascaded.insert((table_id, rid.0)) {
continue;
}
let Some(tname) = live
.iter()
.find(|(t, _, _)| *t == table_id)
.map(|(_, n, _)| *n)
else {
continue;
};
let parent_handle = self.table_by_id(table_id)?;
let Some(parent_row) = parent_handle.lock().get(rid, snapshot) else {
continue;
};
for (child_id, _child_name, child_schema) in &live {
for fk in &child_schema.constraints.foreign_keys {
if fk.ref_table != tname {
continue;
}
let Some(parent_key) =
encode_composite_key(&fk.ref_columns, &parent_row.columns)
else {
continue;
};
match fk.on_delete {
FkAction::Restrict => continue,
FkAction::Cascade => {
let child_rows = load_rows(*child_id)?;
for cr in &child_rows {
if !cascaded.contains(&(*child_id, cr.row_id.0))
&& encode_composite_key(&fk.columns, &cr.columns).as_deref()
== Some(parent_key.as_slice())
{
new_ops.push((*child_id, Staged::Delete(cr.row_id)));
}
}
}
FkAction::SetNull => {
let child_rows = load_rows(*child_id)?;
for cr in &child_rows {
if !cascaded.contains(&(*child_id, cr.row_id.0))
&& encode_composite_key(&fk.columns, &cr.columns).as_deref()
== Some(parent_key.as_slice())
{
let mut cells: Vec<(u16, crate::memtable::Value)> = cr
.columns
.iter()
.map(|(k, v)| (*k, v.clone()))
.collect();
for cid in &fk.columns {
cells.retain(|(k, _)| k != cid);
cells.push((*cid, crate::memtable::Value::Null));
}
new_ops.push((*child_id, Staged::Delete(cr.row_id)));
new_ops.push((*child_id, Staged::Put(cells)));
}
}
}
}
}
}
}
if new_ops.is_empty() {
break;
}
staging.extend(new_ops);
}
let staged_deletes: HashSet<(u64, u64)> = staging
.iter()
.filter_map(|(t, op)| match op {
Staged::Delete(rid) => Some((*t, rid.0)),
_ => None,
})
.collect();
let mut seen_unique: HashSet<(u64, u16, Vec<u8>)> = HashSet::new();
for (table_id, op) in staging.iter() {
let Some((_, tname, schema)) = live.iter().find(|(t, _, _)| t == table_id).copied()
else {
continue;
};
let cells_map: HashMap<u16, crate::memtable::Value>;
match op {
Staged::Put(cells) => {
cells_map = cells.iter().cloned().collect();
if !schema.constraints.checks.is_empty() {
validate_checks(&schema.constraints.checks, &cells_map)?;
}
for uc in &schema.constraints.uniques {
let Some(key) = encode_composite_key(&uc.columns, &cells_map) else {
continue; };
let marker = (*table_id, uc.id, key.clone());
if !seen_unique.insert(marker) {
return Err(MongrelError::Conflict(format!(
"UNIQUE constraint '{}' on table '{tname}' violated within batch",
uc.name
)));
}
let rows = load_rows(*table_id)?;
for r in &rows {
if staged_deletes.contains(&(*table_id, r.row_id.0)) {
continue;
}
if let Some(theirs) = encode_composite_key(&uc.columns, &r.columns) {
if theirs == key {
return Err(MongrelError::Conflict(format!(
"UNIQUE constraint '{}' on table '{tname}' violated",
uc.name
)));
}
}
}
}
for fk in &schema.constraints.foreign_keys {
let Some(child_key) = encode_composite_key(&fk.columns, &cells_map) else {
continue; };
let Some(parent_id) = cat
.tables
.iter()
.find(|t| t.name == fk.ref_table)
.map(|t| t.table_id)
else {
return Err(MongrelError::InvalidArgument(format!(
"FOREIGN KEY '{}' references unknown table '{}'",
fk.name, fk.ref_table
)));
};
let parent_rows = load_rows(parent_id)?;
let mut found = false;
for r in &parent_rows {
if staged_deletes.contains(&(parent_id, r.row_id.0)) {
continue;
}
if let Some(pkey) = encode_composite_key(&fk.ref_columns, &r.columns) {
if pkey == child_key {
found = true;
break;
}
}
}
if !found {
return Err(MongrelError::Conflict(format!(
"FOREIGN KEY '{}' on table '{tname}' has no matching parent in '{}'",
fk.name, fk.ref_table
)));
}
}
}
Staged::Delete(rid) => {
let parent_handle = self.table_by_id(*table_id)?;
let Some(parent_row) = parent_handle.lock().get(*rid, snapshot) else {
continue;
};
for (child_id, child_name, child_schema) in &live {
for fk in &child_schema.constraints.foreign_keys {
if fk.ref_table != tname || fk.on_delete != FkAction::Restrict {
continue;
}
let Some(parent_key) =
encode_composite_key(&fk.ref_columns, &parent_row.columns)
else {
continue;
};
let child_rows = load_rows(*child_id)?;
for r in &child_rows {
if staged_deletes.contains(&(*child_id, r.row_id.0)) {
continue;
}
if let Some(ck) = encode_composite_key(&fk.columns, &r.columns) {
if ck == parent_key {
return Err(MongrelError::Conflict(format!(
"FOREIGN KEY '{}' on table '{child_name}' restricts delete (parent referenced)",
fk.name
)));
}
}
}
}
}
}
Staged::Truncate => {
for (child_id, child_name, child_schema) in &live {
for fk in &child_schema.constraints.foreign_keys {
if fk.ref_table != tname {
continue;
}
let child_rows = load_rows(*child_id)?;
if child_rows
.iter()
.any(|r| encode_composite_key(&fk.columns, &r.columns).is_some())
{
return Err(MongrelError::Conflict(format!(
"FOREIGN KEY '{}' on table '{child_name}' restricts truncate of '{tname}'",
fk.name
)));
}
}
}
}
}
}
Ok(())
}
pub(crate) fn commit_transaction_with_external_states(
&self,
txn_id: u64,
read_epoch: Epoch,
mut staging: Vec<(u64, crate::txn::Staged)>,
external_states: Vec<(String, Vec<u8>)>,
external_trigger_bridge: Option<&dyn ExternalTriggerBridge>,
) -> Result<Epoch> {
use crate::memtable::Row;
use crate::txn::{Staged, StagedOp, WriteKey};
use crate::wal::Op;
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
use std::sync::atomic::Ordering;
if self.poisoned.load(Ordering::Relaxed) {
return Err(MongrelError::Other(
"database poisoned by fsync error".into(),
));
}
let mut external_states = dedup_external_states(external_states);
if !external_states.is_empty() {
let cat = self.catalog.read();
for (name, _) in &external_states {
if !cat.external_tables.iter().any(|entry| entry.name == *name) {
return Err(MongrelError::NotFound(format!(
"external table {name:?} not found"
)));
}
}
}
self.fill_auto_increment_for_staging(&mut staging)?;
self.expand_table_triggers(
&mut staging,
read_epoch,
external_trigger_bridge,
&mut external_states,
)?;
self.fill_auto_increment_for_staging(&mut staging)?;
external_states = dedup_external_states(external_states);
self.validate_constraints(&mut staging, read_epoch)?;
let write_keys = {
let cat = self.catalog.read();
let mut keys: Vec<WriteKey> = Vec::new();
for (table_id, staged) in &staging {
match staged {
Staged::Put(cells) => {
if let Some(entry) = cat.tables.iter().find(|t| t.table_id == *table_id) {
for col in &entry.schema.columns {
if col.flags.contains(crate::schema::ColumnFlags::PRIMARY_KEY) {
if let Some((_, val)) =
cells.iter().find(|(id, _)| *id == col.id)
{
let mut h = DefaultHasher::new();
val.encode_key().hash(&mut h);
keys.push(WriteKey::Unique {
table_id: *table_id,
index_id: 0,
key_hash: h.finish(),
});
}
}
}
for uc in &entry.schema.constraints.uniques {
if let Some(key_bytes) = crate::constraint::encode_composite_key(
&uc.columns,
&cells.iter().cloned().collect(),
) {
let mut h = DefaultHasher::new();
key_bytes.hash(&mut h);
keys.push(WriteKey::Unique {
table_id: *table_id,
index_id: uc.id | 0x8000,
key_hash: h.finish(),
});
}
}
}
}
Staged::Delete(rid) => keys.push(WriteKey::Row {
table_id: *table_id,
row_id: rid.0,
}),
Staged::Truncate => keys.push(WriteKey::Table {
table_id: *table_id,
}),
}
}
for (name, _) in &external_states {
let mut h = DefaultHasher::new();
name.hash(&mut h);
keys.push(WriteKey::Unique {
table_id: EXTERNAL_TABLE_ID,
index_id: 0,
key_hash: h.finish(),
});
}
keys
};
let min_active = self.active_txns.min_read_epoch();
if min_active < u64::MAX {
self.conflicts.prune_below(Epoch(min_active));
}
if self.conflicts.conflicts(&write_keys, read_epoch) {
return Err(MongrelError::Conflict(
"write-write conflict (pre-validate, first-committer-wins)".into(),
));
}
let pre_validate_version = self.conflicts.version();
let mut spilled: Vec<SpilledRun> = Vec::new();
let mut spilled_tables: std::collections::HashSet<u64> = std::collections::HashSet::new();
let mut spill_guard: Option<crate::retention::SpillGuard> = None;
{
let mut table_bytes: HashMap<u64, usize> = HashMap::new();
for (table_id, staged) in &staging {
if let Staged::Put(cells) = staged {
*table_bytes.entry(*table_id).or_default() += cells.len() * 16;
}
}
let tables = self.tables.read();
for (&table_id, &bytes) in &table_bytes {
if bytes as u64
<= self
.spill_threshold
.load(std::sync::atomic::Ordering::Relaxed)
{
continue;
}
let Some(handle) = tables.get(&table_id) else {
continue;
};
spill_guard.get_or_insert_with(|| self.active_spills.register(txn_id));
let mut t = handle.lock();
let tdir = t.table_dir().to_path_buf();
let txn_dir = tdir.join("_txn").join(txn_id.to_string());
std::fs::create_dir_all(&txn_dir)?;
let run_id = t.alloc_run_id() as u128;
let pending_path = txn_dir.join(format!("r-{run_id}.sr"));
let mut rows: Vec<Row> = Vec::new();
for (tid, staged) in &staging {
if *tid != table_id {
continue;
}
if let Staged::Put(cells) = staged {
t.validate_cells_not_null(cells)?;
let row_id = t.alloc_row_id();
let mut row = Row::new(row_id, Epoch(0));
for (c, v) in cells {
row.columns.insert(*c, v.clone());
}
rows.push(row);
}
}
let schema = t.schema_ref().clone();
let kek = t.kek_ref().cloned();
let specs = t.indexable_column_specs();
drop(t);
let mut writer = crate::sorted_run::RunWriter::new(&schema, run_id, Epoch(0), 0)
.uniform_epoch(true);
if let Some(ref kek) = kek {
writer = writer.with_encryption(kek.as_ref(), specs);
}
let header = writer.write(&pending_path, &rows)?;
let row_count = header.row_count;
let min_rid = rows.first().map(|r| r.row_id.0).unwrap_or(0);
let max_rid = rows.last().map(|r| r.row_id.0).unwrap_or(0);
spilled.push(SpilledRun {
table_id,
run_id,
pending_path,
rows,
row_count,
min_rid,
max_rid,
});
spilled_tables.insert(table_id);
}
}
if spill_guard.is_some() {
if let Some(hook) = self.spill_hook.lock().as_ref() {
hook();
}
}
let mut prebuilt: Vec<Option<Row>> = Vec::with_capacity(staging.len());
{
let tables = self.tables.read();
for (table_id, staged) in &staging {
match staged {
Staged::Put(cells) if !spilled_tables.contains(table_id) => {
let handle = tables.get(table_id).ok_or_else(|| {
MongrelError::NotFound(format!("table {table_id} not mounted"))
})?;
let mut t = handle.lock();
t.validate_cells_not_null(cells)?;
let row_id = t.alloc_row_id();
drop(t);
let mut row = Row::new(row_id, Epoch(0));
for (c, v) in cells {
row.columns.insert(*c, v.clone());
}
prebuilt.push(Some(row));
}
Staged::Put(_) | Staged::Delete(_) | Staged::Truncate => prebuilt.push(None),
}
}
}
let mut prepared_external = Vec::with_capacity(external_states.len());
for (name, state) in &external_states {
let pending = prepare_external_state_file(&self.root, name, state, txn_id)?;
prepared_external.push((name.clone(), state.clone(), pending));
}
let added_runs: Vec<crate::wal::AddedRun> = spilled
.iter()
.map(|s| crate::wal::AddedRun {
table_id: s.table_id,
run_id: s.run_id,
row_count: s.row_count,
level: 0,
min_row_id: s.min_rid,
max_row_id: s.max_rid,
content_hash: [0u8; 32],
})
.collect();
let (new_epoch, applies, commit_seq) = {
let mut wal = self.shared_wal.lock();
if self.conflicts.version() != pre_validate_version
&& self.conflicts.conflicts(&write_keys, read_epoch)
{
drop(wal);
for s in &spilled {
if let Some(parent) = s.pending_path.parent() {
let _ = std::fs::remove_dir_all(parent);
}
}
for (_, _, pending) in &prepared_external {
let _ = std::fs::remove_file(pending);
}
return Err(MongrelError::Conflict(
"write-write conflict (sequencer delta re-check)".into(),
));
}
let new_epoch = self.epoch.bump_assigned();
let mut applies: Vec<(u64, Vec<StagedOp>)> = Vec::new();
for (idx, (table_id, staged)) in staging.iter().enumerate() {
if spilled_tables.contains(table_id) && matches!(staged, Staged::Put(_)) {
continue;
}
let mut ops = Vec::new();
match staged {
Staged::Put(_) => {
let mut row = prebuilt[idx].take().expect("prebuilt put row");
row.committed_epoch = new_epoch;
let payload = bincode::serialize(&vec![row.clone()])
.map_err(|e| MongrelError::Other(format!("row serialize: {e}")))?;
wal.append(
txn_id,
*table_id,
Op::Put {
table_id: *table_id,
rows: payload,
},
)?;
ops.push(StagedOp::Put(row));
}
Staged::Delete(rid) => {
wal.append(
txn_id,
*table_id,
Op::Delete {
table_id: *table_id,
row_ids: vec![*rid],
},
)?;
ops.push(StagedOp::Delete(*rid));
}
Staged::Truncate => {
wal.append(
txn_id,
*table_id,
Op::TruncateTable {
table_id: *table_id,
},
)?;
ops.push(StagedOp::Truncate);
}
}
applies.push((*table_id, ops));
}
for (name, state, _) in &prepared_external {
wal.append(
txn_id,
EXTERNAL_TABLE_ID,
Op::ExternalTableState {
name: name.clone(),
state: state.clone(),
},
)?;
}
let commit_seq = wal.append_commit(txn_id, new_epoch, &added_runs)?;
self.conflicts.record(&write_keys, new_epoch);
(new_epoch, applies, commit_seq)
};
self.group
.await_durable(&self.shared_wal, commit_seq)
.inspect_err(|_| {
self.poisoned.store(true, Ordering::Relaxed);
})?;
{
let tables = self.tables.read();
for s in &spilled {
if let Some(handle) = tables.get(&s.table_id) {
let mut t = handle.lock();
let dest = t.run_path(s.run_id as u64);
std::fs::rename(&s.pending_path, &dest)?;
if let Some(parent) = s.pending_path.parent() {
let _ = std::fs::remove_dir_all(parent);
}
t.link_run(crate::manifest::RunRef {
run_id: s.run_id,
level: 0,
epoch_created: new_epoch.0,
row_count: s.row_count,
});
t.apply_run_metadata(&s.rows)?;
t.invalidate_pending_cache();
t.persist_manifest(new_epoch)?;
}
}
for (table_id, ops) in applies {
if let Some(handle) = tables.get(&table_id) {
let mut t = handle.lock();
for op in ops {
match op {
StagedOp::Put(row) => t.apply_put_rows(vec![row])?,
StagedOp::Delete(rid) => t.apply_delete(rid, new_epoch),
StagedOp::Truncate => t.apply_truncate(new_epoch)?,
}
}
t.invalidate_pending_cache();
t.persist_manifest(new_epoch)?;
}
}
}
for (name, _, pending) in &prepared_external {
publish_external_state_file(&self.root, name, pending)?;
}
self.advance_visible(new_epoch);
Ok(new_epoch)
}
fn advance_visible(&self, published: Epoch) {
self.epoch.publish_in_order(published);
}
pub fn snapshot(&self) -> (Snapshot, SnapshotGuard<'_>) {
let e = self.epoch.visible();
let g = self.snapshots.register(e);
(Snapshot::at(e), g)
}
pub fn snapshot_owned(&self) -> (Snapshot, OwnedSnapshotGuard) {
let e = self.epoch.visible();
let g = self.snapshots.register_owned(e);
(Snapshot::at(e), g)
}
pub fn table_names(&self) -> Vec<String> {
self.catalog
.read()
.tables
.iter()
.filter(|t| matches!(t.state, TableState::Live))
.map(|t| t.name.clone())
.collect()
}
pub fn close(&self) -> Result<()> {
for name in self.table_names() {
if let Ok(handle) = self.table(&name) {
if let Err(e) = handle.lock().close() {
eprintln!("[close] flush failed for {name}: {e}");
}
}
}
Ok(())
}
pub fn compact(&self) -> Result<(usize, usize)> {
let mut compacted = 0;
let mut skipped = 0;
for name in self.table_names() {
let Ok(handle) = self.table(&name) else {
continue;
};
{
let mut t = handle.lock();
let before = t.run_count();
if before < 2 {
skipped += 1;
continue;
}
match t.compact() {
Ok(()) => {
let after = t.run_count();
compacted += 1;
eprintln!("[compact] {name}: {before} -> {after} runs");
}
Err(e) => {
eprintln!("[compact] {name}: compaction failed: {e}");
skipped += 1;
}
}
}
}
Ok((compacted, skipped))
}
pub fn compact_table(&self, name: &str) -> Result<bool> {
let handle = self.table(name)?;
let mut t = handle.lock();
let before = t.run_count();
if before < 2 {
return Ok(false);
}
t.compact()?;
Ok(t.run_count() < before)
}
pub fn table(&self, name: &str) -> Result<TableHandle> {
let cat = self.catalog.read();
let entry = cat
.live(name)
.ok_or_else(|| MongrelError::NotFound(format!("table {name:?} not found")))?;
let id = entry.table_id;
drop(cat);
self.tables
.read()
.get(&id)
.cloned()
.ok_or_else(|| MongrelError::NotFound(format!("table {name:?} not mounted")))
}
fn table_by_id(&self, id: u64) -> Result<TableHandle> {
self.tables
.read()
.get(&id)
.cloned()
.ok_or_else(|| MongrelError::NotFound(format!("table id {id} not mounted")))
}
pub fn create_table(&self, name: &str, schema: Schema) -> Result<u64> {
use crate::wal::DdlOp;
use std::sync::atomic::Ordering;
if self.poisoned.load(Ordering::Relaxed) {
return Err(MongrelError::Other(
"database poisoned by fsync error".into(),
));
}
let _g = self.ddl_lock.lock();
{
let cat = self.catalog.read();
if cat.live(name).is_some() {
return Err(MongrelError::InvalidArgument(format!(
"table {name:?} already exists"
)));
}
}
let commit_lock = Arc::clone(&self.commit_lock);
let _c = commit_lock.lock();
let table_id = {
let mut cat = self.catalog.write();
let id = cat.next_table_id;
cat.next_table_id += 1;
id
};
let epoch = self.epoch.bump_assigned();
let txn_id = self.alloc_txn_id();
let mut schema = schema;
schema.schema_id = table_id;
schema.validate_auto_increment()?;
let schema_json = DdlOp::encode_schema(&schema)?;
let commit_seq = {
let mut wal = self.shared_wal.lock();
wal.append(
txn_id,
table_id,
crate::wal::Op::Ddl(DdlOp::CreateTable {
table_id,
name: name.to_string(),
schema_json,
}),
)?;
wal.append_commit(txn_id, epoch, &[])?
};
self.group
.await_durable(&self.shared_wal, commit_seq)
.inspect_err(|_| {
self.poisoned.store(true, Ordering::Relaxed);
})?;
let tdir = self.root.join(TABLES_DIR).join(table_id.to_string());
std::fs::create_dir_all(&tdir)?;
let ctx = SharedCtx {
epoch: Arc::clone(&self.epoch),
page_cache: Arc::clone(&self.page_cache),
decoded_cache: Arc::clone(&self.decoded_cache),
snapshots: Arc::clone(&self.snapshots),
kek: self.kek.clone(),
commit_lock: Arc::clone(&self.commit_lock),
shared: Some(crate::engine::SharedWalCtx {
wal: Arc::clone(&self.shared_wal),
group: Arc::clone(&self.group),
poisoned: Arc::clone(&self.poisoned),
txn_ids: Arc::clone(&self.next_txn_id),
}),
};
let table = Table::create_in(&tdir, schema.clone(), table_id, ctx)?;
{
let mut cat = self.catalog.write();
cat.tables.push(CatalogEntry {
table_id,
name: name.to_string(),
schema,
state: TableState::Live,
created_epoch: epoch.0,
});
}
catalog::write_atomic(&self.root, &self.catalog.read(), self.meta_dek.as_ref())?;
self.tables
.write()
.insert(table_id, Arc::new(Mutex::new(table)));
self.advance_visible(epoch);
Ok(table_id)
}
pub fn drop_table(&self, name: &str) -> Result<()> {
use crate::wal::DdlOp;
use std::sync::atomic::Ordering;
if self.poisoned.load(Ordering::Relaxed) {
return Err(MongrelError::Other(
"database poisoned by fsync error".into(),
));
}
let _g = self.ddl_lock.lock();
let table_id = {
let cat = self.catalog.read();
cat.live(name)
.ok_or_else(|| MongrelError::NotFound(format!("table {name:?} not found")))?
.table_id
};
let commit_lock = Arc::clone(&self.commit_lock);
let _c = commit_lock.lock();
let epoch = self.epoch.bump_assigned();
let txn_id = self.alloc_txn_id();
let commit_seq = {
let mut wal = self.shared_wal.lock();
wal.append(
txn_id,
table_id,
crate::wal::Op::Ddl(DdlOp::DropTable { table_id }),
)?;
wal.append_commit(txn_id, epoch, &[])?
};
self.group
.await_durable(&self.shared_wal, commit_seq)
.inspect_err(|_| {
self.poisoned.store(true, Ordering::Relaxed);
})?;
{
let mut cat = self.catalog.write();
let entry = cat
.tables
.iter_mut()
.find(|t| t.table_id == table_id)
.ok_or_else(|| MongrelError::NotFound(format!("table {name:?} not found")))?;
entry.state = TableState::Dropped { at_epoch: epoch.0 };
cat.triggers.retain(|trigger| {
!matches!(
&trigger.trigger.target,
TriggerTarget::Table(target) if target == name
)
});
}
catalog::write_atomic(&self.root, &self.catalog.read(), self.meta_dek.as_ref())?;
self.tables.write().remove(&table_id);
self.advance_visible(epoch);
Ok(())
}
pub fn rename_table(&self, name: &str, new_name: &str) -> Result<()> {
use crate::wal::DdlOp;
use std::sync::atomic::Ordering;
if self.poisoned.load(Ordering::Relaxed) {
return Err(MongrelError::Other(
"database poisoned by fsync error".into(),
));
}
if name == new_name {
return Ok(());
}
if new_name.is_empty() {
return Err(MongrelError::InvalidArgument(
"rename_table: new name must not be empty".into(),
));
}
let _g = self.ddl_lock.lock();
let table_id = {
let cat = self.catalog.read();
let src = cat
.live(name)
.ok_or_else(|| MongrelError::NotFound(format!("table {name:?} not found")))?;
if cat.live(new_name).is_some() {
return Err(MongrelError::InvalidArgument(format!(
"rename_table: a table named {new_name:?} already exists"
)));
}
src.table_id
};
let commit_lock = Arc::clone(&self.commit_lock);
let _c = commit_lock.lock();
let epoch = self.epoch.bump_assigned();
let txn_id = self.alloc_txn_id();
let commit_seq = {
let mut wal = self.shared_wal.lock();
wal.append(
txn_id,
table_id,
crate::wal::Op::Ddl(DdlOp::RenameTable {
table_id,
new_name: new_name.to_string(),
}),
)?;
wal.append_commit(txn_id, epoch, &[])?
};
self.group
.await_durable(&self.shared_wal, commit_seq)
.inspect_err(|_| {
self.poisoned.store(true, Ordering::Relaxed);
})?;
{
let mut cat = self.catalog.write();
let entry = cat
.tables
.iter_mut()
.find(|t| t.table_id == table_id)
.ok_or_else(|| MongrelError::NotFound(format!("table {name:?} not found")))?;
entry.name = new_name.to_string();
for trigger in &mut cat.triggers {
if matches!(
&trigger.trigger.target,
TriggerTarget::Table(target) if target == name
) {
trigger.trigger = trigger.trigger.retarget_table(new_name, epoch.0)?;
}
}
}
catalog::write_atomic(&self.root, &self.catalog.read(), self.meta_dek.as_ref())?;
self.advance_visible(epoch);
Ok(())
}
pub fn alter_column(
&self,
table_name: &str,
column_name: &str,
change: AlterColumn,
) -> Result<ColumnDef> {
use crate::wal::DdlOp;
use std::sync::atomic::Ordering;
if self.poisoned.load(Ordering::Relaxed) {
return Err(MongrelError::Other(
"database poisoned by fsync error".into(),
));
}
let _g = self.ddl_lock.lock();
let table_id = {
let cat = self.catalog.read();
cat.live(table_name)
.ok_or_else(|| MongrelError::NotFound(format!("table {table_name:?} not found")))?
.table_id
};
let handle =
self.tables.read().get(&table_id).cloned().ok_or_else(|| {
MongrelError::NotFound(format!("table {table_name:?} not mounted"))
})?;
let mut table = handle.lock();
let column = table.prepare_alter_column(column_name, &change)?;
let renamed_column = (column.name != column_name).then(|| column.name.clone());
if table
.schema()
.columns
.iter()
.find(|c| c.id == column.id)
.is_some_and(|c| c == &column)
{
return Ok(column);
}
let commit_lock = Arc::clone(&self.commit_lock);
let _c = commit_lock.lock();
let epoch = self.epoch.bump_assigned();
let txn_id = self.alloc_txn_id();
let column_json = DdlOp::encode_column(&column)?;
let commit_seq = {
let mut wal = self.shared_wal.lock();
wal.append(
txn_id,
table_id,
crate::wal::Op::Ddl(DdlOp::AlterTable {
table_id,
column_json,
}),
)?;
wal.append_commit(txn_id, epoch, &[])?
};
self.group
.await_durable(&self.shared_wal, commit_seq)
.inspect_err(|_| {
self.poisoned.store(true, Ordering::Relaxed);
})?;
table.apply_altered_column(column.clone())?;
let schema = table.schema().clone();
drop(table);
{
let mut cat = self.catalog.write();
let entry = cat
.tables
.iter_mut()
.find(|t| t.table_id == table_id)
.ok_or_else(|| MongrelError::NotFound(format!("table {table_name:?} not found")))?;
entry.schema = schema;
if let Some(new_column_name) = renamed_column {
for trigger in &mut cat.triggers {
if matches!(
&trigger.trigger.target,
TriggerTarget::Table(target) if target == table_name
) {
trigger.trigger = trigger.trigger.renamed_update_column(
column_name,
new_column_name.clone(),
epoch.0,
)?;
}
}
}
}
catalog::write_atomic(&self.root, &self.catalog.read(), self.meta_dek.as_ref())?;
self.advance_visible(epoch);
Ok(column)
}
pub fn gc(&self) -> Result<usize> {
let min_active = self.snapshots.min_active(self.epoch.visible()).0;
let mut reclaimed = 0;
let cat = self.catalog.read();
for entry in &cat.tables {
if let TableState::Dropped { at_epoch } = entry.state {
if at_epoch <= min_active {
let tdir = self.root.join(TABLES_DIR).join(entry.table_id.to_string());
if tdir.exists() {
std::fs::remove_dir_all(&tdir)?;
reclaimed += 1;
}
}
}
}
drop(cat);
let cat = self.catalog.read();
for entry in &cat.tables {
if !matches!(entry.state, TableState::Live) {
continue;
}
let txn_dir = self
.root
.join(TABLES_DIR)
.join(entry.table_id.to_string())
.join("_txn");
if !txn_dir.exists() {
continue;
}
for sub in std::fs::read_dir(&txn_dir)? {
let sub = sub?;
let name = sub.file_name();
let Some(name) = name.to_str() else { continue };
let is_active = name
.parse::<u64>()
.map(|id| self.active_spills.is_active(id))
.unwrap_or(false);
if is_active {
continue;
}
std::fs::remove_dir_all(sub.path())?;
reclaimed += 1;
}
}
drop(cat);
let external_names = {
let cat = self.catalog.read();
cat.external_tables
.iter()
.map(|entry| entry.name.clone())
.collect::<std::collections::HashSet<_>>()
};
let vtab_dir = self.root.join(VTAB_DIR);
if vtab_dir.exists() {
for entry in std::fs::read_dir(&vtab_dir)? {
let entry = entry?;
let name = entry.file_name();
let Some(name) = name.to_str() else { continue };
if external_names.contains(name) {
continue;
}
let path = entry.path();
if path.is_dir() {
std::fs::remove_dir_all(path)?;
} else {
std::fs::remove_file(path)?;
}
reclaimed += 1;
}
}
let tables = self.tables.read();
for handle in tables.values() {
reclaimed += handle.lock().reap_retiring(Epoch(min_active))?;
}
let all_durable = self.active_spills.is_idle()
&& tables.values().all(|h| {
let g = h.lock();
g.memtable_len() == 0 && g.mutable_run_len() == 0
});
drop(tables);
if all_durable {
reclaimed += self.shared_wal.lock().gc_segments(u64::MAX)?;
}
Ok(reclaimed)
}
fn alloc_txn_id(&self) -> u64 {
let mut g = self.next_txn_id.lock();
let id = *g;
*g = g.wrapping_add(1);
id
}
pub fn set_spill_threshold(&self, bytes: u64) {
self.spill_threshold
.store(bytes, std::sync::atomic::Ordering::Relaxed);
}
#[doc(hidden)]
pub fn __set_spill_hook(&self, f: impl Fn() + Send + Sync + 'static) {
*self.spill_hook.lock() = Some(Box::new(f));
}
#[doc(hidden)]
pub fn __wal_group_sync_count(&self) -> u64 {
self.shared_wal.lock().group_sync_count()
}
#[doc(hidden)]
pub fn __poison(&self) {
self.poisoned
.store(true, std::sync::atomic::Ordering::Relaxed);
}
pub fn check(&self) -> Vec<CheckIssue> {
let mut issues = Vec::new();
let cat = self.catalog.read();
let manifest_meta_dek = crate::encryption::meta_dek_for(self.kek.as_deref());
for entry in &cat.tables {
if !matches!(entry.state, TableState::Live) {
continue;
}
let tdir = self.root.join(TABLES_DIR).join(entry.table_id.to_string());
let mut err = |sev: &str, desc: String| {
issues.push(CheckIssue {
table_id: entry.table_id,
table_name: entry.name.clone(),
severity: sev.into(),
description: desc,
});
};
let m = match crate::manifest::read(&tdir, manifest_meta_dek.as_ref()) {
Ok(m) => m,
Err(e) => {
err("error", format!("manifest read failed: {e}"));
continue;
}
};
if m.flushed_epoch > m.current_epoch {
err(
"error",
format!(
"flushed_epoch {} exceeds current_epoch {} (impossible)",
m.flushed_epoch, m.current_epoch
),
);
}
let runs_dir = tdir.join(crate::engine::RUNS_DIR);
let mut referenced: std::collections::HashSet<u128> = std::collections::HashSet::new();
for rr in &m.runs {
referenced.insert(rr.run_id);
let run_path = runs_dir.join(format!("r-{}.sr", rr.run_id));
if !run_path.exists() {
err("error", format!("missing run file: r-{}.sr", rr.run_id));
continue;
}
match crate::sorted_run::RunReader::open(
&run_path,
entry.schema.clone(),
self.kek.clone(),
) {
Ok(reader) => {
if reader.row_count() as u64 != rr.row_count {
err(
"error",
format!(
"run r-{} row count mismatch: manifest {} vs run {}",
rr.run_id,
rr.row_count,
reader.row_count()
),
);
}
}
Err(e) => {
err(
"error",
format!("run r-{} integrity check failed: {e}", rr.run_id),
);
}
}
}
for r in &m.retiring {
referenced.insert(r.run_id);
}
if let Ok(rd) = std::fs::read_dir(&runs_dir) {
for ent in rd.flatten() {
let p = ent.path();
if p.extension().and_then(|s| s.to_str()) != Some("sr") {
continue;
}
let run_id = p
.file_stem()
.and_then(|s| s.to_str())
.and_then(|s| s.strip_prefix("r-"))
.and_then(|s| s.parse::<u128>().ok());
if let Some(id) = run_id {
if !referenced.contains(&id) {
err(
"warning",
format!("orphan run file r-{id}.sr not referenced by the manifest"),
);
}
}
}
}
}
let external_names = cat
.external_tables
.iter()
.map(|entry| entry.name.clone())
.collect::<std::collections::HashSet<_>>();
let vtab_dir = self.root.join(VTAB_DIR);
if let Ok(entries) = std::fs::read_dir(&vtab_dir) {
for entry in entries.flatten() {
let name = entry.file_name();
let Some(name) = name.to_str() else { continue };
if !external_names.contains(name) {
issues.push(CheckIssue {
table_id: EXTERNAL_TABLE_ID,
table_name: name.to_string(),
severity: "warning".into(),
description: format!(
"orphan external table state entry {:?} not referenced by the catalog",
entry.path()
),
});
}
}
}
for (seg, msg) in self.shared_wal.lock().verify_segments() {
issues.push(CheckIssue {
table_id: WAL_TABLE_ID,
table_name: "<wal>".into(),
severity: "error".into(),
description: format!("WAL segment seg-{seg:06}.wal failed integrity check: {msg}"),
});
}
issues
}
pub fn doctor(&self) -> Result<Vec<u64>> {
let _ddl = self.ddl_lock.lock();
let issues = self.check();
let bad_tables: std::collections::HashSet<u64> = issues
.iter()
.filter(|i| {
i.severity == "error"
&& i.table_id != WAL_TABLE_ID
&& i.table_id != EXTERNAL_TABLE_ID
})
.map(|i| i.table_id)
.collect();
if bad_tables.is_empty() {
return Ok(Vec::new());
}
let qdir = self.root.join("_quarantine");
std::fs::create_dir_all(&qdir)?;
let mut quarantined = Vec::new();
for &table_id in &bad_tables {
let tdir = self.root.join(TABLES_DIR).join(table_id.to_string());
if tdir.exists() {
let dest = qdir.join(table_id.to_string());
std::fs::rename(&tdir, &dest)?;
}
{
let mut cat = self.catalog.write();
if let Some(entry) = cat.tables.iter_mut().find(|t| t.table_id == table_id) {
entry.state = TableState::Dropped {
at_epoch: self.epoch.visible().0,
};
}
}
self.tables.write().remove(&table_id);
quarantined.push(table_id);
}
catalog::write_atomic(&self.root, &self.catalog.read(), self.meta_dek.as_ref())?;
Ok(quarantined)
}
#[allow(dead_code)]
pub(crate) fn kek(&self) -> Option<&Arc<crate::encryption::Kek>> {
self.kek.as_ref()
}
#[allow(dead_code)]
pub(crate) fn epoch_authority(&self) -> &Arc<EpochAuthority> {
&self.epoch
}
#[allow(dead_code)]
pub(crate) fn snapshots(&self) -> &Arc<SnapshotRegistry> {
&self.snapshots
}
}
fn external_state_dir(root: &Path, name: &str) -> PathBuf {
root.join(VTAB_DIR).join(name)
}
fn filter_ignored_staging(
staging: Vec<(u64, crate::txn::Staged)>,
ignored_indices: &std::collections::BTreeSet<usize>,
) -> Vec<(u64, crate::txn::Staged)> {
if ignored_indices.is_empty() {
return staging;
}
staging
.into_iter()
.enumerate()
.filter_map(|(idx, staged)| (!ignored_indices.contains(&idx)).then_some(staged))
.collect()
}
fn external_state_file(root: &Path, name: &str) -> PathBuf {
external_state_dir(root, name).join("state.json")
}
fn read_external_state_file(root: &Path, name: &str) -> Result<Vec<u8>> {
let path = external_state_file(root, name);
match std::fs::read(path) {
Ok(bytes) => Ok(bytes),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()),
Err(e) => Err(e.into()),
}
}
fn current_external_state_bytes(
root: &Path,
external_states: &[(String, Vec<u8>)],
name: &str,
) -> Result<Vec<u8>> {
for (table, state) in external_states.iter().rev() {
if table == name {
return Ok(state.clone());
}
}
read_external_state_file(root, name)
}
fn dedup_external_states(external_states: Vec<(String, Vec<u8>)>) -> Vec<(String, Vec<u8>)> {
let mut out = external_states;
dedup_external_states_in_place(&mut out);
out
}
fn dedup_external_states_in_place(external_states: &mut Vec<(String, Vec<u8>)>) {
let mut seen = std::collections::HashSet::new();
let mut out = Vec::with_capacity(external_states.len());
for (name, state) in std::mem::take(external_states).into_iter().rev() {
if seen.insert(name.clone()) {
out.push((name, state));
}
}
out.reverse();
*external_states = out;
}
fn prepare_external_state_file(
root: &Path,
name: &str,
state: &[u8],
txn_id: u64,
) -> Result<PathBuf> {
let dir = external_state_dir(root, name);
std::fs::create_dir_all(&dir)?;
let pending = dir.join(format!("state.json.{txn_id}.tmp"));
{
let mut file = std::fs::File::create(&pending)?;
file.write_all(state)?;
file.sync_all()?;
}
Ok(pending)
}
fn publish_external_state_file(root: &Path, name: &str, pending: &Path) -> Result<()> {
let path = external_state_file(root, name);
std::fs::rename(pending, &path)?;
if let Ok(dir) = std::fs::File::open(external_state_dir(root, name)) {
let _ = dir.sync_all();
}
Ok(())
}
fn write_external_state_file(root: &Path, name: &str, state: &[u8]) -> Result<()> {
let pending = prepare_external_state_file(root, name, state, 0)?;
publish_external_state_file(root, name, &pending)
}
fn recover_shared_wal(
root: &Path,
tables: &HashMap<u64, TableHandle>,
epoch: &EpochAuthority,
wal_dek: Option<&zeroize::Zeroizing<[u8; 32]>>,
) -> Result<()> {
use crate::memtable::Row;
use crate::rowid::RowId;
use crate::wal::{Op, SharedWal};
let records = SharedWal::replay_with_dek(root, wal_dek)?;
let mut committed: HashMap<u64, u64> = HashMap::new();
let mut spilled_to_link: Vec<(
u64, /*txn_id*/
u64, /*epoch*/
Vec<crate::wal::AddedRun>,
)> = Vec::new();
for r in &records {
if let Op::TxnCommit {
epoch: ce,
ref added_runs,
} = r.op
{
committed.insert(r.txn_id, ce);
if !added_runs.is_empty() {
spilled_to_link.push((r.txn_id, ce, added_runs.clone()));
}
}
}
type TableStage = (Vec<Row>, Vec<(RowId, Epoch)>, Option<Epoch>, Epoch);
let mut stage: HashMap<u64, TableStage> = HashMap::new();
let mut max_epoch = epoch.visible().0;
for r in records {
let Some(&ce) = committed.get(&r.txn_id) else {
continue; };
let commit_epoch = Epoch(ce);
max_epoch = max_epoch.max(ce);
match r.op {
Op::Put { table_id, rows } => {
let skip = tables
.get(&table_id)
.map(|h| h.lock().flushed_epoch() >= ce)
.unwrap_or(true);
if skip {
continue;
}
let rows: Vec<Row> = match bincode::deserialize(&rows) {
Ok(v) => v,
Err(_) => continue,
};
let rows: Vec<Row> = rows
.into_iter()
.map(|mut row| {
row.committed_epoch = commit_epoch;
row
})
.collect();
let entry = stage
.entry(table_id)
.or_insert_with(|| (Vec::new(), Vec::new(), None, commit_epoch));
entry.0.extend(rows);
entry.3 = commit_epoch;
}
Op::Delete { table_id, row_ids } => {
let skip = tables
.get(&table_id)
.map(|h| h.lock().flushed_epoch() >= ce)
.unwrap_or(true);
if skip {
continue;
}
let dels = row_ids.into_iter().map(|rid| (rid, commit_epoch));
let entry = stage
.entry(table_id)
.or_insert_with(|| (Vec::new(), Vec::new(), None, commit_epoch));
entry.1.extend(dels);
entry.3 = commit_epoch;
}
Op::TruncateTable { table_id } => {
let skip = tables
.get(&table_id)
.map(|h| h.lock().flushed_epoch() >= ce)
.unwrap_or(true);
if skip {
continue;
}
stage.insert(
table_id,
(Vec::new(), Vec::new(), Some(commit_epoch), commit_epoch),
);
}
Op::ExternalTableState { name, state } => {
write_external_state_file(root, &name, &state)?;
}
Op::Flush { .. } | Op::TxnCommit { .. } | Op::TxnAbort | Op::Ddl(_) => {}
}
}
for (table_id, (rows, deletes, truncate_epoch, table_epoch)) in stage {
let Some(handle) = tables.get(&table_id) else {
continue;
};
let mut t = handle.lock();
if let Some(epoch) = truncate_epoch {
t.apply_truncate(epoch)?;
}
t.recover_apply(rows, deletes)?;
if truncate_epoch.is_some() {
let rows = t.visible_rows(Snapshot::at(Epoch(u64::MAX)))?;
t.live_count = rows.len() as u64;
t.persist_manifest(table_epoch)?;
}
}
for (txn_id, ce, added_runs) in &spilled_to_link {
for ar in added_runs {
let Some(handle) = tables.get(&ar.table_id) else {
continue;
};
let mut t = handle.lock();
let dest = t.run_path(ar.run_id as u64);
if !dest.exists() {
let pending = root
.join(TABLES_DIR)
.join(ar.table_id.to_string())
.join("_txn")
.join(txn_id.to_string())
.join(format!("r-{}.sr", ar.run_id));
if pending.exists() {
if let Some(parent) = pending.parent() {
std::fs::rename(&pending, &dest)?;
let _ = std::fs::remove_dir_all(parent);
}
}
}
if t.run_path(ar.run_id as u64).exists() {
t.recover_spilled_run(crate::manifest::RunRef {
run_id: ar.run_id,
level: ar.level,
epoch_created: *ce,
row_count: ar.row_count,
});
}
}
}
epoch.advance_recovered(Epoch(max_epoch));
Ok(())
}
fn validate_condition_columns(condition: &ProcedureCondition, schema: &Schema) -> Result<()> {
match condition {
ProcedureCondition::Pk { .. } => {
if schema.primary_key().is_none() {
return Err(MongrelError::InvalidArgument(
"procedure condition Pk references a table without a primary key".into(),
));
}
}
ProcedureCondition::BitmapEq { column_id, .. }
| ProcedureCondition::BitmapIn { column_id, .. }
| ProcedureCondition::Range { column_id, .. }
| ProcedureCondition::RangeF64 { column_id, .. }
| ProcedureCondition::IsNull { column_id }
| ProcedureCondition::IsNotNull { column_id }
| ProcedureCondition::FmContains { column_id, .. } => {
validate_column_id(*column_id, schema)?;
}
}
Ok(())
}
fn bind_procedure_args(
procedure: &StoredProcedure,
mut args: HashMap<String, crate::Value>,
) -> Result<HashMap<String, crate::Value>> {
let mut out = HashMap::new();
for param in &procedure.params {
let value = match args.remove(¶m.name) {
Some(value) => value,
None => param.default.clone().ok_or_else(|| {
MongrelError::InvalidArgument(format!(
"missing required procedure parameter {:?}",
param.name
))
})?,
};
if !param.nullable && matches!(value, crate::Value::Null) {
return Err(MongrelError::InvalidArgument(format!(
"procedure parameter {:?} must not be NULL",
param.name
)));
}
if !matches!(value, crate::Value::Null) && !value_matches_type(&value, param.ty) {
return Err(MongrelError::InvalidArgument(format!(
"procedure parameter {:?} has wrong type",
param.name
)));
}
out.insert(param.name.clone(), value);
}
if let Some(extra) = args.keys().next() {
return Err(MongrelError::InvalidArgument(format!(
"unknown procedure parameter {extra:?}"
)));
}
Ok(out)
}
fn value_matches_type(value: &crate::Value, ty: crate::TypeId) -> bool {
matches!(
(value, ty),
(crate::Value::Bool(_), crate::TypeId::Bool)
| (crate::Value::Int64(_), crate::TypeId::Int8)
| (crate::Value::Int64(_), crate::TypeId::Int16)
| (crate::Value::Int64(_), crate::TypeId::Int32)
| (crate::Value::Int64(_), crate::TypeId::Int64)
| (crate::Value::Int64(_), crate::TypeId::UInt8)
| (crate::Value::Int64(_), crate::TypeId::UInt16)
| (crate::Value::Int64(_), crate::TypeId::UInt32)
| (crate::Value::Int64(_), crate::TypeId::UInt64)
| (crate::Value::Int64(_), crate::TypeId::TimestampNanos)
| (crate::Value::Int64(_), crate::TypeId::Date32)
| (crate::Value::Float64(_), crate::TypeId::Float32)
| (crate::Value::Float64(_), crate::TypeId::Float64)
| (crate::Value::Bytes(_), crate::TypeId::Bytes)
| (crate::Value::Embedding(_), crate::TypeId::Embedding { .. })
)
}
fn eval_cells(
cells: &[crate::procedure::ProcedureCell],
args: &HashMap<String, crate::Value>,
outputs: &HashMap<String, ProcedureCallOutput>,
) -> Result<Vec<(u16, crate::Value)>> {
cells
.iter()
.map(|cell| Ok((cell.column_id, eval_value(&cell.value, args, outputs)?)))
.collect()
}
fn eval_condition(
condition: &ProcedureCondition,
args: &HashMap<String, crate::Value>,
outputs: &HashMap<String, ProcedureCallOutput>,
) -> Result<crate::Condition> {
Ok(match condition {
ProcedureCondition::Pk { value } => {
crate::Condition::Pk(eval_value(value, args, outputs)?.encode_key())
}
ProcedureCondition::BitmapEq { column_id, value } => crate::Condition::BitmapEq {
column_id: *column_id,
value: eval_value(value, args, outputs)?.encode_key(),
},
ProcedureCondition::BitmapIn { column_id, values } => crate::Condition::BitmapIn {
column_id: *column_id,
values: values
.iter()
.map(|value| Ok(eval_value(value, args, outputs)?.encode_key()))
.collect::<Result<Vec<_>>>()?,
},
ProcedureCondition::Range { column_id, lo, hi } => crate::Condition::Range {
column_id: *column_id,
lo: expect_i64(eval_value(lo, args, outputs)?)?,
hi: expect_i64(eval_value(hi, args, outputs)?)?,
},
ProcedureCondition::RangeF64 {
column_id,
lo,
lo_inclusive,
hi,
hi_inclusive,
} => crate::Condition::RangeF64 {
column_id: *column_id,
lo: expect_f64(eval_value(lo, args, outputs)?)?,
lo_inclusive: *lo_inclusive,
hi: expect_f64(eval_value(hi, args, outputs)?)?,
hi_inclusive: *hi_inclusive,
},
ProcedureCondition::IsNull { column_id } => crate::Condition::IsNull {
column_id: *column_id,
},
ProcedureCondition::IsNotNull { column_id } => crate::Condition::IsNotNull {
column_id: *column_id,
},
ProcedureCondition::FmContains { column_id, pattern } => crate::Condition::FmContains {
column_id: *column_id,
pattern: expect_bytes(eval_value(pattern, args, outputs)?)?,
},
})
}
fn eval_value(
value: &ProcedureValue,
args: &HashMap<String, crate::Value>,
outputs: &HashMap<String, ProcedureCallOutput>,
) -> Result<crate::Value> {
match value {
ProcedureValue::Literal(value) => Ok(value.clone()),
ProcedureValue::Param(name) => args.get(name).cloned().ok_or_else(|| {
MongrelError::InvalidArgument(format!("unknown procedure parameter {name:?}"))
}),
ProcedureValue::StepScalar(id) => match outputs.get(id) {
Some(ProcedureCallOutput::Scalar(value)) => Ok(value.clone()),
_ => Err(MongrelError::InvalidArgument(format!(
"procedure step {id:?} did not return a scalar"
))),
},
ProcedureValue::StepRows(_) | ProcedureValue::StepRow(_) => {
Err(MongrelError::InvalidArgument(
"row-valued procedure reference cannot be used as a scalar".into(),
))
}
ProcedureValue::Object(_) | ProcedureValue::Array(_) => Err(MongrelError::InvalidArgument(
"structured procedure value cannot be used as a scalar cell".into(),
)),
}
}
fn eval_return_output(
value: &ProcedureValue,
args: &HashMap<String, crate::Value>,
outputs: &HashMap<String, ProcedureCallOutput>,
) -> Result<ProcedureCallOutput> {
match value {
ProcedureValue::Literal(value) => Ok(ProcedureCallOutput::Scalar(value.clone())),
ProcedureValue::Param(name) => Ok(ProcedureCallOutput::Scalar(
args.get(name).cloned().ok_or_else(|| {
MongrelError::InvalidArgument(format!("unknown procedure parameter {name:?}"))
})?,
)),
ProcedureValue::StepRows(id)
| ProcedureValue::StepRow(id)
| ProcedureValue::StepScalar(id) => outputs.get(id).cloned().ok_or_else(|| {
MongrelError::InvalidArgument(format!("unknown procedure step output {id:?}"))
}),
ProcedureValue::Object(fields) => {
let mut out = Vec::with_capacity(fields.len());
for (name, value) in fields {
out.push((name.clone(), eval_return_output(value, args, outputs)?));
}
Ok(ProcedureCallOutput::Object(out))
}
ProcedureValue::Array(values) => {
let mut out = Vec::with_capacity(values.len());
for value in values {
out.push(eval_return_output(value, args, outputs)?);
}
Ok(ProcedureCallOutput::Array(out))
}
}
}
fn expect_i64(value: crate::Value) -> Result<i64> {
match value {
crate::Value::Int64(value) => Ok(value),
_ => Err(MongrelError::InvalidArgument(
"procedure value must be Int64".into(),
)),
}
}
fn expect_f64(value: crate::Value) -> Result<f64> {
match value {
crate::Value::Float64(value) => Ok(value),
_ => Err(MongrelError::InvalidArgument(
"procedure value must be Float64".into(),
)),
}
}
fn expect_bytes(value: crate::Value) -> Result<Vec<u8>> {
match value {
crate::Value::Bytes(value) => Ok(value),
_ => Err(MongrelError::InvalidArgument(
"procedure value must be Bytes".into(),
)),
}
}
fn validate_column_id(column_id: u16, schema: &Schema) -> Result<()> {
if schema.columns.iter().any(|c| c.id == column_id) {
Ok(())
} else {
Err(MongrelError::InvalidArgument(format!(
"unknown column id {column_id}"
)))
}
}
fn trigger_matches_event(
trigger: &StoredTrigger,
event: &WriteEvent,
cat: &Catalog,
) -> Result<bool> {
if trigger.event != event.kind {
return Ok(false);
}
let TriggerTarget::Table(target) = &trigger.target else {
return Ok(false);
};
if target != &event.table {
return Ok(false);
}
if trigger.event == TriggerEvent::Update && !trigger.update_of.is_empty() {
let schema = &cat
.live(target)
.ok_or_else(|| {
MongrelError::InvalidArgument(format!(
"trigger {:?} references unknown table {target:?}",
trigger.name
))
})?
.schema;
let mut watched = Vec::with_capacity(trigger.update_of.len());
for name in &trigger.update_of {
let col = schema.column(name).ok_or_else(|| {
MongrelError::InvalidArgument(format!(
"trigger {:?} references unknown UPDATE OF column {name:?}",
trigger.name
))
})?;
watched.push(col.id);
}
if !event
.changed_columns
.iter()
.any(|column_id| watched.contains(column_id))
{
return Ok(false);
}
}
Ok(true)
}
fn changed_columns(old: Option<&TriggerRowImage>, new: Option<&TriggerRowImage>) -> Vec<u16> {
let mut ids = std::collections::BTreeSet::new();
if let Some(old) = old {
ids.extend(old.columns.keys().copied());
}
if let Some(new) = new {
ids.extend(new.columns.keys().copied());
}
ids.into_iter()
.filter(|id| {
old.and_then(|row| row.columns.get(id)) != new.and_then(|row| row.columns.get(id))
})
.collect()
}
fn eval_trigger_cells(
cells: &[crate::trigger::TriggerCell],
event: &WriteEvent,
selected: Option<&TriggerRowImage>,
) -> Result<Vec<(u16, Value)>> {
cells
.iter()
.map(|cell| {
Ok((
cell.column_id,
eval_trigger_value(&cell.value, event, selected)?,
))
})
.collect()
}
fn eval_trigger_expr(expr: &TriggerExpr, event: &WriteEvent) -> Result<bool> {
match expr {
TriggerExpr::Value(value) => match eval_trigger_value(value, event, None)? {
Value::Bool(value) => Ok(value),
Value::Null => Ok(false),
other => Err(MongrelError::InvalidArgument(format!(
"trigger WHEN value must be boolean, got {other:?}"
))),
},
TriggerExpr::Eq { left, right } => Ok(values_equal(
&eval_trigger_value(left, event, None)?,
&eval_trigger_value(right, event, None)?,
)),
TriggerExpr::NotEq { left, right } => Ok(!values_equal(
&eval_trigger_value(left, event, None)?,
&eval_trigger_value(right, event, None)?,
)),
TriggerExpr::Lt { left, right } => match value_order(
&eval_trigger_value(left, event, None)?,
&eval_trigger_value(right, event, None)?,
) {
Some(ordering) => Ok(ordering == std::cmp::Ordering::Less),
None => Ok(false),
},
TriggerExpr::Lte { left, right } => match value_order(
&eval_trigger_value(left, event, None)?,
&eval_trigger_value(right, event, None)?,
) {
Some(ordering) => Ok(ordering != std::cmp::Ordering::Greater),
None => Ok(false),
},
TriggerExpr::Gt { left, right } => match value_order(
&eval_trigger_value(left, event, None)?,
&eval_trigger_value(right, event, None)?,
) {
Some(ordering) => Ok(ordering == std::cmp::Ordering::Greater),
None => Ok(false),
},
TriggerExpr::Gte { left, right } => match value_order(
&eval_trigger_value(left, event, None)?,
&eval_trigger_value(right, event, None)?,
) {
Some(ordering) => Ok(ordering != std::cmp::Ordering::Less),
None => Ok(false),
},
TriggerExpr::IsNull(value) => Ok(matches!(
eval_trigger_value(value, event, None)?,
Value::Null
)),
TriggerExpr::IsNotNull(value) => Ok(!matches!(
eval_trigger_value(value, event, None)?,
Value::Null
)),
TriggerExpr::And { left, right } => {
if !eval_trigger_expr(left, event)? {
Ok(false)
} else {
Ok(eval_trigger_expr(right, event)?)
}
}
TriggerExpr::Or { left, right } => {
if eval_trigger_expr(left, event)? {
Ok(true)
} else {
Ok(eval_trigger_expr(right, event)?)
}
}
TriggerExpr::Not(expr) => Ok(!eval_trigger_expr(expr, event)?),
}
}
fn eval_trigger_condition(
condition: &TriggerCondition,
event: &WriteEvent,
selected: &TriggerRowImage,
schema: &Schema,
) -> Result<bool> {
match condition {
TriggerCondition::Pk { value } => {
let pk = schema.primary_key().ok_or_else(|| {
MongrelError::InvalidArgument(
"trigger condition Pk references a table without a primary key".into(),
)
})?;
let lhs = eval_trigger_value(value, event, Some(selected))?;
Ok(values_equal(
&lhs,
selected.columns.get(&pk.id).unwrap_or(&Value::Null),
))
}
TriggerCondition::Eq { column_id, value } => Ok(values_equal(
selected.columns.get(column_id).unwrap_or(&Value::Null),
&eval_trigger_value(value, event, Some(selected))?,
)),
TriggerCondition::NotEq { column_id, value } => Ok(!values_equal(
selected.columns.get(column_id).unwrap_or(&Value::Null),
&eval_trigger_value(value, event, Some(selected))?,
)),
TriggerCondition::Lt { column_id, value } => match value_order(
selected.columns.get(column_id).unwrap_or(&Value::Null),
&eval_trigger_value(value, event, Some(selected))?,
) {
Some(ordering) => Ok(ordering == std::cmp::Ordering::Less),
None => Ok(false),
},
TriggerCondition::Lte { column_id, value } => match value_order(
selected.columns.get(column_id).unwrap_or(&Value::Null),
&eval_trigger_value(value, event, Some(selected))?,
) {
Some(ordering) => Ok(ordering != std::cmp::Ordering::Greater),
None => Ok(false),
},
TriggerCondition::Gt { column_id, value } => match value_order(
selected.columns.get(column_id).unwrap_or(&Value::Null),
&eval_trigger_value(value, event, Some(selected))?,
) {
Some(ordering) => Ok(ordering == std::cmp::Ordering::Greater),
None => Ok(false),
},
TriggerCondition::Gte { column_id, value } => match value_order(
selected.columns.get(column_id).unwrap_or(&Value::Null),
&eval_trigger_value(value, event, Some(selected))?,
) {
Some(ordering) => Ok(ordering != std::cmp::Ordering::Less),
None => Ok(false),
},
TriggerCondition::IsNull { column_id } => Ok(matches!(
selected.columns.get(column_id),
None | Some(Value::Null)
)),
TriggerCondition::IsNotNull { column_id } => Ok(!matches!(
selected.columns.get(column_id),
None | Some(Value::Null)
)),
TriggerCondition::And { left, right } => {
if !eval_trigger_condition(left, event, selected, schema)? {
Ok(false)
} else {
Ok(eval_trigger_condition(right, event, selected, schema)?)
}
}
TriggerCondition::Or { left, right } => {
if eval_trigger_condition(left, event, selected, schema)? {
Ok(true)
} else {
Ok(eval_trigger_condition(right, event, selected, schema)?)
}
}
TriggerCondition::Not(condition) => {
Ok(!eval_trigger_condition(condition, event, selected, schema)?)
}
}
}
fn eval_trigger_value(
value: &TriggerValue,
event: &WriteEvent,
selected: Option<&TriggerRowImage>,
) -> Result<Value> {
match value {
TriggerValue::Literal(value) => Ok(value.clone()),
TriggerValue::NewColumn(column_id) => event
.new
.as_ref()
.and_then(|row| row.columns.get(column_id))
.cloned()
.ok_or_else(|| MongrelError::InvalidArgument("NEW column is not available".into())),
TriggerValue::OldColumn(column_id) => event
.old
.as_ref()
.and_then(|row| row.columns.get(column_id))
.cloned()
.ok_or_else(|| MongrelError::InvalidArgument("OLD column is not available".into())),
TriggerValue::SelectedColumn(column_id) => selected
.and_then(|row| row.columns.get(column_id))
.cloned()
.ok_or_else(|| {
MongrelError::InvalidArgument("SELECTED column is not available".into())
}),
}
}
fn values_equal(left: &Value, right: &Value) -> bool {
match (left, right) {
(Value::Null, Value::Null) => true,
(Value::Bool(a), Value::Bool(b)) => a == b,
(Value::Int64(a), Value::Int64(b)) => a == b,
(Value::Float64(a), Value::Float64(b)) => a.to_bits() == b.to_bits(),
(Value::Bytes(a), Value::Bytes(b)) => a == b,
(Value::Embedding(a), Value::Embedding(b)) => {
a.len() == b.len()
&& a.iter()
.zip(b.iter())
.all(|(a, b)| a.to_bits() == b.to_bits())
}
_ => false,
}
}
fn value_order(left: &Value, right: &Value) -> Option<std::cmp::Ordering> {
match (left, right) {
(Value::Null, _) | (_, Value::Null) => None,
(Value::Bool(a), Value::Bool(b)) => Some(a.cmp(b)),
(Value::Int64(a), Value::Int64(b)) => Some(a.cmp(b)),
(Value::Int64(a), Value::Float64(b)) => {
let af = *a as f64;
Some(af.total_cmp(b))
}
(Value::Float64(a), Value::Int64(b)) => {
let bf = *b as f64;
Some(a.total_cmp(&bf))
}
(Value::Float64(a), Value::Float64(b)) => Some(a.total_cmp(b)),
(Value::Bytes(a), Value::Bytes(b)) => Some(a.cmp(b)),
(Value::Embedding(_), Value::Embedding(_)) => None,
_ => None,
}
}
fn trigger_message(value: Value) -> String {
match value {
Value::Null => "NULL".into(),
Value::Bool(value) => value.to_string(),
Value::Int64(value) => value.to_string(),
Value::Float64(value) => value.to_string(),
Value::Bytes(value) => String::from_utf8_lossy(&value).into_owned(),
Value::Embedding(value) => format!("{value:?}"),
Value::Decimal(value) => value.to_string(),
Value::Interval {
months,
days,
nanos,
} => format!("{months}m {days}d {nanos}ns"),
}
}
fn validate_trigger_step<'a>(
step: &TriggerStep,
cat: &'a Catalog,
target_schema: &Schema,
event: TriggerEvent,
select_schemas: &mut HashMap<String, &'a Schema>,
) -> Result<()> {
match step {
TriggerStep::SetNew { cells } => {
if event == TriggerEvent::Delete {
return Err(MongrelError::InvalidArgument(
"SetNew trigger step is not valid for DELETE triggers".into(),
));
}
for cell in cells {
validate_column_id(cell.column_id, target_schema)?;
validate_trigger_value(&cell.value, target_schema, event)?;
}
}
TriggerStep::Insert { table, cells } => {
let schema = trigger_write_schema(cat, table, "insert")?;
for cell in cells {
validate_column_id(cell.column_id, schema)?;
validate_trigger_value(&cell.value, target_schema, event)?;
}
}
TriggerStep::UpdateByPk { table, pk, cells } => {
let schema = trigger_write_schema(cat, table, "update")?;
if schema.primary_key().is_none() {
return Err(MongrelError::InvalidArgument(format!(
"trigger update_by_pk references table {table:?} without a primary key"
)));
}
validate_trigger_value(pk, target_schema, event)?;
for cell in cells {
validate_column_id(cell.column_id, schema)?;
validate_trigger_value(&cell.value, target_schema, event)?;
}
}
TriggerStep::DeleteByPk { table, pk } => {
let schema = trigger_write_schema(cat, table, "delete")?;
if schema.primary_key().is_none() {
return Err(MongrelError::InvalidArgument(format!(
"trigger delete_by_pk references table {table:?} without a primary key"
)));
}
validate_trigger_value(pk, target_schema, event)?;
}
TriggerStep::Select {
id,
table,
conditions,
} => {
let schema = trigger_read_schema(cat, table)?;
for condition in conditions {
validate_trigger_condition(condition, schema, target_schema, event)?;
}
if select_schemas.contains_key(id) {
return Err(MongrelError::InvalidArgument(format!(
"duplicate select id {id:?} in trigger program"
)));
}
select_schemas.insert(id.clone(), schema);
}
TriggerStep::Foreach { id, steps } => {
if !select_schemas.contains_key(id) {
return Err(MongrelError::InvalidArgument(format!(
"foreach references unknown select id {id:?}"
)));
}
let mut inner_select_schemas = select_schemas.clone();
for step in steps {
validate_trigger_step(step, cat, target_schema, event, &mut inner_select_schemas)?;
}
}
TriggerStep::DeleteWhere { table, conditions } => {
let schema = trigger_write_schema(cat, table, "delete")?;
for condition in conditions {
validate_trigger_condition(condition, schema, target_schema, event)?;
}
}
TriggerStep::UpdateWhere {
table,
conditions,
cells,
} => {
let schema = trigger_write_schema(cat, table, "update")?;
for condition in conditions {
validate_trigger_condition(condition, schema, target_schema, event)?;
}
for cell in cells {
validate_column_id(cell.column_id, schema)?;
validate_trigger_value(&cell.value, target_schema, event)?;
}
}
TriggerStep::Raise { message, .. } => {
validate_trigger_value(message, target_schema, event)?
}
}
Ok(())
}
fn trigger_write_schema<'a>(cat: &'a Catalog, table: &str, op: &str) -> Result<&'a Schema> {
if let Some(entry) = cat.live(table) {
return Ok(&entry.schema);
}
if let Some(entry) = cat.external_tables.iter().find(|entry| entry.name == table) {
let allowed = match op {
"insert" => entry.capabilities.writable || entry.capabilities.insert_only,
"update" | "delete" => entry.capabilities.writable,
_ => false,
};
if !allowed {
return Err(MongrelError::InvalidArgument(format!(
"trigger {op} references external table {table:?}, but module {:?} is not writable for that operation",
entry.module
)));
}
if !entry.capabilities.transaction_safe {
return Err(MongrelError::InvalidArgument(format!(
"trigger {op} references external table {table:?}, but module {:?} is not transaction-safe",
entry.module
)));
}
return Ok(&entry.declared_schema);
}
Err(MongrelError::InvalidArgument(format!(
"trigger references unknown table {table:?}"
)))
}
fn trigger_read_schema<'a>(cat: &'a Catalog, table: &str) -> Result<&'a Schema> {
if let Some(entry) = cat.live(table) {
return Ok(&entry.schema);
}
if let Some(entry) = cat.external_tables.iter().find(|entry| entry.name == table) {
if entry.capabilities.trigger_safe {
return Ok(&entry.declared_schema);
}
return Err(MongrelError::InvalidArgument(format!(
"trigger reads external table {table:?}, but module {:?} is not trigger-safe",
entry.module
)));
}
Err(MongrelError::InvalidArgument(format!(
"trigger references unknown table {table:?}"
)))
}
fn validate_trigger_condition(
condition: &TriggerCondition,
schema: &Schema,
target_schema: &Schema,
event: TriggerEvent,
) -> Result<()> {
match condition {
TriggerCondition::Pk { value } => {
if schema.primary_key().is_none() {
return Err(MongrelError::InvalidArgument(
"trigger condition Pk references a table without a primary key".into(),
));
}
validate_trigger_value(value, target_schema, event)
}
TriggerCondition::Eq { column_id, value }
| TriggerCondition::NotEq { column_id, value }
| TriggerCondition::Lt { column_id, value }
| TriggerCondition::Lte { column_id, value }
| TriggerCondition::Gt { column_id, value }
| TriggerCondition::Gte { column_id, value } => {
validate_column_id(*column_id, schema)?;
validate_trigger_value(value, target_schema, event)
}
TriggerCondition::IsNull { column_id } | TriggerCondition::IsNotNull { column_id } => {
validate_column_id(*column_id, schema)
}
TriggerCondition::And { left, right } | TriggerCondition::Or { left, right } => {
validate_trigger_condition(left, schema, target_schema, event)?;
validate_trigger_condition(right, schema, target_schema, event)
}
TriggerCondition::Not(condition) => {
validate_trigger_condition(condition, schema, target_schema, event)
}
}
}
fn validate_trigger_expr(expr: &TriggerExpr, schema: &Schema, event: TriggerEvent) -> Result<()> {
match expr {
TriggerExpr::Value(value) | TriggerExpr::IsNull(value) | TriggerExpr::IsNotNull(value) => {
validate_trigger_value(value, schema, event)
}
TriggerExpr::Eq { left, right }
| TriggerExpr::NotEq { left, right }
| TriggerExpr::Lt { left, right }
| TriggerExpr::Lte { left, right }
| TriggerExpr::Gt { left, right }
| TriggerExpr::Gte { left, right } => {
validate_trigger_value(left, schema, event)?;
validate_trigger_value(right, schema, event)
}
TriggerExpr::And { left, right } | TriggerExpr::Or { left, right } => {
validate_trigger_expr(left, schema, event)?;
validate_trigger_expr(right, schema, event)
}
TriggerExpr::Not(expr) => validate_trigger_expr(expr, schema, event),
}
}
fn validate_trigger_value(
value: &TriggerValue,
schema: &Schema,
event: TriggerEvent,
) -> Result<()> {
match value {
TriggerValue::Literal(_) => Ok(()),
TriggerValue::NewColumn(id) => {
if event == TriggerEvent::Delete {
return Err(MongrelError::InvalidArgument(
"DELETE triggers cannot reference NEW".into(),
));
}
validate_column_id(*id, schema)
}
TriggerValue::OldColumn(id) => {
if event == TriggerEvent::Insert {
return Err(MongrelError::InvalidArgument(
"INSERT triggers cannot reference OLD".into(),
));
}
validate_column_id(*id, schema)
}
TriggerValue::SelectedColumn(_) => Ok(()),
}
}
fn recover_ddl_from_wal(
root: &Path,
cat: &mut Catalog,
meta_dek: Option<&[u8; META_DEK_LEN]>,
wal_dek: Option<&zeroize::Zeroizing<[u8; 32]>>,
) -> Result<()> {
use crate::wal::{DdlOp, Op, SharedWal};
let records = match SharedWal::replay_with_dek(root, wal_dek) {
Ok(r) => r,
Err(_) => return Ok(()),
};
let mut committed: HashMap<u64, u64> = HashMap::new();
for r in &records {
if let Op::TxnCommit { epoch: ce, .. } = r.op {
committed.insert(r.txn_id, ce);
}
}
let mut changed = false;
for r in records {
let Some(&ce) = committed.get(&r.txn_id) else {
continue;
};
match r.op {
Op::Ddl(DdlOp::CreateTable {
table_id,
ref name,
ref schema_json,
}) => {
if cat.tables.iter().any(|t| t.table_id == table_id) {
continue;
}
let schema = DdlOp::decode_schema(schema_json)?;
let tdir = root.join(TABLES_DIR).join(table_id.to_string());
if !tdir.exists() {
std::fs::create_dir_all(tdir.join(crate::engine::WAL_DIR))?;
std::fs::create_dir_all(tdir.join(crate::engine::RUNS_DIR))?;
crate::engine::write_schema(&tdir, &schema)?;
let mut m = crate::manifest::Manifest::new(table_id, schema.schema_id);
crate::manifest::write_atomic(&tdir, &mut m, meta_dek)?;
}
cat.tables.push(CatalogEntry {
table_id,
name: name.clone(),
schema,
state: TableState::Live,
created_epoch: ce,
});
cat.next_table_id = cat.next_table_id.max(table_id + 1);
changed = true;
}
Op::Ddl(DdlOp::DropTable { table_id }) => {
if let Some(entry) = cat.tables.iter_mut().find(|t| t.table_id == table_id) {
if matches!(entry.state, TableState::Live) {
entry.state = TableState::Dropped { at_epoch: ce };
changed = true;
}
}
}
Op::Ddl(DdlOp::RenameTable {
table_id,
ref new_name,
}) => {
if let Some(entry) = cat.tables.iter_mut().find(|t| t.table_id == table_id) {
if entry.name != *new_name {
entry.name = new_name.clone();
changed = true;
}
}
}
Op::Ddl(DdlOp::AlterTable {
table_id,
ref column_json,
}) => {
let column = DdlOp::decode_column(column_json)?;
if let Some(entry) = cat.tables.iter_mut().find(|t| t.table_id == table_id) {
if apply_recovered_column_def(&mut entry.schema, column) {
let tdir = root.join(TABLES_DIR).join(table_id.to_string());
if tdir.exists() {
crate::engine::write_schema(&tdir, &entry.schema)?;
}
changed = true;
}
}
}
_ => {}
}
}
if changed {
catalog::write_atomic(root, cat, meta_dek)?;
}
Ok(())
}
fn apply_recovered_column_def(schema: &mut Schema, column: ColumnDef) -> bool {
match schema.columns.iter_mut().find(|c| c.id == column.id) {
Some(existing) if *existing == column => false,
Some(existing) => {
*existing = column;
schema.schema_id = schema.schema_id.saturating_add(1);
true
}
None => {
schema.columns.push(column);
schema.schema_id = schema.schema_id.saturating_add(1);
true
}
}
}
fn sweep_pending_txn_dirs(root: &Path, cat: &Catalog) {
for entry in &cat.tables {
let txn_dir = root
.join(TABLES_DIR)
.join(entry.table_id.to_string())
.join("_txn");
if txn_dir.exists() {
let _ = std::fs::remove_dir_all(&txn_dir);
}
}
}
#[cfg(test)]
mod trigger_engine_tests {
use super::*;
fn event_with(new_cells: &[(u16, Value)], old_cells: &[(u16, Value)]) -> WriteEvent {
WriteEvent {
table: "test".into(),
kind: TriggerEvent::Insert,
new: Some(TriggerRowImage {
columns: new_cells.iter().cloned().collect(),
}),
old: Some(TriggerRowImage {
columns: old_cells.iter().cloned().collect(),
}),
changed_columns: Vec::new(),
op_indices: Vec::new(),
put_idx: None,
trigger_stack: Vec::new(),
}
}
fn event_insert(new_cells: &[(u16, Value)]) -> WriteEvent {
WriteEvent {
table: "test".into(),
kind: TriggerEvent::Insert,
new: Some(TriggerRowImage {
columns: new_cells.iter().cloned().collect(),
}),
old: None,
changed_columns: Vec::new(),
op_indices: Vec::new(),
put_idx: None,
trigger_stack: Vec::new(),
}
}
#[test]
fn value_order_int64_vs_float64() {
assert_eq!(
value_order(&Value::Int64(5), &Value::Float64(5.0)),
Some(std::cmp::Ordering::Equal)
);
assert_eq!(
value_order(&Value::Int64(5), &Value::Float64(3.0)),
Some(std::cmp::Ordering::Greater)
);
assert_eq!(
value_order(&Value::Int64(2), &Value::Float64(3.0)),
Some(std::cmp::Ordering::Less)
);
}
#[test]
fn value_order_null_returns_none() {
assert_eq!(value_order(&Value::Int64(5), &Value::Null), None);
assert_eq!(value_order(&Value::Null, &Value::Int64(5)), None);
assert_eq!(value_order(&Value::Null, &Value::Null), None);
}
#[test]
fn value_order_cross_group_returns_none() {
assert_eq!(
value_order(&Value::Int64(5), &Value::Bytes(b"x".to_vec())),
None
);
assert_eq!(value_order(&Value::Bool(true), &Value::Int64(1)), None);
assert_eq!(
value_order(
&Value::Embedding(vec![1.0, 2.0]),
&Value::Embedding(vec![1.0, 2.0])
),
None
);
}
#[test]
fn eval_trigger_expr_ranges_and_booleans() {
let expr = TriggerExpr::And {
left: Box::new(TriggerExpr::Gt {
left: TriggerValue::NewColumn(1),
right: TriggerValue::Literal(Value::Int64(0)),
}),
right: Box::new(TriggerExpr::Lte {
left: TriggerValue::NewColumn(1),
right: TriggerValue::Literal(Value::Int64(100)),
}),
};
assert!(eval_trigger_expr(&expr, &event_insert(&[(1, Value::Int64(50))])).unwrap());
assert!(!eval_trigger_expr(&expr, &event_insert(&[(1, Value::Int64(200))])).unwrap());
assert!(!eval_trigger_expr(&expr, &event_insert(&[(1, Value::Null)])).unwrap());
let or_expr = TriggerExpr::Or {
left: Box::new(TriggerExpr::Lt {
left: TriggerValue::NewColumn(1),
right: TriggerValue::Literal(Value::Int64(0)),
}),
right: Box::new(TriggerExpr::Not(Box::new(TriggerExpr::IsNull(
TriggerValue::OldColumn(2),
)))),
};
assert!(eval_trigger_expr(
&or_expr,
&event_with(&[(1, Value::Int64(5))], &[(2, Value::Int64(99))])
)
.unwrap());
assert!(!eval_trigger_expr(
&or_expr,
&event_with(&[(1, Value::Int64(5))], &[(2, Value::Null)])
)
.unwrap());
assert!(eval_trigger_expr(
&TriggerExpr::Value(TriggerValue::Literal(Value::Bool(true))),
&event_insert(&[])
)
.unwrap());
assert!(!eval_trigger_expr(
&TriggerExpr::Value(TriggerValue::Literal(Value::Bool(false))),
&event_insert(&[])
)
.unwrap());
assert!(!eval_trigger_expr(
&TriggerExpr::Value(TriggerValue::Literal(Value::Null)),
&event_insert(&[])
)
.unwrap());
}
}