use std::{
collections::HashMap,
fs::{self, File},
io::BufReader,
path::{Path, PathBuf},
};
use crate::{
persistence::binary::{read_catalog_v, read_data, read_header},
wal::{
checkpoint::{read_checkpoint_data, CheckpointInfo, CheckpointWriter},
entry::{Lsn, WalOp},
reader::{ReadResult, WalReader},
},
Database, StorageError,
};
#[derive(Debug, Clone)]
pub struct RecoveryConfig {
pub validate_checksums: bool,
pub stop_on_corruption: bool,
pub max_checkpoint_retries: usize,
pub progress_interval: usize,
}
impl Default for RecoveryConfig {
fn default() -> Self {
Self {
validate_checksums: true,
stop_on_corruption: true,
max_checkpoint_retries: 3,
progress_interval: 10000,
}
}
}
#[derive(Debug, Clone, Default)]
pub struct RecoveryStats {
pub checkpoint_lsn: Lsn,
pub entries_replayed: u64,
pub entries_skipped: u64,
pub transactions_committed: u64,
pub transactions_rolled_back: u64,
pub inserts_applied: u64,
pub updates_applied: u64,
pub deletes_applied: u64,
pub tables_created: u64,
pub indexes_created: u64,
pub corruption_detected: bool,
pub corruption_position: Option<u64>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum TransactionState {
InFlight,
Committed,
RolledBack,
}
struct TransactionTracker {
states: HashMap<u64, TransactionState>,
buffered_ops: HashMap<u64, Vec<(Lsn, WalOp)>>,
}
impl TransactionTracker {
fn new() -> Self {
Self { states: HashMap::new(), buffered_ops: HashMap::new() }
}
fn begin_transaction(&mut self, txn_id: u64) {
self.states.insert(txn_id, TransactionState::InFlight);
self.buffered_ops.insert(txn_id, Vec::new());
}
fn commit_transaction(&mut self, txn_id: u64) -> Vec<(Lsn, WalOp)> {
self.states.insert(txn_id, TransactionState::Committed);
self.buffered_ops.remove(&txn_id).unwrap_or_default()
}
fn rollback_transaction(&mut self, txn_id: u64) {
self.states.insert(txn_id, TransactionState::RolledBack);
self.buffered_ops.remove(&txn_id);
}
fn buffer_op(&mut self, txn_id: u64, lsn: Lsn, op: WalOp) {
if let Some(ops) = self.buffered_ops.get_mut(&txn_id) {
ops.push((lsn, op));
}
}
fn is_in_flight(&self, txn_id: u64) -> bool {
matches!(self.states.get(&txn_id), Some(TransactionState::InFlight))
}
fn get_in_flight_transactions(&self) -> Vec<u64> {
self.states
.iter()
.filter(|(_, state)| **state == TransactionState::InFlight)
.map(|(id, _)| *id)
.collect()
}
fn committed_count(&self) -> u64 {
self.states.values().filter(|s| **s == TransactionState::Committed).count() as u64
}
fn rolled_back_count(&self) -> u64 {
self.states
.values()
.filter(|s| **s == TransactionState::RolledBack || **s == TransactionState::InFlight)
.count() as u64
}
}
pub struct RecoveryManager {
config: RecoveryConfig,
checkpoint_dir: PathBuf,
wal_path: Option<PathBuf>,
}
impl RecoveryManager {
pub fn new<P: AsRef<Path>>(checkpoint_dir: P) -> Self {
Self {
config: RecoveryConfig::default(),
checkpoint_dir: checkpoint_dir.as_ref().to_path_buf(),
wal_path: None,
}
}
pub fn with_config<P: AsRef<Path>>(checkpoint_dir: P, config: RecoveryConfig) -> Self {
Self { config, checkpoint_dir: checkpoint_dir.as_ref().to_path_buf(), wal_path: None }
}
pub fn with_wal<P: AsRef<Path>>(mut self, wal_path: P) -> Self {
self.wal_path = Some(wal_path.as_ref().to_path_buf());
self
}
pub fn recover(&self) -> Result<(Database, RecoveryStats), StorageError> {
let mut stats = RecoveryStats::default();
let (mut db, checkpoint_lsn) = self.load_latest_checkpoint(&mut stats)?;
stats.checkpoint_lsn = checkpoint_lsn;
if let Some(ref wal_path) = self.wal_path {
if wal_path.exists() {
self.replay_wal(&mut db, wal_path, checkpoint_lsn, &mut stats)?;
}
}
Ok((db, stats))
}
fn load_latest_checkpoint(
&self,
stats: &mut RecoveryStats,
) -> Result<(Database, Lsn), StorageError> {
let checkpoints = self.list_checkpoints()?;
if checkpoints.is_empty() {
log::info!("No checkpoints found, starting with empty database");
return Ok((Database::new(), 0));
}
let mut retries = 0;
for checkpoint in checkpoints.into_iter().rev() {
if retries >= self.config.max_checkpoint_retries {
log::warn!(
"Exceeded max checkpoint retries ({}), starting with empty database",
self.config.max_checkpoint_retries
);
return Ok((Database::new(), 0));
}
match self.load_checkpoint(&checkpoint.path) {
Ok((db, lsn)) => {
log::info!("Loaded checkpoint at LSN {} from {:?}", lsn, checkpoint.path);
return Ok((db, lsn));
}
Err(e) => {
log::warn!("Failed to load checkpoint {:?}: {}", checkpoint.path, e);
retries += 1;
stats.corruption_detected = true;
}
}
}
log::warn!("All checkpoints failed to load, starting with empty database");
Ok((Database::new(), 0))
}
fn list_checkpoints(&self) -> Result<Vec<CheckpointInfo>, StorageError> {
if !self.checkpoint_dir.exists() {
return Ok(Vec::new());
}
let checkpoint_writer = CheckpointWriter::new(&self.checkpoint_dir)?;
checkpoint_writer.list_checkpoints()
}
fn load_checkpoint(&self, path: &Path) -> Result<(Database, Lsn), StorageError> {
let (header, data) = read_checkpoint_data(path)?;
let mut reader = BufReader::new(&data[..]);
let version = read_header(&mut reader)?;
let mut db = read_catalog_v(&mut reader, version)?;
read_data(&mut reader, &mut db, version)?;
Ok((db, header.lsn))
}
fn replay_wal(
&self,
db: &mut Database,
wal_path: &Path,
start_lsn: Lsn,
stats: &mut RecoveryStats,
) -> Result<(), StorageError> {
let file = File::open(wal_path)
.map_err(|e| StorageError::IoError(format!("Failed to open WAL: {}", e)))?;
let reader = BufReader::new(file);
let mut wal_reader = WalReader::open(reader)?;
let mut tracker = TransactionTracker::new();
let mut current_txn_id: Option<u64> = None;
loop {
match wal_reader.read_entry()? {
ReadResult::Entry(entry) => {
if entry.lsn <= start_lsn {
stats.entries_skipped += 1;
continue;
}
match &entry.op {
WalOp::TxnBegin { txn_id } => {
tracker.begin_transaction(*txn_id);
current_txn_id = Some(*txn_id);
}
WalOp::TxnCommit { txn_id } => {
let ops = tracker.commit_transaction(*txn_id);
for (lsn, op) in ops {
self.apply_op(db, lsn, op, stats)?;
}
if current_txn_id == Some(*txn_id) {
current_txn_id = None;
}
}
WalOp::TxnRollback { txn_id } => {
tracker.rollback_transaction(*txn_id);
if current_txn_id == Some(*txn_id) {
current_txn_id = None;
}
}
WalOp::CheckpointBegin { .. } | WalOp::CheckpointComplete { .. } => {
}
op => {
if let Some(txn_id) = current_txn_id {
if tracker.is_in_flight(txn_id) {
tracker.buffer_op(txn_id, entry.lsn, op.clone());
}
} else {
self.apply_op(db, entry.lsn, op.clone(), stats)?;
}
}
}
stats.entries_replayed += 1;
if stats.entries_replayed.is_multiple_of(self.config.progress_interval as u64) {
log::debug!(
"Recovery progress: {} entries replayed",
stats.entries_replayed
);
}
}
ReadResult::Eof => {
break;
}
ReadResult::Corruption { position } => {
stats.corruption_detected = true;
stats.corruption_position = Some(position);
if self.config.stop_on_corruption {
log::warn!(
"WAL corruption detected at position {}, stopping replay",
position
);
break;
} else {
log::warn!(
"WAL corruption detected at position {}, continuing with partial recovery",
position
);
break;
}
}
}
}
let in_flight = tracker.get_in_flight_transactions();
if !in_flight.is_empty() {
log::info!(
"Rolling back {} uncommitted transactions: {:?}",
in_flight.len(),
in_flight
);
}
stats.transactions_committed = tracker.committed_count();
stats.transactions_rolled_back = tracker.rolled_back_count();
log::info!(
"WAL replay complete: {} entries, {} committed txns, {} rolled back txns",
stats.entries_replayed,
stats.transactions_committed,
stats.transactions_rolled_back
);
Ok(())
}
fn apply_op(
&self,
db: &mut Database,
_lsn: Lsn,
op: WalOp,
stats: &mut RecoveryStats,
) -> Result<(), StorageError> {
match op {
WalOp::Insert { table_id: _, row_id: _, values } => {
stats.inserts_applied += 1;
log::trace!("Applied insert: {:?}", values);
}
WalOp::Update { table_id: _, row_id: _, old_values: _, new_values } => {
stats.updates_applied += 1;
log::trace!("Applied update: {:?}", new_values);
}
WalOp::Delete { table_id: _, row_id: _, old_values: _ } => {
stats.deletes_applied += 1;
log::trace!("Applied delete");
}
WalOp::CreateTable { table_id: _, table_name, schema_data } => {
match deserialize_table_schema(&schema_data) {
Ok(schema) => {
if db.get_table(&table_name).is_none() {
if let Err(e) = db.create_table(schema) {
log::warn!(
"Failed to create table {} during recovery: {}",
table_name,
e
);
} else {
stats.tables_created += 1;
}
}
}
Err(e) => {
log::warn!("Failed to deserialize schema for table {}: {}", table_name, e);
}
}
}
WalOp::DropTable { table_id: _, table_name } => {
if let Err(e) = db.drop_table(&table_name) {
log::warn!("Failed to drop table {} during recovery: {}", table_name, e);
}
}
WalOp::CreateIndex {
index_id: _,
index_name,
table_id: _,
column_indices,
is_unique,
} => {
log::trace!(
"Would create index {} on columns {:?}, unique={}",
index_name,
column_indices,
is_unique
);
stats.indexes_created += 1;
}
WalOp::DropIndex { index_id: _, index_name } => {
log::trace!("Would drop index {}", index_name);
}
WalOp::TxnBegin { .. } | WalOp::TxnCommit { .. } | WalOp::TxnRollback { .. } => {
}
WalOp::CheckpointBegin { .. } | WalOp::CheckpointComplete { .. } => {
}
}
Ok(())
}
}
fn deserialize_table_schema(data: &[u8]) -> Result<vibesql_catalog::TableSchema, StorageError> {
use vibesql_catalog::{ColumnSchema, TableSchema};
let mut pos = 0;
let name_end = data[pos..].iter().position(|&b| b == 0).ok_or_else(|| {
StorageError::IoError("Invalid schema data: missing table name".to_string())
})?;
let table_name = String::from_utf8(data[pos..pos + name_end].to_vec())
.map_err(|e| StorageError::IoError(format!("Invalid UTF-8 in table name: {}", e)))?;
pos += name_end + 1;
if pos + 4 > data.len() {
return Err(StorageError::IoError("Invalid schema data: missing column count".to_string()));
}
let column_count =
u32::from_le_bytes([data[pos], data[pos + 1], data[pos + 2], data[pos + 3]]) as usize;
pos += 4;
let mut columns = Vec::with_capacity(column_count);
for _ in 0..column_count {
let name_end = data[pos..].iter().position(|&b| b == 0).ok_or_else(|| {
StorageError::IoError("Invalid schema data: missing column name".to_string())
})?;
let column_name = String::from_utf8(data[pos..pos + name_end].to_vec())
.map_err(|e| StorageError::IoError(format!("Invalid UTF-8 in column name: {}", e)))?;
pos += name_end + 1;
let type_end = data[pos..].iter().position(|&b| b == 0).ok_or_else(|| {
StorageError::IoError("Invalid schema data: missing data type".to_string())
})?;
let type_str = String::from_utf8(data[pos..pos + type_end].to_vec())
.map_err(|e| StorageError::IoError(format!("Invalid UTF-8 in data type: {}", e)))?;
pos += type_end + 1;
let data_type = parse_data_type(&type_str)?;
if pos >= data.len() {
return Err(StorageError::IoError(
"Invalid schema data: missing nullable flag".to_string(),
));
}
let nullable = data[pos] != 0;
pos += 1;
columns.push(ColumnSchema::new(column_name, data_type, nullable));
}
Ok(TableSchema::new(table_name, columns))
}
fn parse_data_type(s: &str) -> Result<vibesql_types::DataType, StorageError> {
use vibesql_types::DataType;
let s = s.trim();
if s == "Integer" {
return Ok(DataType::Integer);
}
if s == "Bigint" {
return Ok(DataType::Bigint);
}
if s == "Smallint" {
return Ok(DataType::Smallint);
}
if s == "Unsigned" {
return Ok(DataType::Unsigned);
}
if s == "Real" {
return Ok(DataType::Real);
}
if s == "DoublePrecision" {
return Ok(DataType::DoublePrecision);
}
if s == "Boolean" {
return Ok(DataType::Boolean);
}
if s == "Date" {
return Ok(DataType::Date);
}
if s == "Null" {
return Ok(DataType::Null);
}
if s == "CharacterLargeObject" {
return Ok(DataType::CharacterLargeObject);
}
if s == "BinaryLargeObject" {
return Ok(DataType::BinaryLargeObject);
}
if s == "Name" {
return Ok(DataType::Name);
}
if s.starts_with("Time") {
let with_timezone = s.contains("with_timezone: true");
return Ok(DataType::Time { with_timezone });
}
if s.starts_with("Timestamp") {
let with_timezone = s.contains("with_timezone: true");
return Ok(DataType::Timestamp { with_timezone });
}
if s.starts_with("Varchar") {
if s.contains("None") {
return Ok(DataType::Varchar { max_length: None });
}
if let Some(start) = s.find("Some(") {
if let Some(end) = s[start..].find(')') {
if let Ok(len) = s[start + 5..start + end].parse() {
return Ok(DataType::Varchar { max_length: Some(len) });
}
}
}
return Ok(DataType::Varchar { max_length: None });
}
if s.starts_with("Character") {
if let Some(start) = s.find("length:") {
let len_str = &s[start + 7..];
if let Some(len_end) = len_str.find(|c: char| !c.is_ascii_digit() && c != ' ') {
if let Ok(len) = len_str[..len_end].trim().parse() {
return Ok(DataType::Character { length: len });
}
} else if let Ok(len) = len_str.trim().trim_end_matches('}').trim().parse() {
return Ok(DataType::Character { length: len });
}
}
return Ok(DataType::Character { length: 1 });
}
if s.starts_with("Float") {
if let Some(start) = s.find("precision:") {
let prec_str = &s[start + 10..];
if let Some(prec_end) = prec_str.find(|c: char| !c.is_ascii_digit() && c != ' ') {
if let Ok(p) = prec_str[..prec_end].trim().parse() {
return Ok(DataType::Float { precision: p });
}
} else if let Ok(p) = prec_str.trim().trim_end_matches('}').trim().parse() {
return Ok(DataType::Float { precision: p });
}
}
return Ok(DataType::Float { precision: 53 }); }
if s.starts_with("Decimal") {
let mut precision = 38;
let mut scale = 0;
if let Some(prec_start) = s.find("precision:") {
let prec_str = &s[prec_start + 10..];
if let Some(prec_end) = prec_str.find(|c: char| !c.is_ascii_digit() && c != ' ') {
if let Ok(p) = prec_str[..prec_end].trim().parse() {
precision = p;
}
}
}
if let Some(scale_start) = s.find("scale:") {
let scale_str = &s[scale_start + 6..];
if let Some(scale_end) = scale_str.find(|c: char| !c.is_ascii_digit() && c != ' ') {
if let Ok(sc) = scale_str[..scale_end].trim().parse() {
scale = sc;
}
}
}
return Ok(DataType::Decimal { precision, scale });
}
if s.starts_with("Numeric") {
let mut precision = 38;
let mut scale = 0;
if let Some(prec_start) = s.find("precision:") {
let prec_str = &s[prec_start + 10..];
if let Some(prec_end) = prec_str.find(|c: char| !c.is_ascii_digit() && c != ' ') {
if let Ok(p) = prec_str[..prec_end].trim().parse() {
precision = p;
}
}
}
if let Some(scale_start) = s.find("scale:") {
let scale_str = &s[scale_start + 6..];
if let Some(scale_end) = scale_str.find(|c: char| !c.is_ascii_digit() && c != ' ') {
if let Ok(sc) = scale_str[..scale_end].trim().parse() {
scale = sc;
}
}
}
return Ok(DataType::Numeric { precision, scale });
}
if s.starts_with("Vector") {
if let Some(start) = s.find("dimensions:") {
let dim_str = &s[start + 11..];
if let Some(dim_end) = dim_str.find(|c: char| !c.is_ascii_digit() && c != ' ') {
if let Ok(d) = dim_str[..dim_end].trim().parse() {
return Ok(DataType::Vector { dimensions: d });
}
} else if let Ok(d) = dim_str.trim().trim_end_matches('}').trim().parse() {
return Ok(DataType::Vector { dimensions: d });
}
}
return Ok(DataType::Vector { dimensions: 128 }); }
if s.starts_with("Bit") {
if s.contains("None") {
return Ok(DataType::Bit { length: None });
}
if let Some(start) = s.find("Some(") {
if let Some(end) = s[start..].find(')') {
if let Ok(len) = s[start + 5..start + end].parse() {
return Ok(DataType::Bit { length: Some(len) });
}
}
}
return Ok(DataType::Bit { length: Some(1) });
}
log::warn!("Unknown data type '{}', defaulting to Varchar", s);
Ok(DataType::Varchar { max_length: None })
}
pub fn recover<P: AsRef<Path>>(
checkpoint_dir: P,
wal_path: Option<P>,
) -> Result<(Database, RecoveryStats), StorageError> {
let mut manager = RecoveryManager::new(checkpoint_dir);
if let Some(wal) = wal_path {
manager = manager.with_wal(wal);
}
manager.recover()
}
pub fn needs_recovery<P1: AsRef<Path>, P2: AsRef<Path>>(
checkpoint_dir: P1,
wal_path: Option<P2>,
) -> bool {
let checkpoint_dir = checkpoint_dir.as_ref();
let has_checkpoints = checkpoint_dir.exists()
&& fs::read_dir(checkpoint_dir)
.map(|entries| {
entries
.filter_map(Result::ok)
.any(|e| e.path().extension().is_some_and(|ext| ext == "vchk"))
})
.unwrap_or(false);
let has_wal = wal_path.is_some_and(|p| p.as_ref().exists());
has_checkpoints || has_wal
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use tempfile::TempDir;
use vibesql_catalog::{ColumnSchema, TableSchema};
use vibesql_types::{DataType, SqlValue};
use super::*;
use crate::wal::checkpoint::CheckpointWriter;
#[test]
fn test_recovery_empty_dir() {
let temp_dir = TempDir::new().unwrap();
let checkpoint_dir = temp_dir.path().join("checkpoints");
fs::create_dir_all(&checkpoint_dir).unwrap();
let manager = RecoveryManager::new(&checkpoint_dir);
let (db, stats) = manager.recover().unwrap();
assert_eq!(stats.checkpoint_lsn, 0);
assert_eq!(stats.entries_replayed, 0);
assert!(db.list_tables().is_empty());
}
#[test]
fn test_recovery_config_defaults() {
let config = RecoveryConfig::default();
assert!(config.validate_checksums);
assert!(config.stop_on_corruption);
assert_eq!(config.max_checkpoint_retries, 3);
assert_eq!(config.progress_interval, 10000);
}
#[test]
fn test_transaction_tracker_basic() {
let mut tracker = TransactionTracker::new();
tracker.begin_transaction(1);
assert!(tracker.is_in_flight(1));
tracker.buffer_op(
1,
10,
WalOp::Insert { table_id: 1, row_id: 0, values: vec![SqlValue::Integer(42)] },
);
let ops = tracker.commit_transaction(1);
assert_eq!(ops.len(), 1);
assert!(!tracker.is_in_flight(1));
}
#[test]
fn test_transaction_tracker_rollback() {
let mut tracker = TransactionTracker::new();
tracker.begin_transaction(1);
tracker.buffer_op(
1,
10,
WalOp::Insert { table_id: 1, row_id: 0, values: vec![SqlValue::Integer(42)] },
);
tracker.rollback_transaction(1);
assert!(!tracker.is_in_flight(1));
assert_eq!(tracker.rolled_back_count(), 1);
}
#[test]
fn test_transaction_tracker_in_flight_at_end() {
let mut tracker = TransactionTracker::new();
tracker.begin_transaction(1);
tracker.begin_transaction(2);
tracker.commit_transaction(1);
let in_flight = tracker.get_in_flight_transactions();
assert_eq!(in_flight, vec![2]);
}
#[test]
fn test_deserialize_table_schema() {
let schema = TableSchema::new(
"test".to_string(),
vec![
ColumnSchema::new("id".to_string(), DataType::Integer, false),
ColumnSchema::new(
"name".to_string(),
DataType::Varchar { max_length: Some(50) },
true,
),
],
);
let mut data = Vec::new();
data.extend_from_slice(schema.name.as_bytes());
data.push(0);
data.extend_from_slice(&(schema.columns.len() as u32).to_le_bytes());
for col in &schema.columns {
data.extend_from_slice(col.name.as_bytes());
data.push(0);
let type_str = format!("{:?}", col.data_type);
data.extend_from_slice(type_str.as_bytes());
data.push(0);
data.push(if col.nullable { 1 } else { 0 });
}
let result = deserialize_table_schema(&data).unwrap();
assert_eq!(result.name, "test");
assert_eq!(result.columns.len(), 2);
assert_eq!(result.columns[0].name, "id");
assert!(!result.columns[0].nullable);
assert_eq!(result.columns[1].name, "name");
assert!(result.columns[1].nullable);
}
#[test]
fn test_parse_data_type() {
assert!(matches!(parse_data_type("Integer").unwrap(), DataType::Integer));
assert!(matches!(parse_data_type("Bigint").unwrap(), DataType::Bigint));
assert!(matches!(parse_data_type("Boolean").unwrap(), DataType::Boolean));
assert!(matches!(
parse_data_type("CharacterLargeObject").unwrap(),
DataType::CharacterLargeObject
));
match parse_data_type("Varchar { max_length: Some(100) }").unwrap() {
DataType::Varchar { max_length: Some(100) } => {}
other => panic!("Expected Varchar(100), got {:?}", other),
}
match parse_data_type("Varchar { max_length: None }").unwrap() {
DataType::Varchar { max_length: None } => {}
other => panic!("Expected Varchar(None), got {:?}", other),
}
}
#[test]
fn test_needs_recovery_empty() {
let temp_dir = TempDir::new().unwrap();
let checkpoint_dir = temp_dir.path().join("checkpoints");
assert!(!needs_recovery(checkpoint_dir, None::<PathBuf>));
}
#[test]
fn test_needs_recovery_with_checkpoint() {
let temp_dir = TempDir::new().unwrap();
let checkpoint_dir = temp_dir.path().join("checkpoints");
let mut writer = CheckpointWriter::new(&checkpoint_dir).unwrap();
writer.create_checkpoint(10, b"test data", 1).unwrap();
assert!(needs_recovery(checkpoint_dir, None::<PathBuf>));
}
#[test]
fn test_recovery_stats_default() {
let stats = RecoveryStats::default();
assert_eq!(stats.checkpoint_lsn, 0);
assert_eq!(stats.entries_replayed, 0);
assert_eq!(stats.transactions_committed, 0);
assert_eq!(stats.transactions_rolled_back, 0);
assert!(!stats.corruption_detected);
}
}