diff --git a/Cargo.lock b/Cargo.lock index 8bb8a88..5a8900c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -178,10 +178,12 @@ dependencies = [ "clap", "crc", "env_logger", + "libc", "log", "postcard", "serde", "tempfile", + "thiserror", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 6a14a59..e2d0012 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,9 +8,11 @@ bitflags = "2.13.0" clap = { version = "4.6.1", features = ["derive"] } crc = "3.4.0" env_logger = "0.11.11" +libc = "0.2.186" log = "0.4.33" postcard = { version = "1.1.3", features = ["alloc"] } serde = { version = "1.0.228", features = ["derive"] } +thiserror = "2.0.18" [dev-dependencies] tempfile = "3.27.0" diff --git a/README.md b/README.md index 257f686..170f846 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,5 @@ # Cryo **State: Not Runnable** +[![CI](https://github.com/DavisRayM/cryo/actions/workflows/ci.yml/badge.svg)](https://github.com/DavisRayM/cryo/actions/workflows/ci.yml) Cryo is a **WIP** database system. It's composable, meaning components are built to be plug-and-play, so new pieces can be added later with minimal friction. diff --git a/src/lib.rs b/src/lib.rs index 42a96f1..19391ba 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,15 +1,13 @@ +use std::io; + use crc::Crc; -pub mod page; -pub mod pager; pub mod recovery; -pub mod wal; +pub mod storage; -pub use page::{Page, PageFlags}; -pub use pager::{AccessContext, Pager}; -pub use wal::{Logger, Lsn, Record, RecordEntry, RecordFlags, WalFlushGuard}; +pub use storage::{AccessContext, FlushGuard, Page, PageFlags, Pager}; -/// https://reveng.sourceforge.io/crc-catalogue/all.htm +/// pub(crate) const CRC32C: Crc = Crc::::new(&crc::CRC_32_ISCSI); /// Read from `reader` N bytes that would construct `ty`pe. @@ -24,3 +22,204 @@ macro_rules! read_be { <$ty>::from_be_bytes(buf) }}; } + +pub type Key = u32; + +pub const KEYCELL_KEY_OFFSET: usize = 0; +pub const KEYCELL_KEY_SIZE: usize = size_of::(); + +pub const KEYCELL_OFFSET_OFFSET: usize = KEYCELL_KEY_OFFSET + KEYCELL_KEY_SIZE; +pub const KEYCELL_OFFSET_SIZE: usize = size_of::(); + +pub const KEYCELL_SIZE: usize = KEYCELL_KEY_SIZE + KEYCELL_OFFSET_SIZE; + +pub const VALUECELL_KEY_OFFSET: usize = KEYCELL_KEY_OFFSET; +pub const VALUECELL_KEY_SIZE: usize = KEYCELL_KEY_SIZE; + +pub const VALUECELL_VALUE_LEN_OFFSET: usize = + VALUECELL_KEY_OFFSET + VALUECELL_KEY_SIZE; +pub const VALUECELL_VALUE_LEN_SIZE: usize = size_of::(); + +pub const VALUECELL_HEADER_SIZE: usize = + VALUECELL_KEY_SIZE + VALUECELL_VALUE_LEN_SIZE; + +#[derive(Debug, Clone)] +pub(crate) struct KeyCell { + pub key: Key, + pub offset: u32, +} + +impl KeyCell { + pub(crate) fn with_key(key: &Key) -> Self { + Self { + key: *key, + offset: 0, + } + } + + pub(crate) fn from_bytes(bytes: &[u8]) -> io::Result { + if bytes.len() != KEYCELL_SIZE { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "bytes is not valid KeyCell bytes", + )); + } + let key = u32::from_be_bytes( + bytes[KEYCELL_KEY_OFFSET..KEYCELL_OFFSET_OFFSET] + .try_into() + .expect("is key sized bytes"), + ); + let offset = u32::from_be_bytes( + bytes[KEYCELL_OFFSET_OFFSET..KEYCELL_SIZE] + .try_into() + .expect("is offset sized bytes"), + ); + + Ok(Self { key, offset }) + } +} + +impl From<&KeyCell> for [u8; KEYCELL_SIZE] { + fn from(val: &KeyCell) -> Self { + let mut out = [0; KEYCELL_SIZE]; + out[KEYCELL_KEY_OFFSET..KEYCELL_OFFSET_OFFSET] + .copy_from_slice(&val.key.to_be_bytes()); + out[KEYCELL_OFFSET_OFFSET..KEYCELL_SIZE] + .copy_from_slice(&val.offset.to_be_bytes()); + + out + } +} + +impl PartialEq for KeyCell { + fn eq(&self, other: &Self) -> bool { + self.key.eq(&other.key) + } +} + +impl Eq for KeyCell {} + +impl PartialOrd for KeyCell { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for KeyCell { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + self.key.cmp(&other.key) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ValueCell { + key: u32, + value: Box<[u8]>, +} + +impl ValueCell { + pub fn key(bytes: &[u8]) -> io::Result { + if bytes.len() <= VALUECELL_HEADER_SIZE { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "bytes is not valid KeyCell bytes", + )); + } + + Ok(u32::from_be_bytes( + bytes[VALUECELL_KEY_OFFSET..VALUECELL_VALUE_LEN_OFFSET] + .try_into() + .expect("is key sized bytes"), + )) + } + + pub fn value(bytes: &[u8]) -> io::Result> { + if bytes.len() <= VALUECELL_HEADER_SIZE { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "bytes is not valid KeyCell bytes", + )); + } + + let len = u16::from_be_bytes( + bytes[VALUECELL_VALUE_LEN_OFFSET..VALUECELL_HEADER_SIZE] + .try_into() + .expect("is len sized bytes"), + ); + + let mut value = vec![0; len as usize]; + value.clone_from_slice( + &bytes[VALUECELL_HEADER_SIZE..VALUECELL_HEADER_SIZE + len as usize], + ); + Ok(value.into()) + } + + #[allow(clippy::len_without_is_empty)] + pub fn len(&self) -> usize { + VALUECELL_HEADER_SIZE + self.value.len() + } + + pub(crate) fn from_bytes(bytes: &[u8]) -> io::Result { + if bytes.len() <= VALUECELL_HEADER_SIZE { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "bytes is not valid KeyCell bytes", + )); + } + let key = u32::from_be_bytes( + bytes[VALUECELL_KEY_OFFSET..VALUECELL_VALUE_LEN_OFFSET] + .try_into() + .expect("is key sized bytes"), + ); + let len = u16::from_be_bytes( + bytes[VALUECELL_VALUE_LEN_OFFSET..VALUECELL_HEADER_SIZE] + .try_into() + .expect("is len sized bytes"), + ); + + let mut value = vec![0; len as usize]; + value.clone_from_slice( + &bytes[VALUECELL_HEADER_SIZE..VALUECELL_HEADER_SIZE + len as usize], + ); + + Ok(Self { + key, + value: value.into(), + }) + } +} + +impl From<&ValueCell> for Box<[u8]> { + fn from(val: &ValueCell) -> Self { + let mut out = vec![0; val.len()]; + out[VALUECELL_KEY_OFFSET..VALUECELL_VALUE_LEN_OFFSET] + .copy_from_slice(&val.key.to_be_bytes()); + + out[VALUECELL_VALUE_LEN_OFFSET..VALUECELL_HEADER_SIZE] + .copy_from_slice(&(val.value.len() as u16).to_be_bytes()); + out[VALUECELL_HEADER_SIZE..].copy_from_slice(&val.value); + + out.into() + } +} + +/// StorageInterface defines an interface for interacting with +/// "Storages" which handle organizing information on disk. +pub trait StorageInterface { + /// Retrieve request key from underlying storage. + /// + /// Returns `Ok(None)` if key is not present. + fn get(&self, key: Key) -> io::Result>; + + /// Set the value of `key` in the underlying storage, returning the + /// previously set value if any. + fn set( + &mut self, + key: Key, + value: Box<[u8]>, + ) -> io::Result>; + + /// Remove the value of the `key` in the underlying storage, returning + /// the previously set value if any. + fn remove(&mut self, key: Key) -> io::Result>; +} diff --git a/src/main.rs b/src/main.rs index 334acb9..7527201 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,7 +1,10 @@ use std::sync::Arc; use clap::Parser; -use cryo::pager::{AccessContext, Pager}; +use cryo::{ + AccessContext, + storage::{Tree, cursor::DebugOpt}, +}; use env_logger::Env; #[derive(Parser, Debug, Clone)] @@ -15,33 +18,43 @@ fn main() { ); let cli = Cli::parse(); - let pager = Arc::new(Pager::open(cli.database, 10).unwrap()); + let tree = Arc::new(Tree::load(cli.database, 10).unwrap()); + let mut ctx = AccessContext::maintenance("main test"); let start = Arc::new(std::sync::Barrier::new(11)); let mut handles = Vec::with_capacity(10); - for _ in 0..10 { - let pager = Arc::clone(&pager); + for i in 0..10 { + let tree = Arc::clone(&tree); let start = Arc::clone(&start); handles.push(std::thread::spawn(move || { start.wait(); - pager - .mut_page(1, AccessContext::maintenance("main test"), |_| { - std::thread::sleep(std::time::Duration::from_millis(250)); - }) - .unwrap(); + let start = (i * 10) + 1; + for k in start..start + 10 { + tree.cursor() + .unwrap() + .insert( + &mut ctx, + &k, + "so much data" + .as_bytes() + .into(), + ) + .unwrap(); + } })); } start.wait(); std::thread::sleep(std::time::Duration::from_millis(100)); - log::info!("Before thread join: {pager}"); - for handle in handles { handle.join().unwrap(); } - log::info!("After thread join: {pager}"); + tree.cursor() + .unwrap() + .debug_print(DebugOpt::default()) + .unwrap(); } diff --git a/src/page.rs b/src/page.rs deleted file mode 100644 index 430d02d..0000000 --- a/src/page.rs +++ /dev/null @@ -1,232 +0,0 @@ -use std::{fmt, ops}; - -use bitflags::bitflags; - -use crate::CRC32C; - -pub const CHECKSUM_OFFSET: usize = 0; -pub const CHECKSUM_SIZE: usize = size_of::(); - -pub const FLAGS_OFFSET: usize = CHECKSUM_OFFSET + CHECKSUM_SIZE; -pub const FLAGS_SIZE: usize = size_of::(); - -pub const FREESPACE_START_OFFSET: usize = FLAGS_OFFSET + FLAGS_SIZE; -pub const FREESPACE_START_SIZE: usize = size_of::(); - -pub const FREESPACE_END_OFFSET: usize = - FREESPACE_START_OFFSET + FREESPACE_START_SIZE; -pub const FREESPACE_END_SIZE: usize = size_of::(); - -pub const FREESPACE_OFFSET: usize = FREESPACE_END_OFFSET + FREESPACE_END_SIZE; -pub const FREESPACE_SIZE: usize = size_of::(); - -pub const NUM_KEY_OFFSET: usize = FREESPACE_OFFSET + FREESPACE_SIZE; -pub const NUM_KEY_SIZE: usize = size_of::(); - -pub const LSN_OFFSET: usize = NUM_KEY_OFFSET + NUM_KEY_SIZE; -pub const LSN_SIZE: usize = size_of::(); - -pub const PAGE_SIZE_OFFSET: usize = LSN_OFFSET + LSN_SIZE; -pub const PAGE_SIZE_SIZE: usize = size_of::(); - -pub const FORMAT_VERSION_OFFSET: usize = PAGE_SIZE_OFFSET + PAGE_SIZE_SIZE; -pub const FORMAT_VERSION_SIZE: usize = size_of::(); - -pub const HEADER_SIZE: usize = 100; - -pub const MAGIC: &str = "CRYOGENIC"; -pub const MAGIC_SIZE: usize = MAGIC.len(); -pub const MAGIC_OFFSET: usize = HEADER_SIZE - MAGIC_SIZE; - -macro_rules! read_be { - ($page:expr, $ty:ty, $start:expr, $end: expr) => { - <$ty>::from_be_bytes( - $page - .cell($start, $end) - .try_into() - .expect("incorrect number of bytes"), - ) - }; -} -macro_rules! write_be { - ($page:expr, $start:expr, $end: expr, $slice: expr) => { - $page - .mut_cell($start, $end) - .copy_from_slice($slice.to_be_bytes().as_ref()) - }; -} -macro_rules! field { - ($getter:ident, $setter:ident, $ty:ty, $start:expr, $end:expr) => { - pub fn $getter(&self) -> $ty { - read_be!(self, $ty, $start, $end) - } - - pub fn $setter(&mut self, value: $ty) { - write_be!(self, $start, $end, value) - } - }; -} - -/// Basic operational unit within the index-organized table. -/// -/// Page Layout: -/// [0..4] u32 checksum -/// [4..5] u8 flags (is_leaf, is_root, has_overflow, ...) -/// [5..7] u16 free_space_start -/// [7..9] u16 free_space_end -/// [9..11] u16 free_space -/// [11..13] u16 number_of_keys -/// [13..21] u64 latest_lsn -/// [21..23] u16 page_size (first page only; free space otherwise) -/// [23..24] u8 format_version (first page only; free space otherwise) -/// [24..91] reserved -/// [91..100] bytes magic -/// [100..] content -/// -#[derive(Clone)] -pub struct Page { - inner: Box<[u8]>, -} - -bitflags! { - /// [`PageFlags`] is a set of all possible flags to a page - #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] - pub struct PageFlags: u8 { - const IsLeaf = 0x01; - const IsRoot = 0x02; - const HasOverflow = 0x04; - } -} - -impl Page { - /// Create a new [`Page`] - /// - /// ## Note - /// - /// Suggest utilizing a Vec, Box<[u8]> or a [u8; N] to avoid - /// any copying that might be done. - pub fn build(bytes: T) -> Self - where - T: Into>, - { - let inner: Box<[u8]> = bytes.into(); - assert!( - inner.len() >= 512 && inner.len() <= u16::MAX as usize, - "bytes is not page size len" - ); - Self { inner } - } - - /// Immutable view into the held [`Page`] - pub fn cell(&self, start: usize, end: usize) -> &[u8] { - &self[start..end] - } - - /// Mutable view into the held [`Page`] - pub fn mut_cell(&mut self, start: usize, end: usize) -> &mut [u8] { - &mut self[start..end] - } - - /// Computes the current checksum of the package - pub fn compute_checksum(&self) -> u32 { - CRC32C.checksum(&self[FLAGS_OFFSET..]) - } - - pub fn magic(&self) -> &[u8] { - self.cell(MAGIC_OFFSET, HEADER_SIZE) - } - - pub fn set_magic(&mut self) { - self.mut_cell(MAGIC_OFFSET, HEADER_SIZE) - .copy_from_slice(MAGIC.as_bytes()); - } - - field!(checksum, set_checksum, u32, CHECKSUM_OFFSET, FLAGS_OFFSET); - field!(flags, set_flags, u8, FLAGS_OFFSET, FREESPACE_START_OFFSET); - field!( - free_space_start, - set_free_space_start, - u16, - FREESPACE_START_OFFSET, - FREESPACE_END_OFFSET - ); - field!( - free_space_end, - set_free_space_end, - u16, - FREESPACE_END_OFFSET, - FREESPACE_OFFSET - ); - field!( - free_space, - set_free_space, - u16, - FREESPACE_OFFSET, - NUM_KEY_OFFSET - ); - field!(num_keys, set_num_keys, u16, NUM_KEY_OFFSET, LSN_OFFSET); - field!(latest_lsn, set_lsn, u64, LSN_OFFSET, PAGE_SIZE_OFFSET); - field!( - page_size, - set_page_size, - u16, - PAGE_SIZE_OFFSET, - FORMAT_VERSION_OFFSET - ); - field!( - format_version, - set_format_version, - u8, - FORMAT_VERSION_OFFSET, - FORMAT_VERSION_OFFSET + FORMAT_VERSION_SIZE - ); -} - -impl ops::DerefMut for Page { - fn deref_mut(&mut self) -> &mut Self::Target { - &mut self.inner - } -} - -impl ops::Deref for Page { - type Target = [u8]; - - fn deref(&self) -> &Self::Target { - &self.inner - } -} - -impl fmt::Display for Page { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!( - f, - "keys={}, free_space(start={}, end={}, size={})", - self.num_keys(), - self.free_space_start(), - self.free_space_end(), - self.free_space(), - ) - } -} - -impl fmt::Debug for Page { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}", self) - } -} - -#[cfg(test)] -mod test { - use super::*; - - #[test] - fn page_field_access() { - let mut page = Page::build(vec![0; 4096]); - - page.set_free_space(4096); - page.set_flags(64); - assert_eq!(page.flags(), 64); - assert_eq!(page.free_space(), 4096); - assert_ne!(page.inner[..], vec![0; 4096][..]) - } -} diff --git a/src/recovery.rs b/src/recovery.rs deleted file mode 100644 index 8b13789..0000000 --- a/src/recovery.rs +++ /dev/null @@ -1 +0,0 @@ - diff --git a/src/recovery/logger.rs b/src/recovery/logger.rs new file mode 100644 index 0000000..756f816 --- /dev/null +++ b/src/recovery/logger.rs @@ -0,0 +1,1258 @@ +use std::{ + collections::VecDeque, + fs::{File, OpenOptions}, + io::{self, BufReader, Seek, SeekFrom, Write}, + path::{Path, PathBuf}, + sync::{Arc, Mutex}, +}; + +use log::{info, trace, warn}; + +use crate::{ + AccessContext, FlushGuard, + storage::{self, pager::ChangeGuard}, +}; + +use super::{Lsn, Record, RecordEntry}; + +/// The file extension used for WAL generation segment files. +const WAL_EXTENSION: &str = "wal"; + +/// A [`FlushGuard`] that enforces the write-ahead rule: a page may only be +/// flushed once the WAL is durable through the page's `pageLSN`. +pub struct WalFlushGuard { + wal: Arc, +} + +impl WalFlushGuard { + /// Create a guard that flushes `wal` before dependent pages are written. + pub fn new(wal: Arc) -> Self { + Self { wal } + } +} + +impl FlushGuard for WalFlushGuard { + fn before_flush( + &self, + _page_id: u64, + page: &crate::Page, + ) -> storage::Result<()> { + let lsn = Lsn::from(page.latest_lsn()); + self.wal.flush_through(lsn)?; + self.wal.sync_all()?; + Ok(()) + } +} + +/// A [`ChangeGuard`] that enforces the write-ahead rule: changes may only be +/// applied to a page once they have been written to the WAL. +pub struct WalChangeGuard { + wal: Arc, +} + +impl WalChangeGuard { + /// Create a guard that writes [`RecordEntry`] on page change. + pub fn new(wal: Arc) -> Self { + Self { wal } + } +} + +impl ChangeGuard for WalChangeGuard { + fn before_change( + &self, + ctx: &mut AccessContext, + page_id: u64, + mutations: Vec, + ) -> storage::Result> { + let Some(txn_id) = ctx.txn_id else { + return Err(storage::StorageError::NotAllowed( + "attempt to change page without transaction", + )); + }; + + let record = Record::Update { + txn_id: txn_id, + page_id, + mutations, + prev_lsn: ctx.lsn, + }; + let lsn = self.wal.append(record)?; + ctx.lsn = Some(lsn.into()); + Ok(Some(lsn)) + } +} + +/// A directory-backed Write-Ahead Log. +/// +/// The log is stored as a sequence of append-only generation files named +/// `.wal`. Physical [`Lsn`]s encode `(generation, offset)`, so lookups can +/// seek directly to the owning generation file and byte offset. New appends +/// always go to the highest generation; [`Logger::rotate`] starts a new one. +/// +/// All state is held behind a [`Mutex`] so the public API only requires shared +/// (`&self`) access and the logger can be shared across threads. +pub struct Logger { + inner: Mutex, +} + +impl Logger { + /// Open (or create) a WAL [`Logger`] backed by the directory at `path`. + /// + /// The directory is scanned for `.wal` generation files. The highest + /// generation is opened for appending and `next_lsn`/`flushed_lsn` are + /// resumed from its valid prefix. When the directory contains no generation + /// files, generation `0` is created. + pub fn open(path: impl AsRef) -> io::Result { + let dir = path.as_ref().to_path_buf(); + std::fs::create_dir_all(&dir)?; + + let generations = discover_generations(&dir)?; + let current_generation = generations + .last() + .copied() + .unwrap_or(0); + + let mut writer = OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .open(generation_path(&dir, current_generation))?; + + let (mut next_lsn, records) = + scan_records_from(&mut writer, current_generation, 0)?; + let flushed_lsn = records + .last() + .map(|entry| entry.lsn); + let mut buffer = VecDeque::new(); + + let lsn: u64 = next_lsn.into(); + if lsn == 0 { + let lsn = next_lsn; + next_lsn = lsn + .advanced_by(Record::StartSentinel.len() as u32) + .expect("can advance lsn"); + buffer.push_back(RecordEntry { + lsn, + record: Record::StartSentinel, + }); + } + + // Position the append handle at the end of the valid prefix so a + // trailing partial frame is overwritten by the next append. + writer.seek(io::SeekFrom::Start(next_lsn.offset as u64))?; + + info!( + "wal open: dir={} current_generation={current_generation} \ + next_lsn={next_lsn} flushed_lsn={flushed_lsn:?}", + dir.display() + ); + + Ok(Self { + inner: Mutex::new(Inner { + dir, + writer, + current_generation, + buffer, + next_lsn, + flushed_lsn, + }), + }) + } + + fn lock(&self) -> io::Result> { + self.inner + .lock() + .map_err(|_| io::Error::other("wal: lock poisoned")) + } +} + +/// Mutable state protected by the [`Logger`]'s lock. +struct Inner { + /// The generation currently being appended to. + pub current_generation: u32, + /// The [`Lsn`] the next appended record will occupy. + pub next_lsn: Lsn, + /// The [`Lsn`] of the last record durably written to disk, if any. + pub flushed_lsn: Option, + + /// Directory containing the `.wal` generation files. + dir: PathBuf, + /// Append handle for the current (highest) generation. + writer: File, + /// Records appended but not yet flushed to disk. + buffer: VecDeque, +} + +impl Inner { + /// Open a fresh read handle for `generation`'s segment file. + fn open_generation_reader( + &self, + generation: u32, + ) -> io::Result> { + let file = OpenOptions::new() + .read(true) + .open(generation_path(&self.dir, generation))?; + Ok(BufReader::new(file)) + } + + /// Flush buffered records up to and including `target_lsn`. + fn flush_through(&mut self, target_lsn: Lsn) -> io::Result<()> { + if let Some(flushed_lsn) = self.flushed_lsn + && target_lsn <= flushed_lsn + { + trace!( + "wal flush skipped: target_lsn={target_lsn} \ + flushed_lsn={flushed_lsn}" + ); + return Ok(()); + } + + info!( + "wal flush start: target_lsn={target_lsn} \ + current_flushed={:?}", + self.flushed_lsn + ); + + let mut flushed_until = self.flushed_lsn; + + while let Some(entry) = self.buffer.pop_front() { + if entry.lsn > target_lsn { + self.buffer.push_front(entry); + break; + } + + self.write(&entry)?; + flushed_until = Some(entry.lsn); + } + + self.writer.flush()?; + self.writer.sync_all()?; + self.flushed_lsn = flushed_until; + + info!("wal flush complete: flushed_lsn={:?}", self.flushed_lsn); + Ok(()) + } + + /// Writes a single [`RecordEntry`] to the underlying file. + /// + /// ## Errors + /// + /// If this is called with an `entry` with an `LSN` from a different + /// generation than the current. + fn write(&mut self, entry: &RecordEntry) -> io::Result<()> { + if entry.lsn.generation != self.current_generation { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "entry lsn is not for the current log generation", + )); + } + + self.writer + .seek(SeekFrom::Start(entry.lsn.offset as u64))?; + let payload = entry.as_bytes()?; + self.writer + .write_all(&payload)?; + + Ok(()) + } +} + +impl Logger { + /// Retrieve the [`Record`] stored at `lsn`. + /// + /// The record is served from the in-memory buffer when it has not yet been + /// flushed, otherwise it is read from the generation file addressed by + /// `lsn.generation()` at byte offset `lsn.offset()`. + pub fn get(&self, lsn: Lsn) -> io::Result> { + let inner = self.lock()?; + + if let Ok(pos) = inner + .buffer + .binary_search_by(|r| r.lsn.cmp(&lsn)) + { + return Ok(Some(inner.buffer[pos].clone())); + } + + if lsn >= inner.next_lsn { + return Ok(None); + } + + // Anything at or after the first buffered record but not found in the + // buffer does not correspond to a real record boundary. + if let Some(first_buffered) = inner + .buffer + .front() + .map(|r| r.lsn) + && lsn >= first_buffered + { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "wal: attempt to retrieve record outside of known range", + )); + } + + let mut reader = inner.open_generation_reader(lsn.generation)?; + reader.seek(SeekFrom::Start(lsn.offset as u64))?; + + let Some((stored_lsn, record)) = Record::read(&mut reader)? else { + return Ok(None); + }; + if stored_lsn != u64::from(lsn) { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!( + "wal: unexpected LSN. expected={lsn}, got={}", + Lsn::from(stored_lsn) + ), + )); + } + + Ok(Some((lsn, record).into())) + } + + /// Retrieve every record from `lsn` onwards, in order. + /// + /// Traversal follows the physical layout: it reads the generation + /// containing `lsn` from `lsn.offset()`, then continues through each + /// subsequent on-disk generation, and finally appends any buffered records + /// that have not yet been flushed. + pub fn records_from(&self, lsn: Lsn) -> io::Result> { + let inner = self.lock()?; + let mut records = Vec::new(); + + // Read flushed records across generations, starting from `lsn`. + let mut generation = lsn.generation; + let mut offset = lsn.offset; + while generation <= inner.current_generation { + let mut reader = inner.open_generation_reader(generation)?; + let (_next, mut found) = + scan_records_from(&mut reader, generation, offset)?; + records.append(&mut found); + + generation += 1; + offset = 0; + } + + // Append buffered (not-yet-flushed) records at or after `lsn`. + records.extend( + inner + .buffer + .iter() + .filter(|entry| entry.lsn >= lsn) + .cloned(), + ); + + Ok(records) + } + + /// Append `record` to the WAL, returning its assigned [`Lsn`]. + /// + /// The append is buffered in memory and only reaches disk on a call to + /// [`Logger::flush_through`]. + pub fn append(&self, record: Record) -> io::Result { + record.validate(None)?; + + let mut inner = self.lock()?; + let lsn = inner.next_lsn; + inner.next_lsn = lsn + .advanced_by(record.len() as u32) + .ok_or_else(|| { + io::Error::other( + "WAL generation offset overflow: rotate to a new generation before appending", + ) + })?; + + info!( + "wal append: lsn={lsn} txn={:?} page={:?} kind={}", + record.txn_id(), + record.page_id(), + record.kind(), + ); + + inner + .buffer + .push_back((lsn, record).into()); + + Ok(lsn) + } + + /// Read every flushed [`Record`] in the current generation from its start. + pub fn read_all_current_gen(&self) -> io::Result> { + let inner = self.lock()?; + let (_next, records) = { + let mut reader = + inner.open_generation_reader(inner.current_generation)?; + scan_records_from(&mut reader, inner.current_generation, 0)? + }; + Ok(records) + } + + /// Flush all buffered records up to and including `target_lsn` to disk. + /// + /// Records after `target_lsn` remain buffered. This writes to the OS file + /// but does not guarantee durability; use [`Logger::sync_all`] for that. + pub fn flush_through(&self, target_lsn: Lsn) -> io::Result<()> { + let mut inner = self.lock()?; + inner.flush_through(target_lsn) + } + + /// Start a new generation, directing subsequent appends to it. + /// + /// Buffered records are flushed and synced into the current generation + /// first so the previous generation is complete and durable before the + /// boundary. The new generation's addresses start at offset `0`. + pub fn rotate(&self) -> io::Result { + let mut inner = self.lock()?; + + let pending = inner + .buffer + .back() + .map(|entry| entry.lsn); + if let Some(last) = pending { + inner.flush_through(last)?; + } + inner.writer.sync_all()?; + + let next_generation = inner.current_generation + 1; + let writer = OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .open(generation_path(&inner.dir, next_generation))?; + + let next_lsn = inner + .next_lsn + .next_generation(); + inner.writer = writer; + inner.current_generation = next_generation; + inner.next_lsn = next_lsn; + + info!("wal rotate: new generation={next_generation}"); + Ok(next_generation) + } + + /// Durably persist all flushed WAL bytes (equivalent to `fsync`). + pub fn sync_all(&self) -> io::Result<()> { + let inner = self.lock()?; + inner.writer.sync_all() + } +} + +/// Scans a single `generation`'s `reader` for all valid [`Record`] entries +/// starting from byte `offset`. +/// +/// Returns the entries found along with the next [`Lsn`] within this generation +/// (i.e. the byte offset immediately past the last valid frame). A trailing +/// partial/corrupt frame is treated as the end of the valid log and the reader +/// is rewound to the last known-good position. +fn scan_records_from( + reader: &mut (impl io::Read + io::Seek), + generation: u32, + offset: u32, +) -> io::Result<(Lsn, Vec)> { + let mut offset = offset; + let mut records = Vec::new(); + reader.seek(io::SeekFrom::Start(offset as u64))?; + + loop { + match Record::read(reader) { + Ok(Some((stored_lsn, record))) => { + let lsn = Lsn::new(generation, offset); + if u64::from(lsn) != stored_lsn { + warn!( + "WAL LSN does not match offset!! expected={lsn} \ + stored={}", + Lsn::from(stored_lsn) + ); + } + offset = reader.stream_position()? as u32; + records.push(RecordEntry { lsn, record }); + } + Ok(None) => break, + Err(e) + if matches!( + e.kind(), + io::ErrorKind::UnexpectedEof | io::ErrorKind::InvalidData + ) => + { + reader.seek(io::SeekFrom::Start(offset as u64))?; + break; + } + Err(e) => return Err(e), + } + } + + Ok((Lsn::new(generation, offset), records)) +} +/// Returns the path of the `.wal` segment inside `dir`. +fn generation_path(dir: &Path, generation: u32) -> PathBuf { + dir.join(format!("{generation}.{WAL_EXTENSION}")) +} + +/// Discovers all WAL generation numbers present in `dir`. +/// +/// A generation file is any file named `.wal` where `N` parses as a `u32`. +/// The returned generations are sorted ascending. +fn discover_generations(dir: &Path) -> io::Result> { + let mut generations = Vec::new(); + + for entry in std::fs::read_dir(dir)? { + let entry = entry?; + let path = entry.path(); + + if path + .extension() + .and_then(|ext| ext.to_str()) + != Some(WAL_EXTENSION) + { + continue; + } + + if let Some(generation) = path + .file_stem() + .and_then(|stem| stem.to_str()) + .and_then(|stem| stem.parse::().ok()) + { + generations.push(generation); + } + } + + generations.sort_unstable(); + Ok(generations) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + Page, + recovery::record::{MAGIC, RECORD_FORMAT_VERSION}, + storage::page::{Mutation, MutationOffset}, + }; + use tempfile::TempDir; + + /// Open a [`Logger`] backed by a fresh temporary directory, returning the + /// guard so the directory lives for the duration of the test. + fn temp_logger() -> (TempDir, Logger) { + let dir = TempDir::new().expect("temp dir can be created"); + let logger = Logger::open(dir.path()).expect("logger can be created"); + (dir, logger) + } + + fn update_record(prev_lsn: Option) -> Record { + Record::Update { + txn_id: 10, + page_id: 7, + mutations: vec![Mutation { + offset: MutationOffset { start: 42, end: 45 }, + before: vec![0; 3].into_boxed_slice(), + after: vec![b'x', b'y', b'z'].into_boxed_slice(), + }], + prev_lsn, + } + } + + #[test] + fn append_buffers_records_until_flush_through() { + let (_dir, logger) = temp_logger(); + + let begin_transaction = Record::Begin { + txn_id: 1, + prev_lsn: None, + }; + let expected_begin_lsn = + Lsn::new(0, Record::StartSentinel.len() as u32); + let expected_update_lsn = expected_begin_lsn + .advanced_by(begin_transaction.len() as u32) + .unwrap(); + let begin_lsn = logger + .append(begin_transaction) + .expect("begin can be appended"); + let update_lsn = logger + .append(update_record(Some(begin_lsn.into()))) + .expect("update can be appended"); + + assert_eq!(begin_lsn, expected_begin_lsn); + assert_eq!(update_lsn, expected_update_lsn); + assert_eq!( + logger + .lock() + .unwrap() + .flushed_lsn, + None + ); + assert_eq!( + logger + .lock() + .unwrap() + .buffer + .len(), + // Logger also contains the Start sentinel + 3 + ); + + logger + .flush_through(begin_lsn) + .expect("flush through first record succeeds"); + + assert_eq!( + logger + .lock() + .unwrap() + .flushed_lsn, + Some(begin_lsn) + ); + assert_eq!( + logger + .lock() + .unwrap() + .buffer + .len(), + 1 + ); + + let records = logger + .read_all_current_gen() + .expect("flushed WAL records can be read"); + assert_eq!(records.len(), 2); + assert_eq!(records[1].lsn, begin_lsn); + assert_eq!(records[1].record.kind(), "begin"); + + logger + .flush_through(update_lsn) + .expect("flush through second record succeeds"); + + assert_eq!( + logger + .lock() + .unwrap() + .flushed_lsn, + Some(update_lsn) + ); + assert!( + logger + .lock() + .unwrap() + .buffer + .is_empty() + ); + + let records = logger + .read_all_current_gen() + .expect("all flushed WAL records can be read"); + assert_eq!(records.len(), 3); + assert_eq!(records[1].lsn, begin_lsn); + assert_eq!(records[2].lsn, update_lsn); + assert_eq!(records[2].record.kind(), "update"); + assert_eq!(records[2].record.prev_lsn(), Some(begin_lsn.into())); + } + + #[test] + fn logger_new_scans_existing_records_and_resumes_lsn_numbering() { + let dir = TempDir::new().expect("temp dir can be created"); + + let begin_lsn; + let commit_lsn; + let commit_len; + { + let logger = + Logger::open(dir.path()).expect("logger can be created"); + begin_lsn = logger + .append(Record::Begin { + txn_id: 1, + prev_lsn: None, + }) + .expect("begin can be appended"); + let commit = Record::Commit { + txn_id: 1, + prev_lsn: Some(begin_lsn.into()), + }; + commit_len = commit.len() as u32; + commit_lsn = logger + .append(commit) + .expect("commit can be appended"); + logger + .flush_through(commit_lsn) + .expect("records can be flushed"); + logger + .sync_all() + .expect("records can be synced"); + } + + let reopened = + Logger::open(dir.path()).expect("logger can scan existing WAL"); + + assert_eq!( + reopened + .lock() + .unwrap() + .flushed_lsn, + Some(commit_lsn) + ); + assert_eq!( + reopened + .lock() + .unwrap() + .next_lsn, + commit_lsn + .advanced_by(commit_len) + .unwrap() + ); + assert!( + reopened + .lock() + .unwrap() + .buffer + .is_empty() + ); + + let end_lsn = reopened + .append(Record::End { + txn_id: 1, + prev_lsn: Some(commit_lsn.into()), + }) + .expect("end can be appended after reopening"); + assert_eq!( + end_lsn, + commit_lsn + .advanced_by(commit_len) + .unwrap() + ); + } + + #[test] + fn record_read_rejects_invalid_magic_version_and_checksum() { + use std::io::Cursor; + + let mut invalid_magic = Cursor::new(vec![b'X', b'X']); + let err = Record::read(&mut invalid_magic) + .expect_err("invalid magic should be rejected"); + assert_eq!(err.kind(), io::ErrorKind::InvalidData); + + let mut invalid_version = + Cursor::new(vec![b'P', b'D', RECORD_FORMAT_VERSION + 1]); + let err = Record::read(&mut invalid_version) + .expect_err("invalid version should be rejected"); + assert_eq!(err.kind(), io::ErrorKind::InvalidData); + + let dir = TempDir::new().expect("temp dir can be created"); + let path = generation_path(dir.path(), 0); + { + let logger = + Logger::open(dir.path()).expect("logger can be created"); + let lsn = logger + .append(Record::Begin { + txn_id: 1, + prev_lsn: None, + }) + .expect("record can be appended"); + logger + .flush_through(lsn) + .expect("record can be flushed"); + logger + .sync_all() + .expect("record can be synced"); + } + + let mut bytes = std::fs::read(&path).expect("wal file can be read") + [Record::StartSentinel.len()..] + .to_vec(); + let last = bytes.len() - 1; + bytes[last] ^= 0xff; + + let mut corrupted = Cursor::new(bytes); + let err = Record::read(&mut corrupted) + .expect_err("checksum mismatch should be rejected"); + assert_eq!(err.kind(), io::ErrorKind::InvalidData); + } + + #[test] + fn scan_existing_stops_at_trailing_partial_frame() { + use std::io::Cursor; + + let dir = TempDir::new().expect("temp dir can be created"); + let path = generation_path(dir.path(), 0); + + let begin_lsn; + { + let logger = + Logger::open(dir.path()).expect("logger can be created"); + begin_lsn = logger + .append(Record::Begin { + txn_id: 1, + prev_lsn: None, + }) + .expect("begin can be appended"); + logger + .flush_through(begin_lsn) + .expect("record can be flushed"); + logger + .sync_all() + .expect("record can be synced"); + } + + let mut bytes = std::fs::read(&path).expect("wal file can be read"); + bytes.extend_from_slice(MAGIC.as_bytes()); + + let mut cursor = Cursor::new(bytes); + let (_next_lsn, lsns) = scan_records_from(&mut cursor, 0, 0) + .expect("trailing partial frame is treated as end of valid WAL"); + let last_lsn = lsns.last().unwrap().lsn; + + assert_eq!(last_lsn, begin_lsn); + } + + #[test] + fn get_retrieves_flushed_and_buffered_records_by_lsn() { + let (_dir, logger) = temp_logger(); + + let begin_lsn = logger + .append(Record::Begin { + txn_id: 1, + prev_lsn: None, + }) + .expect("begin can be appended"); + let update_lsn = logger + .append(update_record(Some(begin_lsn.into()))) + .expect("update can be appended"); + let commit_lsn = logger + .append(Record::Commit { + txn_id: 1, + prev_lsn: Some(update_lsn.into()), + }) + .expect("commit can be appended"); + + logger + .flush_through(update_lsn) + .expect("first two records can be flushed"); + + let begin = logger + .get(begin_lsn) + .expect("flushed begin lookup succeeds") + .expect("flushed begin is found"); + assert_eq!(begin.lsn, begin_lsn); + assert_eq!(begin.record.kind(), "begin"); + + let update = logger + .get(update_lsn) + .expect("flushed update lookup succeeds") + .expect("flushed update is found"); + assert_eq!(update.lsn, update_lsn); + assert_eq!(update.record.kind(), "update"); + assert_eq!(update.record.prev_lsn(), Some(begin_lsn.into())); + + let commit = logger + .get(commit_lsn) + .expect("buffered commit lookup succeeds") + .expect("buffered commit is found"); + assert_eq!(commit.lsn, commit_lsn); + assert_eq!(commit.record.kind(), "commit"); + assert_eq!(commit.record.prev_lsn(), Some(update_lsn.into())); + + let eof = logger + .lock() + .unwrap() + .next_lsn; + assert!( + logger + .get(eof) + .expect("lookup at EOF succeeds") + .is_none() + ); + } + + #[test] + fn records_from_reads_both_flushed_and_buffered() { + let (_dir, logger) = temp_logger(); + + let begin_lsn = logger + .append(Record::Begin { + txn_id: 1, + prev_lsn: None, + }) + .expect("begin can be appended"); + let update_lsn = logger + .append(update_record(Some(begin_lsn.into()))) + .expect("update can be appended"); + let commit_lsn = logger + .append(Record::Commit { + txn_id: 1, + prev_lsn: Some(update_lsn.into()), + }) + .expect("commit can be appended"); + let end_lsn = logger + .append(Record::End { + txn_id: 1, + prev_lsn: Some(commit_lsn.into()), + }) + .expect("end can be appended"); + + logger + .flush_through(update_lsn) + .expect("first two records can be flushed"); + + let records = logger + .records_from(begin_lsn) + .expect("flushed and buffered suffix can be read"); + assert_eq!( + records + .iter() + .map(|entry| entry.lsn) + .collect::>(), + vec![begin_lsn, update_lsn, commit_lsn, end_lsn] + ); + } + + #[test] + fn records_from_traverses_multiple_generations() { + let (_dir, logger) = temp_logger(); + + // Generation 0. + let g0_begin = logger + .append(Record::Begin { + txn_id: 1, + prev_lsn: None, + }) + .expect("begin can be appended"); + let g0_commit = logger + .append(Record::Commit { + txn_id: 1, + prev_lsn: Some(g0_begin.into()), + }) + .expect("commit can be appended"); + logger + .flush_through(g0_commit) + .expect("generation 0 can be flushed"); + + // Roll to generation 1. + let new_gen = logger + .rotate() + .expect("wal can rotate"); + assert_eq!(new_gen, 1); + + let g1_begin = logger + .append(Record::Begin { + txn_id: 2, + prev_lsn: None, + }) + .expect("begin can be appended in new generation"); + assert_eq!(g1_begin, Lsn::new(1, 0)); + + // Buffered record still in generation 1. + let g1_commit = logger + .append(Record::Commit { + txn_id: 2, + prev_lsn: Some(g1_begin.into()), + }) + .expect("commit can be appended in new generation"); + logger + .flush_through(g1_begin) + .expect("only the first generation-1 record is flushed"); + + // Traverse from the very first LSN across both generations, including + // the still-buffered final record. + let records = logger + .records_from(g0_begin) + .expect("records can be traversed across generations"); + assert_eq!( + records + .iter() + .map(|entry| entry.lsn) + .collect::>(), + vec![g0_begin, g0_commit, g1_begin, g1_commit] + ); + + // A generation-crossing lookup by LSN resolves to the right file. + let fetched = logger + .get(g1_begin) + .expect("cross-generation get succeeds") + .expect("record is found"); + assert_eq!(fetched.lsn, g1_begin); + assert_eq!(fetched.record.txn_id(), Some(2)); + } + + #[test] + fn wal_flush_guard_flushes_through_page_lsn() { + let dir = TempDir::new().expect("temp dir can be created"); + let wal = std::sync::Arc::new( + Logger::open(dir.path()).expect("logger can be created"), + ); + + let lsn = wal + .append(Record::Begin { + txn_id: 1, + prev_lsn: None, + }) + .expect("begin can be appended"); + + let guard = WalFlushGuard::new(wal.clone()); + let mut page = Page::build(vec![0; 4096]); + page.set_lsn(lsn.into()); + + guard + .before_flush(1, &page) + .expect("guard can flush WAL through page LSN"); + + assert_eq!( + wal.lock() + .unwrap() + .flushed_lsn, + Some(lsn) + ); + assert!( + wal.lock() + .unwrap() + .buffer + .is_empty() + ); + } + + #[test] + fn wal_change_guard_tracks_changes() { + let (_dir, logger) = temp_logger(); + let wal = Arc::new(logger); + assert_eq!( + wal.lock() + .unwrap() + .buffer + .len(), + 1 + ); + let mut ctx = AccessContext::txn(10, None, "insert record"); + + let guard = WalChangeGuard::new(wal.clone()); + let lsn = guard + .before_change( + &mut ctx, + 0, + vec![ + Mutation { + offset: MutationOffset { start: 80, end: 84 }, + before: vec![0; 4].into_boxed_slice(), + after: vec![1, 2, 3, 4].into_boxed_slice(), + }, + Mutation { + offset: MutationOffset { + start: 1094, + end: 1098, + }, + before: vec![0; 4].into_boxed_slice(), + after: vec![1, 2, 3, 4].into_boxed_slice(), + }, + ], + ) + .unwrap(); + + assert_eq!(ctx.lsn.unwrap(), lsn.unwrap().into()); + assert_eq!( + wal.lock() + .unwrap() + .buffer + .len(), + 2 + ); + } + + /// A commit is only "durable" once its record has been flushed through + /// and synced. + #[test] + fn commit_is_durable_before_being_reported() { + let dir = TempDir::new().expect("temp dir can be created"); + + let commit_lsn; + { + let logger = + Logger::open(dir.path()).expect("logger can be created"); + let begin = logger + .append(Record::Begin { + txn_id: 1, + prev_lsn: None, + }) + .expect("begin can be appended"); + commit_lsn = logger + .append(Record::Commit { + txn_id: 1, + prev_lsn: Some(begin.into()), + }) + .expect("commit can be appended"); + + // Commit protocol: force WAL through the Commit record, then sync. + logger + .flush_through(commit_lsn) + .expect("commit record can be flushed"); + logger + .sync_all() + .expect("commit record can be synced"); + + assert!( + logger + .lock() + .unwrap() + .flushed_lsn + .unwrap() + >= commit_lsn, + "commit must be flushed before success is reported" + ); + } + + // Reopen: the committed record survived without an explicit End. + let reopened = + Logger::open(dir.path()).expect("logger can reopen after commit"); + let commit = reopened + .get(commit_lsn) + .expect("commit lookup succeeds") + .expect("commit is durable"); + assert_eq!(commit.record.kind(), "commit"); + } + + #[test] + fn append_rejects_update_with_mismatched_before_after() { + let (_dir, logger) = temp_logger(); + + let bad = Record::Update { + txn_id: 1, + page_id: 1, + mutations: vec![Mutation { + offset: MutationOffset { start: 42, end: 45 }, + before: vec![0; 3].into_boxed_slice(), + after: vec![b'x', b'y'].into_boxed_slice(), + }], + prev_lsn: None, + }; + + let err = logger + .append(bad) + .expect_err("mismatched before/after must be rejected"); + assert_eq!(err.kind(), io::ErrorKind::InvalidData); + } + + #[test] + fn append_rejects_compensation_without_undo_next() { + let (_dir, logger) = temp_logger(); + + let bad = Record::Compensation { + txn_id: 1, + page_id: 1, + offset: 0, + after: vec![1, 2, 3], + undo_next_lsn: None, + prev_lsn: None, + }; + + let err = logger + .append(bad) + .expect_err("redo-only CLR without undo_next must be rejected"); + assert_eq!(err.kind(), io::ErrorKind::InvalidData); + } + + #[test] + fn compensation_undo_next_lsn_drives_undo_traversal() { + let (_dir, logger) = temp_logger(); + + let begin = logger + .append(Record::Begin { + txn_id: 1, + prev_lsn: None, + }) + .expect("begin can be appended"); + let update = logger + .append(update_record(Some(begin.into()))) + .expect("update can be appended"); + + // Compensate the update: its undo_next_lsn points before the update, + // i.e. at the Begin record. + let clr = logger + .append(Record::Compensation { + txn_id: 1, + page_id: 7, + offset: 42, + after: vec![b'a', b'b', b'c'], + undo_next_lsn: Some(begin.into()), + prev_lsn: Some(update.into()), + }) + .expect("clr can be appended"); + + logger + .flush_through(clr) + .expect("records can be flushed"); + + let clr_entry = logger + .get(clr) + .expect("clr lookup succeeds") + .expect("clr is found"); + let Record::Compensation { undo_next_lsn, .. } = clr_entry.record + else { + panic!("expected compensation record"); + }; + + // Undo resumes at undo_next_lsn, which resolves to the Begin record. + let resume = Lsn::from(undo_next_lsn.expect("clr carries undo_next")); + assert_eq!(resume, begin); + let resumed = logger + .get(resume) + .expect("resume lookup succeeds") + .expect("resume record is found"); + assert_eq!(resumed.record.kind(), "begin"); + } + + #[test] + fn flush_through_preserves_records_after_target() { + let (_dir, logger) = temp_logger(); + + let begin = logger + .append(Record::Begin { + txn_id: 1, + prev_lsn: None, + }) + .expect("begin can be appended"); + let update = logger + .append(update_record(Some(begin.into()))) + .expect("update can be appended"); + let commit = logger + .append(Record::Commit { + txn_id: 1, + prev_lsn: Some(update.into()), + }) + .expect("commit can be appended"); + + logger + .flush_through(update) + .expect("flush through the update only"); + + assert_eq!( + logger + .lock() + .unwrap() + .flushed_lsn, + Some(update) + ); + // The commit remains buffered and is still retrievable by LSN. + assert_eq!( + logger + .lock() + .unwrap() + .buffer + .len(), + 1 + ); + let buffered = logger + .get(commit) + .expect("buffered commit lookup succeeds") + .expect("commit still buffered"); + assert_eq!(buffered.record.kind(), "commit"); + + // The on-disk prefix stops at the flushed target. + let on_disk = logger + .read_all_current_gen() + .expect("flushed prefix can be read"); + assert_eq!( + on_disk + .iter() + .map(|e| e.lsn) + .collect::>(), + vec![Lsn::new(0, 0), begin, update] + ); + } +} diff --git a/src/recovery/mod.rs b/src/recovery/mod.rs new file mode 100644 index 0000000..a1a9a90 --- /dev/null +++ b/src/recovery/mod.rs @@ -0,0 +1,33 @@ +pub mod logger; +pub mod record; + +pub use record::{Lsn, Record, RecordEntry, RecordFlags}; +use std::io; + +/// Attempts to read `buf` bytes or an eof +/// +/// ## Errors +/// +/// If the function partially fills `buf` an [`io::ErrorKind::UnexpectedEof`] +/// will be returned. +pub(crate) fn read_exact_or_eof( + reader: &mut impl io::Read, + buf: &mut [u8], +) -> io::Result { + let mut read = 0; + + while read < buf.len() { + match reader.read(&mut buf[read..])? { + 0 if read == 0 => return Ok(false), + 0 => { + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "partial WAL frame", + )); + } + n => read += n, + } + } + + Ok(true) +} diff --git a/src/recovery/record.rs b/src/recovery/record.rs new file mode 100644 index 0000000..441f231 --- /dev/null +++ b/src/recovery/record.rs @@ -0,0 +1,494 @@ +use super::read_exact_or_eof; +use bitflags::bitflags; +use serde::{Deserialize, Serialize}; +use std::io::{self, Read}; + +use crate::{read_be, storage::page::Mutation}; + +pub const MAGIC: &str = "PO"; +pub const MAGIC_SIZE: usize = MAGIC.len(); + +pub const RECORD_FORMAT_VERSION: u8 = 1; +pub const RECORD_FORMAT_SIZE: usize = size_of::(); + +pub const FLAGS_SIZE: usize = size_of::(); +pub const LSN_SIZE: usize = size_of::(); +pub const CHECKSUM_SIZE: usize = size_of::(); +pub const PAYLOAD_LEN_SIZE: usize = size_of::(); + +pub const HEADER_SIZE: usize = MAGIC_SIZE + + RECORD_FORMAT_SIZE + + FLAGS_SIZE + + LSN_SIZE + + CHECKSUM_SIZE + + PAYLOAD_LEN_SIZE; + +#[derive(Debug, Clone)] +pub struct RecordEntry { + pub lsn: Lsn, + pub record: Record, +} + +impl RecordEntry { + pub fn as_bytes(&self) -> std::io::Result> { + let mut out = vec![]; + + let value = self.record.as_bytes()?; + let crc = crate::CRC32C.checksum(&value); + + out.extend(MAGIC.as_bytes()); + out.push(RECORD_FORMAT_VERSION); + out.push(RecordFlags::empty().bits()); + out.extend(u64::from(self.lsn).to_be_bytes()); + out.extend(crc.to_be_bytes().as_ref()); + out.extend((value.len() as u32).to_be_bytes()); + out.extend(value); + + Ok(out) + } +} + +/// [`Lsn`] is a physical log address. +/// +/// It is encoded as a `u64` where the high 32 bits are the WAL `generation` +/// and the low 32 bits are the byte `offset` within that generation's file. +/// +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct Lsn { + pub generation: u32, + pub offset: u32, +} + +impl Lsn { + /// Construct an [`Lsn`] from its `generation` and byte `offset`. + pub const fn new(generation: u32, offset: u32) -> Self { + Self { generation, offset } + } +} + +bitflags! { + #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] + pub struct RecordFlags: u8 {} +} + +/// [`Record`] is an entry in the Write-Ahead Log. +/// +/// The [`Record`] keeps track of actions taken against pages in memory so they +/// can be redone during recovery or undone when a transaction aborts. +/// +/// Record Layout: +/// +/// `[`0..2`]` bytes`[`2`]` magic +/// `[`2..3`]` u8 format +/// `[`3..4`]` u8 flags +/// `[`4..12`]` u64 lsn +/// `[`12..16`]` u32 crc +/// `[`16..20`]` u32 payload_len +/// `[`20..`]` bytes payload +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum Record { + /// Marks the beginning of log. + StartSentinel, + + /// Marks the beginning of a transaction. + Begin { txn_id: u64, prev_lsn: Option }, + + /// Describes a byte-range update made to a page. + /// + /// The `before` bytes are used to undo the update and the `after` bytes are + /// used to redo it. + Update { + txn_id: u64, + page_id: u64, + mutations: Vec, + prev_lsn: Option, + }, + + /// Marks a transaction as committed. + Commit { txn_id: u64, prev_lsn: Option }, + + /// Marks a transaction as aborted. + Abort { txn_id: u64, prev_lsn: Option }, + + /// Describes an undo action that has already been applied. + /// + /// Compensation records are redo-only and point at the next log record that + /// should be undone for the transaction. + Compensation { + txn_id: u64, + page_id: u64, + offset: u16, + after: Vec, + undo_next_lsn: Option, + prev_lsn: Option, + }, + + /// Marks the end of a transaction's log records. + End { txn_id: u64, prev_lsn: Option }, + + /// Marks the beginning of a checkpoint. + BeginCheckpoint, + + /// Marks the end of a checkpoint. + EndCheckpoint, +} + +impl Record { + /// Reads a single [`Record`] from `reader`. Returning the stored `lsn` + /// and [`Record`] payload. + /// + /// The record is read from the reader's current position and validated against + /// the expected on-disk record format. + /// + /// ## Errors + /// + /// This function returns an error when bytes cannot be read from `reader` or + /// when the bytes read do not describe a valid [`Record`]. + pub fn read(reader: &mut impl Read) -> io::Result> { + let mut magic = [0; MAGIC_SIZE]; + if !read_exact_or_eof(reader, &mut magic)? { + return Ok(None); + } + if magic != MAGIC.as_bytes() { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "invalid WAL record magic", + )); + } + + let version = read_be!(reader, u8); + if version != RECORD_FORMAT_VERSION { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "unsupported WAL record version", + )); + } + + let flags = read_be!(reader, u8); + let _flags = RecordFlags::from_bits(flags).ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidData, + "corrupted WAL record flags", + ) + })?; + + let lsn = read_be!(reader, u64); + let crc = read_be!(reader, u32); + let payload_len = read_be!(reader, u32); + + if payload_len > u16::MAX as u32 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "WAL payload too large", + )); + } + + let mut payload = vec![0; payload_len as usize]; + reader.read_exact(&mut payload)?; + + let actual_crc = crate::CRC32C.checksum(&payload); + if actual_crc != crc { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "WAL record checksum mismatch", + )); + } + + let record: Record = postcard::from_bytes(&payload[..]) + .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; + record.validate(None)?; + + Ok(Some((lsn, record))) + } + + #[allow(clippy::len_without_is_empty)] + pub fn len(&self) -> usize { + HEADER_SIZE + + self + .as_bytes() + .expect("should be able to marshal record") + .len() + } + + pub fn txn_id(&self) -> Option { + match self { + Self::Begin { txn_id, .. } + | Self::Update { txn_id, .. } + | Self::Commit { txn_id, .. } + | Self::Abort { txn_id, .. } + | Self::Compensation { txn_id, .. } + | Self::End { txn_id, .. } => Some(*txn_id), + Self::BeginCheckpoint + | Self::EndCheckpoint + | Self::StartSentinel => None, + } + } + + pub fn page_id(&self) -> Option { + match self { + Self::Update { page_id, .. } + | Self::Compensation { page_id, .. } => Some(*page_id), + _ => None, + } + } + + pub fn prev_lsn(&self) -> Option { + match self { + Self::Begin { prev_lsn, .. } + | Self::Update { prev_lsn, .. } + | Self::Commit { prev_lsn, .. } + | Self::Abort { prev_lsn, .. } + | Self::Compensation { prev_lsn, .. } + | Self::End { prev_lsn, .. } => *prev_lsn, + Self::BeginCheckpoint + | Self::EndCheckpoint + | Self::StartSentinel => None, + } + } + + pub fn kind(&self) -> &'static str { + match self { + Self::Begin { .. } => "begin", + Self::Update { .. } => "update", + Self::Commit { .. } => "commit", + Self::Abort { .. } => "abort", + Self::Compensation { .. } => "clr", + Self::End { .. } => "end", + Self::BeginCheckpoint => "begin_checkpoint", + Self::EndCheckpoint => "end_checkpoint", + Self::StartSentinel => "log_start", + } + } + + pub fn as_bytes(&self) -> io::Result> { + let payload = postcard::to_allocvec(self) + .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; + if payload.len() > u16::MAX as usize { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "WAL record too large", + )); + } + + Ok(payload.into()) + } + + /// Validate that this record is structurally sound before it is applied to + /// or read from the log. + /// + /// These checks reject records that recovery could never apply correctly: + /// + /// - `Update.before` and `Update.after` must have equal length so an undo + /// can restore exactly the bytes a redo replaced. + /// - `Update`/`Compensation` payloads must be non-empty (a zero-length + /// change carries no redo/undo information). + /// - `Compensation` records are redo-only and must carry an + /// `undo_next_lsn` so undo can continue past the compensated action. + /// + /// A `page_size` hint, when provided, additionally requires the changed + /// byte range (`offset + len`) to fit within a single page. + pub fn validate(&self, page_size: Option) -> io::Result<()> { + let invalid = |msg: &str| { + io::Error::new(io::ErrorKind::InvalidData, msg.to_string()) + }; + + match self { + Self::Update { mutations, .. } => { + for mutation in mutations.iter() { + if mutation.before.len() != mutation.after.len() { + return Err(invalid( + "WAL Update before/after length mismatch", + )); + } + + if mutation.after.is_empty() { + return Err(invalid("WAL Update carries no bytes")); + } + + Self::check_range( + mutation.offset.start as u16, + mutation.after.len(), + page_size, + )?; + } + } + Self::Compensation { + offset, + after, + undo_next_lsn, + .. + } => { + if after.is_empty() { + return Err(invalid("WAL Compensation carries no bytes")); + } + if undo_next_lsn.is_none() { + return Err(invalid( + "WAL Compensation missing undo_next_lsn", + )); + } + Self::check_range(*offset, after.len(), page_size)?; + } + _ => {} + } + + Ok(()) + } + + /// Ensure a changed byte range `[offset, offset + len)` fits within a page. + fn check_range( + offset: u16, + len: usize, + page_size: Option, + ) -> io::Result<()> { + let Some(page_size) = page_size else { + return Ok(()); + }; + + let end = offset as usize + len; + if end > page_size as usize { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!( + "WAL record byte range {offset}+{len} exceeds page size \ + {page_size}" + ), + )); + } + + Ok(()) + } +} + +impl Lsn { + /// The [`Lsn`] of the record that would follow a record of `len` bytes + /// written at `self`, staying within the same generation. + /// + /// Returns `None` when the addition would overflow the 32-bit offset field, + /// i.e. the generation has grown beyond 4 GiB. The caller must rotate to a + /// new generation before appending further. + pub fn advanced_by(self, len: u32) -> Option { + let offset = self.offset.checked_add(len)?; + Some(Lsn { + generation: self.generation, + offset, + }) + } + + /// The first [`Lsn`] of the generation following `self`. + pub fn next_generation(self) -> Lsn { + Lsn { + generation: self.generation + 1, + offset: 0, + } + } +} + +impl From for u64 { + fn from(value: Lsn) -> Self { + ((value.generation as u64) << 32) | (value.offset as u64) + } +} + +impl From for Lsn { + fn from(value: u64) -> Lsn { + let generation = (value >> 32) as u32; + let offset = (value & 0xffff_ffff) as u32; + Lsn { generation, offset } + } +} + +impl std::fmt::Display for Lsn { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}:{}", self.generation, self.offset) + } +} + +impl PartialOrd for RecordEntry { + fn partial_cmp(&self, other: &Self) -> Option { + self.lsn + .partial_cmp(&other.lsn) + } +} + +impl PartialEq for RecordEntry { + fn eq(&self, other: &Self) -> bool { + self.lsn.eq(&other.lsn) + } +} + +impl From<(Lsn, Record)> for RecordEntry { + fn from((lsn, record): (Lsn, Record)) -> Self { + RecordEntry { lsn, record } + } +} + +#[cfg(test)] +mod test { + use crate::storage::page::MutationOffset; + + use super::*; + + fn update_record(prev_lsn: Option) -> Record { + Record::Update { + txn_id: 10, + page_id: 7, + mutations: vec![Mutation { + offset: MutationOffset { start: 42, end: 45 }, + before: vec![0; 3].into_boxed_slice(), + after: vec![b'x', b'y', b'z'].into_boxed_slice(), + }], + prev_lsn, + } + } + + #[test] + fn record_metadata_helpers_return_expected_values() { + let update = update_record(Some(3)); + assert_eq!(update.txn_id(), Some(10)); + assert_eq!(update.page_id(), Some(7)); + assert_eq!(update.prev_lsn(), Some(3)); + assert_eq!(update.kind(), "update"); + + let clr = Record::Compensation { + txn_id: 11, + page_id: 8, + offset: 9, + after: vec![1, 2, 3], + undo_next_lsn: Some(4), + prev_lsn: Some(5), + }; + assert_eq!(clr.txn_id(), Some(11)); + assert_eq!(clr.page_id(), Some(8)); + assert_eq!(clr.prev_lsn(), Some(5)); + assert_eq!(clr.kind(), "clr"); + + let checkpoint = Record::BeginCheckpoint; + assert_eq!(checkpoint.txn_id(), None); + assert_eq!(checkpoint.page_id(), None); + assert_eq!(checkpoint.prev_lsn(), None); + assert_eq!(checkpoint.kind(), "begin_checkpoint"); + } + + #[test] + fn validate_rejects_byte_range_beyond_page_size() { + let update = Record::Update { + txn_id: 1, + page_id: 1, + mutations: vec![Mutation { + offset: MutationOffset { + start: 4094, + end: 4098, + }, + before: vec![0; 4].into_boxed_slice(), + after: vec![1, 1, 1, 1].into_boxed_slice(), + }], + prev_lsn: None, + }; + + assert!(update.validate(None).is_ok()); + let err = update + .validate(Some(4096)) + .expect_err("range past page size must be rejected"); + assert_eq!(err.kind(), io::ErrorKind::InvalidData); + } +} diff --git a/src/storage/btree.rs b/src/storage/btree.rs new file mode 100644 index 0000000..2026407 --- /dev/null +++ b/src/storage/btree.rs @@ -0,0 +1,147 @@ +use std::{path::PathBuf, sync::Arc}; + +use log::trace; + +use super::{ + AccessContext, Cursor, MetaPage, Page, PageFlags, Pager, StorageError, + TablePage, + constants::page::META_PAGE_ID, + error::Result, + page::{AnyPage, AnyPageMut}, +}; + +const TREE_CONTEXT: AccessContext = + AccessContext::maintenance("tree operation"); + +/// [Tree] is a wrapping structure that signifies a `Blink-Tree` index-organized +/// table that can be traversed by [`Cursor`]. +pub struct Tree { + pub(crate) inner: Arc, +} + +/// Provides standardized access for [`Cursor`] to navigate the [`Tree`] +pub(crate) struct TreeInner { + pager: Pager, +} + +impl TreeInner { + /// Returns the root of the [`Tree`] + pub fn root(&self) -> Result { + self.meta_page(&TREE_CONTEXT, |p| p.tree_root() as usize) + } + + /// Set current tree root to `root` + pub fn set_root(&self, ctx: &mut AccessContext, root: u32) -> Result<()> { + self.mut_meta_page(ctx, |mut p| { + p.set_tree_root(root); + }) + } + + /// Creates a new B-Tree root page and updates the meta page to point to it. + /// + /// The old root page is not updated in any way. It's the responsibility of + /// the caller to do so. + pub fn create_root( + &self, + ctx: &mut AccessContext, + flags: PageFlags, + ) -> Result<()> { + let new_root = self + .pager + .allocate_page(ctx, flags | PageFlags::IsRoot)?; + self.set_root(ctx, new_root as u32)?; + Ok(()) + } +} + +impl TreeInner { + pub fn meta_page( + &self, + ctx: &AccessContext, + f: impl FnOnce(MetaPage<&Page>) -> R, + ) -> Result { + self.pager + .page(META_PAGE_ID, ctx, |p| match p { + AnyPage::Meta(p) => Ok(f(p)), + _ => Err(StorageError::CorruptedData(format!( + "expected metadata page at ID {}", + META_PAGE_ID + ))), + })? + } + + pub fn mut_meta_page( + &self, + ctx: &mut AccessContext, + f: impl FnOnce(MetaPage<&mut Page>) -> R, + ) -> Result { + self.pager + .mut_page(META_PAGE_ID, ctx, |p| match p { + AnyPageMut::Meta(p) => Ok(f(p)), + _ => Err(StorageError::CorruptedData(format!( + "expected metadata page at ID {}", + META_PAGE_ID + ))), + })? + } + + pub fn table_page( + &self, + ctx: &AccessContext, + page_id: usize, + f: impl FnOnce(TablePage<&Page>) -> R, + ) -> Result { + self.pager + .page(page_id, ctx, |p| match p { + AnyPage::Table(p) => Ok(f(p)), + _ => Err(StorageError::CorruptedData(format!( + "expected table page at ID {}", + META_PAGE_ID + ))), + })? + } + + pub fn mut_table_page( + &self, + ctx: &mut AccessContext, + page_id: usize, + f: impl FnOnce(TablePage<&mut Page>) -> R, + ) -> Result { + self.pager + .mut_page(page_id, ctx, |p| match p { + AnyPageMut::Table(p) => Ok(f(p)), + _ => Err(StorageError::CorruptedData(format!( + "expected table page at ID {}", + META_PAGE_ID + ))), + })? + } +} + +impl Tree { + /// Load/Create a [`Tree`] at the given `path`. Initiating table meta page + /// and tree root if not initiated. + pub fn load(path: impl Into, cache_size: usize) -> Result { + let tree = Self { + inner: Arc::new(TreeInner { + pager: Pager::open(path, cache_size)?, + }), + }; + + let root = tree.inner.root()?; + if root == 0 { + let mut ctx = AccessContext::maintenance("initialize tree"); + tree.inner + .create_root(&mut ctx, PageFlags::IsLeaf)?; + trace!("tree root initialize: context={ctx:?}"); + } + + Ok(tree) + } + + /// Creates a new [`Cursor`] that can be used to traverse + /// the tree. + pub fn cursor(&self) -> Result { + Cursor::from_root(&self.inner) + } +} diff --git a/src/storage/constants.rs b/src/storage/constants.rs new file mode 100644 index 0000000..d4e63b0 --- /dev/null +++ b/src/storage/constants.rs @@ -0,0 +1,73 @@ +pub mod page { + //! Constant values utilized in page layout. + //! + + /// Page identifier reserved for the root page. + /// + /// Page identifiers are one-based; page id `0` is invalid. + pub const META_PAGE_ID: usize = 1; + + /// The default maximum allowed gap during page diff. + pub const DEFAULT_MERGE_MUTATION_GAP: usize = 4; + + pub const MAGIC: [u8; MAGIC_SIZE] = [25, 3, 20, 26, 7, 4, 8, 0]; + pub const MAGIC_SIZE: usize = 8; + pub const MAGIC_OFFSET: usize = 0; + + pub const CHECKSUM_OFFSET: usize = MAGIC_OFFSET + MAGIC_SIZE; + pub const CHECKSUM_SIZE: usize = size_of::(); + + pub const FLAGS_OFFSET: usize = CHECKSUM_OFFSET + CHECKSUM_SIZE; + pub const FLAGS_SIZE: usize = size_of::(); + + pub const FREESPACE_START_OFFSET: usize = FLAGS_OFFSET + FLAGS_SIZE; + pub const FREESPACE_START_SIZE: usize = size_of::(); + + pub const FREESPACE_END_OFFSET: usize = + FREESPACE_START_OFFSET + FREESPACE_START_SIZE; + pub const FREESPACE_END_SIZE: usize = size_of::(); + + pub const FREESPACE_OFFSET: usize = + FREESPACE_END_OFFSET + FREESPACE_END_SIZE; + pub const FREESPACE_SIZE: usize = size_of::(); + + pub const NUM_KEY_OFFSET: usize = FREESPACE_OFFSET + FREESPACE_SIZE; + pub const NUM_KEY_SIZE: usize = size_of::(); + + pub const PAGE_SIZE_OFFSET: usize = NUM_KEY_OFFSET; + pub const PAGE_SIZE_SIZE: usize = NUM_KEY_SIZE; + + pub const LSN_OFFSET: usize = NUM_KEY_OFFSET + NUM_KEY_SIZE; + pub const LSN_SIZE: usize = size_of::(); + + pub const LEFT_SIBLING_OFFSET: usize = LSN_OFFSET + LSN_SIZE; + pub const LEFT_SIBLING_SIZE: usize = size_of::(); + + pub const FORMAT_VERSION_OFFSET: usize = LSN_OFFSET + LSN_SIZE; + pub const FORMAT_VERSION: u8 = 1; + pub const FORMAT_VERSION_SIZE: usize = size_of::(); + + pub const BTREE_ROOT_OFFSET: usize = + FORMAT_VERSION_OFFSET + FORMAT_VERSION_SIZE; + pub const BTREE_ROOT_SIZE: usize = size_of::(); + + pub const NEXT_PAGE_OFFSET: usize = BTREE_ROOT_OFFSET + BTREE_ROOT_SIZE; + + pub const RIGHT_SIBLING_OFFSET: usize = + LEFT_SIBLING_OFFSET + LEFT_SIBLING_SIZE; + pub const RIGHT_SIBLING_SIZE: usize = size_of::(); + + pub const RIGHT_MOST_POINTER_OFFSET: usize = + RIGHT_SIBLING_OFFSET + RIGHT_SIBLING_SIZE; + pub const RIGHT_MOST_POINTER_SIZE: usize = size_of::(); + + pub const NODE_HIGH_KEY_OFFSET: usize = + RIGHT_MOST_POINTER_OFFSET + RIGHT_MOST_POINTER_SIZE; + pub const NODE_HIGH_KEY_SIZE: usize = size_of::(); + + pub const OVERFLOW_OFFSET_OFFSET: usize = + NODE_HIGH_KEY_OFFSET + NODE_HIGH_KEY_SIZE; + pub const OVERFLOW_OFFSET_SIZE: usize = size_of::(); + + pub const HEADER_SIZE: usize = 64; +} diff --git a/src/storage/cursor.rs b/src/storage/cursor.rs new file mode 100644 index 0000000..da3fc73 --- /dev/null +++ b/src/storage/cursor.rs @@ -0,0 +1,762 @@ +use std::{collections::VecDeque, sync::Arc}; + +use log::{debug, trace, warn}; + +/// Maximum hops allowed +const MAX_HOPS: usize = 64; + +use super::{ + AccessContext, Page, PageFlags, PageView, StorageError, TablePage, + btree::TreeInner, constants::page::HEADER_SIZE, error::Result, +}; + +use crate::{ + KEYCELL_SIZE, Key, KeyCell, VALUECELL_KEY_SIZE, VALUECELL_VALUE_LEN_SIZE, + ValueCell, +}; + +const CURSOR_CONTEXT: AccessContext = + AccessContext::maintenance("cursor operation"); + +pub struct Cursor { + breadcrumbs: Vec<(usize, KeyCell)>, + current_page: usize, + tree: Arc, +} + +#[derive(Debug, Clone)] +struct CursorValue { + pub idx: usize, + pub val_cell: ValueCell, +} + +impl From<(usize, ValueCell)> for CursorValue { + fn from(value: (usize, ValueCell)) -> Self { + Self { + idx: value.0, + val_cell: value.1, + } + } +} + +impl Cursor { + /// Initialize a [`Cursor`] at `root` position of [`super::Tree`] + pub(crate) fn from_root(tree: &Arc) -> Result { + Ok(Self { + breadcrumbs: vec![], + current_page: tree.root()?, + tree: tree.clone(), + }) + } + + /// Inserts a new `key` & `value` into the tree. + /// + /// TODO: Decide on self-split or user-initiated... + pub fn insert( + &mut self, + ctx: &mut AccessContext, + key: &Key, + value: Vec, + ) -> Result> { + self.find(&CURSOR_CONTEXT, key)?; + let new_cell = ValueCell { + key: *key, + value: value.into(), + }; + + match self.read_value(ctx, key)? { + Some(old_cell) => { + self.insert_existing(ctx, &old_cell, &new_cell)?; + Ok(Some(old_cell.val_cell)) + } + None => { + self.insert_new(ctx, &new_cell)?; + Ok(None) + } + } + } + + /// Locate the `Leaf` page that would hold `key` and return `key` + /// bytes if present. This action will leave the cursor at the leaf node. + pub fn search(&mut self, key: &Key) -> Result> { + self.find(&CURSOR_CONTEXT, key)?; + + let out = self.read_value(&CURSOR_CONTEXT, key)?; + trace!( + "tree cursor search complete: page={} key={} found={} depth={}", + self.current_page, + key, + out.is_some(), + self.breadcrumbs.len() + ); + + Ok(out.map(|cv| cv.val_cell)) + } + + /// Attempts to locate `key` in tree. Navigating to `key` position while + /// validating that `MAX_HOPS` is not exceeded. + pub fn find(&mut self, ctx: &AccessContext, key: &Key) -> Result<()> { + for attempt in 0..MAX_HOPS { + if attempt >= MAX_HOPS { + return Err(StorageError::RecursionDetected( + "cursor reached maximum hops", + )); + } + + trace!( + "tree cursor search: key={key} current_page={} depth={}", + self.current_page, + self.breadcrumbs.len() + ); + + if !self.find_inner(&ctx, key)? { + break; + } + } + + Ok(()) + } +} + +impl Cursor { + /// Locates the next hop to `key` from `Self::current_page`, updating + /// current page and pushing breadcrumb. + /// + /// This function will return `true` once a `Leaf` page is located. + fn find_inner(&mut self, ctx: &AccessContext, key: &Key) -> Result { + let next_location = self.tree.table_page( + ctx, + self.current_page, + |page| -> Result> { + let flags = page.flags(); + if flags.contains(PageFlags::IsLeaf) { + return Ok(None); + } + + let count = page.num_keys() as usize; + let keys = self.key_range(0, count, &page)?; + + let next_page = + match keys.binary_search(&KeyCell::with_key(key)) { + Ok(pos) => keys[pos].clone(), + Err(pos) => { + if pos >= keys.len() { + KeyCell { + key: page.high_key() as u32, + offset: page.right_pointer().ok_or( + StorageError::CorruptedData( + format!( + "unable to locate pointer for high key {} in page {}", page.high_key(), self.current_page)) + )? + } + } else { + keys[pos - 1].clone() + } + } + }; + Ok(Some(next_page)) + }, + )??; + + trace!( + "cursor find next positon: key={} next={:?}", + key, next_location + ); + + if let Some(page) = next_location { + self.breadcrumbs + .push((self.current_page, page.clone())); + self.current_page = page.offset as usize; + return Ok(false); + } + Ok(true) + } + + /// Inserts new value cell in the page cursor is currently in + fn insert_new( + &self, + ctx: &mut AccessContext, + cell: &ValueCell, + ) -> Result<()> { + self.tree + .mut_table_page(ctx, self.current_page, |mut page| { + let key_count = page.num_keys() as usize; + let keys = self.key_range(0, key_count, &page)?; + let new_count = key_count + 1; + + let free_space = page.free_space() as usize; + let free_space_start = page.free_space_start() as usize; + let free_space_end = page.free_space_end() as usize; + + if cell.len() > free_space { + return Err(StorageError::WouldSplit); + } + + let new_space_end = free_space_end - cell.len(); + let new_space_start = HEADER_SIZE + (new_count * KEYCELL_SIZE); + let new_space = free_space - cell.len(); + + if new_space_end < free_space_start { + return Err(StorageError::FragmentedPage); + } + + let key = KeyCell { + key: cell.key, + offset: new_space_end as u32, + }; + + trace!( + "cursor insert: page={} key={:?} new_space={} space=(start={}, end={})", + self.current_page, + key, + new_space, + new_space_start, + new_space_end, + ); + + match keys.binary_search(&key) { + Err(pos) => { + let key_offset = HEADER_SIZE + (pos * KEYCELL_SIZE); + let (_left, right) = keys.split_at(pos); + let mut key_bytes = + Into::<[u8; KEYCELL_SIZE]>::into(&key).to_vec(); + let after_insert = right + .iter() + .flat_map(Into::<[u8; KEYCELL_SIZE]>::into) + .collect::>(); + key_bytes.extend(after_insert); + + let value_bytes: Box<[u8]> = cell.into(); + + page.mut_cell(new_space_end, free_space_end) + .copy_from_slice(&value_bytes); + page.mut_cell(key_offset, new_space_start) + .copy_from_slice(&key_bytes); + + page.set_free_space_end(new_space_end as u16); + page.set_free_space_start(new_space_start as u16); + page.set_free_space(new_space as u16); + page.set_num_keys(new_count as u16); + + Ok(()) + } + Ok(_) => Err(StorageError::NotAllowed( + "called insert new on existing key", + )), + } + })? + } + + /// Update `old` in the page [`Cursor`] is in with `updated`. + fn insert_existing( + &self, + ctx: &mut AccessContext, + old: &CursorValue, + updated: &ValueCell, + ) -> Result<()> { + let inserted = self.tree + .mut_table_page(ctx, self.current_page, |mut page| -> Result { + let key = self + .key_range(old.idx, old.idx + 1, &page)? + .pop() + .expect("should have old key"); + + if old.val_cell.len() >= updated.len() { + self.reclaim_existing(&mut page, &key, &old.val_cell, updated); + Ok(true) + } else { + warn!( + "cursor insert existing: page={} unreclaimed={} allocated={}", + self.current_page, + old.val_cell.len(), + updated.len(), + ); + self.format_value(&mut page, &key)?; + Ok(false) + } + })??; + + if !inserted { + self.insert_new(ctx, updated)?; + } + Ok(()) + } + + /// Attempts to read [`CursorValue`] of associated `key` in page. + /// + /// ## Errors + /// + /// This funtion will error if a read attempt is made on an internal + /// [`Page`]. Utilize `Self::key_range` instead. + fn read_value( + &self, + ctx: &AccessContext, + key: &Key, + ) -> Result> { + self.tree + .table_page(ctx, self.current_page, |page| { + let flags = page.flags(); + if !flags.contains(PageFlags::IsLeaf) { + return Err(StorageError::NotAllowed( + "attempt to read value from non-leaf page", + )); + } + + let count = page.num_keys() as usize; + let keys = self.key_range(0, count, &page)?; + trace!("cursor read: key={} page={}", key, self.current_page); + + match keys.binary_search(&KeyCell::with_key(key)) { + Ok(pos) => { + let key = keys[pos].clone(); + let value = ValueCell::from_bytes( + page.cell_from(key.offset as usize), + )?; + + debug!( + "cursor read success: key={:?} page={}", + key, self.current_page + ); + Ok(Some((pos, value).into())) + } + Err(_) => Ok(None), + } + })? + } + + /// Retrieves list of [`KeyCell`] starting from logical `start_index` up till + /// `end_index`. This function returns [R_start..R_end). + /// + /// ## Panics + /// + /// This function will panic if the range(start_index..end_index) is not valid + /// for [`Page`] `num_keys`. + fn key_range( + &self, + start_index: usize, + end_index: usize, + page: &impl PageView, + ) -> Result> { + let start_offset = HEADER_SIZE + (start_index * KEYCELL_SIZE); + let end_offset = HEADER_SIZE + (end_index * KEYCELL_SIZE); + + let mut bytes = vec![0; end_offset - start_offset]; + let mut out = Vec::new(); + bytes.copy_from_slice(page.cell(start_offset, end_offset)); + + let mut keys = bytes + .chunks(KEYCELL_SIZE) + .map(KeyCell::from_bytes) + .collect::>>(); + + debug!( + "cursor key range: start={}:{} end={}:{}", + start_index, start_offset, end_index, end_offset + ); + + for r in keys.drain(..) { + out.push(r?); + } + + Ok(out) + } + + /// Wipes the [`ValueCell`] pointed to by `key`, removing `key` from page in + /// the process. + fn format_value( + &self, + page: &mut TablePage<&mut Page>, + key: &KeyCell, + ) -> Result<()> { + if !page + .flags() + .contains(PageFlags::IsLeaf) + { + panic!("attempt to format internal page key"); + } + + self.remove_key(page, key)?; + + let start = key.offset as usize; + let value_len = ValueCell::value(page.cell_from(start))?.len(); + let value_len = + VALUECELL_KEY_SIZE + VALUECELL_VALUE_LEN_SIZE + value_len; + let initial_free_space = page.free_space(); + + trace!( + "cursor format value: key={} offset={} initial_space={} value_len={}", + key.key, start, initial_free_space, value_len + ); + + page.mut_cell(start, start + value_len) + .copy_from_slice(vec![0; value_len].as_ref()); + page.set_free_space(initial_free_space + value_len as u16); + + if start == page.free_space_end() as usize { + page.set_free_space_end((start + value_len) as u16); + } + Ok(()) + } + + /// Removes `key` in the table, updating table information as necessary + fn remove_key( + &self, + page: &mut TablePage<&mut Page>, + key: &KeyCell, + ) -> Result<()> { + let mut keys = self.key_range(0, page.num_keys() as usize, page)?; + + match keys.binary_search(key) { + Ok(pos) => { + keys.remove(pos); + let key_bytes = keys + .iter() + .flat_map(Into::<[u8; KEYCELL_SIZE]>::into) + .collect::>(); + let end = HEADER_SIZE + (keys.len() * KEYCELL_SIZE); + let initial_space = page.free_space(); + let new_space = initial_space - KEYCELL_SIZE as u16; + + trace!( + "cursor remove key: key={} count_after={} space_after={}", + key.key, + keys.len(), + new_space + ); + + page.set_num_keys(keys.len() as u16); + page.mut_cell(HEADER_SIZE, end) + .clone_from_slice(&key_bytes); + page.set_free_space_start(end as u16); + page.set_free_space(new_space); + + Ok(()) + } + Err(_) => Err(StorageError::NotAllowed( + "attempt to remove non-existent key", + )), + } + } + + /// Reclaim the space previously held by `old` at `key`. This function will + /// overwrite the `old` bytes with `updated` bytes, updating free_space when + /// necessary. + /// + /// ## Panics + /// + /// This function requires that `old` is of greater len or equal to + /// `updated`. This check must be done by the caller. + fn reclaim_existing( + &self, + page: &mut TablePage<&mut Page>, + key: &KeyCell, + old: &ValueCell, + updated: &ValueCell, + ) { + let diff = old.len() - updated.len(); + let new_space = page.free_space() as usize + diff; + let value_bytes: Box<[u8]> = updated.into(); + let offset = key.offset as usize; + trace!( + "cursor reclaim: key={} initial_space={} new_space={} diff={}", + key.key, + page.free_space(), + new_space, + diff + ); + + page.mut_cell(offset, offset + value_bytes.len()) + .clone_from_slice(&value_bytes); + page.set_free_space(new_space as u16); + } +} + +#[derive(Debug, Clone, Copy)] +pub struct DebugOpt { + /// Stop descent after `max_depth` level + pub max_depth: usize, + /// Maximum children to show per internal + pub max_children: usize, + /// Maximum keys to show per leaf + pub max_leaf_keys: usize, +} + +impl Default for DebugOpt { + fn default() -> Self { + Self { + max_depth: 3, + max_children: 10, + max_leaf_keys: 10, + } + } +} + +impl Cursor { + pub fn debug_print(&self, opt: DebugOpt) -> Result<()> { + // NOTE: Test with multi node tree + let mut queue = VecDeque::new(); + queue.push_front((self.tree.root()?, 0, 0)); + + let depth_marker = |depth: usize, key: usize| -> String { + if depth >= 1 { + format!("┗{}(<={})", "━".repeat(depth), key) + } else { + String::default() + } + }; + + while !queue.is_empty() { + let (page, depth, key) = queue + .pop_front() + .expect("queue isn't empty"); + + if depth > opt.max_depth { + println!(" <...pruned depth...> "); + } + + self.tree.table_page( + &CURSOR_CONTEXT, + page, + |p| -> Result<()> { + let root = if p + .flags() + .contains(PageFlags::IsRoot) + { + "root" + } else { + "" + }; + + print!("[{}{}#{page} ", depth_marker(depth, key), root); + + let key_count = p.num_keys() as usize; + + match p + .flags() + .contains(PageFlags::IsLeaf) + { + true => { + let end_index = key_count.min(opt.max_leaf_keys); + let keys = self + .key_range(0, end_index, &p)? + .iter() + .map(|k| k.key) + .collect::>(); + let pruned = if end_index < key_count { + format!( + " (+{} keys hidden)", + key_count - end_index + ) + } else { + String::default() + }; + + print!( + "LEAF n={} lsn={} hi={}] keys: {keys:?}{pruned}", + p.num_keys(), + p.latest_lsn(), + p.high_key() + ); + } + _ => { + let end_index = key_count.min(opt.max_children); + self.key_range(0, end_index, &p)?.iter().for_each(|k| { + queue.push_back(( + k.offset as usize, + depth + 1, + k.key as usize, + )) + }); + + let pruned = if end_index < key_count { + format!( + " (+{} children pruned)", + key_count - end_index + ) + } else { + String::default() + }; + + print!( + "INTERNAL n={} lsn={} hi={}] {pruned}", + p.num_keys(), + p.latest_lsn(), + p.high_key() + ); + } + } + if let Some(right) = p.right_pointer() { + print!(" --right→ #{right}") + } + Ok(()) + }, + )??; + } + + Ok(()) + } +} + +#[cfg(test)] +mod test { + use tempfile::TempDir; + + use crate::{ + AccessContext, KEYCELL_SIZE, KeyCell, ValueCell, storage::Tree, + }; + + fn temp_tree() -> (TempDir, Tree) { + let dir = TempDir::new().expect("can create tempdir"); + let path = dir.path().join("store.db"); + let tree = Tree::load(path, 8).expect("can load tree"); + + (dir, tree) + } + + fn filled_leaf_root() -> (TempDir, Tree) { + let (dir, tree) = temp_tree(); + + let root = tree + .inner + .root() + .expect("can retrieve tree root"); + let mut ctx = AccessContext::maintenance("test leaf split"); + assert!(root != 0, "root should be a valid page ID"); + tree.inner + .mut_table_page(&mut ctx, root, |mut p| { + let mut initial_start = p.free_space_start() as usize; + let mut initial_end = p.free_space_end() as usize; + + p.set_free_space(0); + p.set_num_keys(4); + + let sample = + [(5, "asb"), (20, "230"), (50, "sdafjl"), (90, "assdfj")]; + let sample = sample + .iter() + .map(|s| ValueCell { + key: s.0, + value: s.1.as_bytes().into(), + }) + .map(|r| (r.key, Into::>::into(&r))) + .collect::>(); + + for (key, v) in sample { + p.mut_cell(initial_end - v.len(), initial_end) + .copy_from_slice(&v); + initial_end = initial_end - v.len(); + + let key = KeyCell { + key: key, + offset: initial_end as u32, + }; + p.mut_cell(initial_start, initial_start + KEYCELL_SIZE) + .copy_from_slice( + Into::<[u8; KEYCELL_SIZE]>::into(&key).as_ref(), + ); + initial_start += KEYCELL_SIZE; + } + + p.set_free_space_end(initial_start as u16); + }) + .expect("can mutate page"); + + (dir, tree) + } + + #[test] + fn cursor_search_success_on_not_found() { + let (_dir, tree) = temp_tree(); + let mut cursor = tree + .cursor() + .expect("can initialize cursor"); + + let key: u32 = 99; + let result = cursor + .search(&key) + .expect("can search tree"); + assert!(result.is_none()) + } + + #[test] + fn cursor_insert_can_insert_into_new() { + let (_dir, tree) = temp_tree(); + let mut ctx = AccessContext::maintenance("cursor insert test"); + + let records: [(u32, &str); 3] = [(10, "abc"), (20, "nas"), (5, "mna")]; + for r in records { + let mut cursor = tree + .cursor() + .expect("can initialize cursor"); + cursor + .insert(&mut ctx, &r.0, r.1.as_bytes().to_vec()) + .expect("can insert record"); + } + + records.iter().for_each(|r| { + let mut cursor = tree + .cursor() + .expect("can initialize cursor"); + + let found = cursor + .search(&r.0) + .expect("can search") + .expect("record is located"); + + assert_eq!(found.key, r.0); + assert_eq!(found.value.as_ref(), r.1.as_bytes()); + }); + } + + #[test] + fn cursor_insert_overrides_on_existing() { + let (_dir, tree) = temp_tree(); + let mut ctx = AccessContext::maintenance("cursor insert overwrite"); + + let record_1: (u32, &str) = (10, "abc"); + let record_2: (u32, &str) = (10, "lorem ipsum"); + + let out = tree + .cursor() + .expect("can init cursor") + .insert(&mut ctx, &record_1.0, record_1.1.as_bytes().to_vec()) + .expect("can insert"); + + assert!(out.is_none()); + + let replaced = tree + .cursor() + .expect("can init cursor") + .insert(&mut ctx, &record_2.0, record_2.1.as_bytes().to_vec()) + .expect("can insert"); + + assert!(replaced.is_some()); + let actual = replaced.unwrap(); + assert_eq!(actual.key, record_1.0); + assert_eq!(actual.value.as_ref(), record_1.1.as_bytes()); + + let updated = tree + .cursor() + .expect("can init cursor") + .search(&record_2.0) + .expect("can search tree"); + assert!(updated.is_some()); + + let actual = updated.unwrap(); + assert_eq!(actual.key, record_2.0); + assert_eq!(actual.value.as_ref(), record_2.1.as_bytes()); + } + + #[test] + #[should_panic(expected = "WouldSplit")] + fn cursor_insert_on_split_errors() { + let (_dir, tree) = filled_leaf_root(); + let mut ctx = AccessContext::maintenance("cursor insert test"); + let record: (u32, &str) = (10, "abc"); + + tree.cursor() + .expect("can init cursor") + .insert(&mut ctx, &record.0, record.1.as_bytes().to_vec()) + .unwrap(); + } +} diff --git a/src/storage/error.rs b/src/storage/error.rs new file mode 100644 index 0000000..408af65 --- /dev/null +++ b/src/storage/error.rs @@ -0,0 +1,42 @@ +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum StorageError { + #[error("storage I/O error: {0}")] + Io(#[from] std::io::Error), + + #[error("accessed invalid page: {0}")] + InvalidPage(String), + + #[error("storage data corrupted: {0}")] + CorruptedData(String), + + #[error("outdated or unsupported table format version")] + FormatVersion, + + #[error("cache miss: page {0} is not currently tracked")] + CacheMiss(usize), + + #[error("page is currently in use")] + PagePinned, + + #[error("all pages in use")] + AllInUse, + + #[error("action not allowed: {0}")] + NotAllowed(&'static str), + + #[error("internal lock poisoned")] + LockPoisoned, + + #[error("recursion detected: {0}")] + RecursionDetected(&'static str), + + #[error("action would split current tree")] + WouldSplit, + + #[error("page requires defragmentation")] + FragmentedPage, +} + +pub type Result = std::result::Result; diff --git a/src/storage/mod.rs b/src/storage/mod.rs new file mode 100644 index 0000000..0947460 --- /dev/null +++ b/src/storage/mod.rs @@ -0,0 +1,12 @@ +pub mod btree; +pub mod constants; +pub mod cursor; +pub mod error; +pub mod page; +pub mod pager; + +pub use btree::Tree; +pub use cursor::Cursor; +pub use error::{Result, StorageError}; +pub use page::{MetaPage, Page, PageFlags, PageView, PageViewMut, TablePage}; +pub use pager::{AccessContext, FlushGuard, Pager}; diff --git a/src/storage/page.rs b/src/storage/page.rs new file mode 100644 index 0000000..f4b7faf --- /dev/null +++ b/src/storage/page.rs @@ -0,0 +1,853 @@ +use std::{fmt, ops}; + +use bitflags::bitflags; +use log::trace; +use serde::{Deserialize, Serialize}; + +use crate::CRC32C; + +use super::constants::page::*; + +trait Field: Sized { + fn read(bytes: &[u8]) -> Self; + fn write(&self, out: &mut [u8]); +} + +macro_rules! impl_field_int { + ($($t:ty),*) => {$( + impl Field for $t { + fn read(bytes: &[u8]) -> Self { + <$t>::from_be_bytes(bytes.try_into().expect("wrong byte count")) + } + fn write(&self, out: &mut [u8]) { + out.copy_from_slice(&self.to_be_bytes()); + } + } + )*}; +} +impl_field_int!(u8, u16, u32, u64); + +bitflags! { + /// [`PageFlags`] is a set of all possible flags to a page + #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] + pub struct PageFlags: u8 { + const IsLeaf = 1; + const IsRoot = 1 << 1; + const HasOverflow = 1 << 2; + const IsMeta = 1 << 3; + const IsInternal = 1 << 4; + const IsOverflow = 1 << 5; + const IsFree = 1 << 6; + } +} + +impl Field for PageFlags { + fn read(bytes: &[u8]) -> Self { + PageFlags::from_bits(u8::read(bytes)).expect("invalid page flags") + } + fn write(&self, out: &mut [u8]) { + self.bits().write(out); + } +} + +/// Basic operational unit within the index-organized table. +/// +/// ## Page Header +/// ```text +/// [0..8] bytes[8] magic +/// [8..12] u32 checksum +/// [12..13] u8 flags (is_meta, is_leaf, is_root, has_overflow, ...) +/// [13..15] u16 free_space_start +/// [15..17] u16 free_space_end +/// [17..19] u16 free_space +/// [..] unused +/// [21..29] u64 latest_lsn +/// [..] unused +/// [49..53] u32 overflow_offset (only valid if has_overflow bit is set) +/// [..] reserved +/// [64..] BODY +/// ``` +#[derive(Clone)] +pub struct Page { + bytes: Box<[u8]>, +} + +impl Page { + /// Build a page from `bytes` + /// + /// ## Panics + /// + /// This function panics if the length of `bytes` is not a power of two + /// greater than or equal to 512. + pub fn build(bytes: T) -> Self + where + T: Into>, + { + let bytes: Box<[u8]> = bytes.into(); + assert!( + Self::is_valid_size(bytes.len()), + "size is not a power of two greater than or equal to 512" + ); + Self { bytes } + } + + /// Create a new [`Page`] of `size` size. + /// + /// ## Panics + /// + /// This function panics if `size` is not a power of two greater than or equal to 512. + pub fn new(size: u16, flags: PageFlags) -> Self { + let mut out = Self::build(vec![0; size as usize]); + out.format(size, flags); + out + } + + /// Formats the page, zeroing all page bytes, resetting `flags` and page + /// header fields. + pub fn format(&mut self, size: u16, flags: PageFlags) { + self.mut_cell(0, size as usize) + .copy_from_slice(vec![0; size as usize].as_ref()); + + self.set_flags(flags); + self.set_free_space_start(HEADER_SIZE as u16); + self.set_free_space_end(size); + self.set_free_space(size - HEADER_SIZE as u16); + self.set_magic(); + self.set_checksum(self.compute_checksum()); + } + + /// Returns whether the [`Page`] is valid. + /// + /// A page is valid if it contains valid `magic` bytes and + /// stored checksum matches computed checksum + pub fn valid(&self) -> (bool, Option<&str>) { + if self.magic() != MAGIC { + return (false, Some("unexpected magic bytes, not reading a page")); + } + + if self.checksum() != self.compute_checksum() { + return (false, Some("corrupted data, checksum is not valid")); + } + + (true, None) + } + + /// Returns an immutable slice into the page bytes from `[start...end)`. + pub fn cell(&self, start: usize, end: usize) -> &[u8] { + &self[start..end] + } + + /// Returns an immutable slice into the page bytes from `[start...PAGE_END]`. + pub fn cell_from(&self, start: usize) -> &[u8] { + &self[start..] + } + + /// Returns a mutable slice into the page bytes from `[start...end)`. + pub fn mut_cell(&mut self, start: usize, end: usize) -> &mut [u8] { + &mut self[start..end] + } + + fn is_valid_size(size: impl Into) -> bool { + let size: usize = size.into(); + size >= 512 && size.is_power_of_two() + } +} + +pub enum AnyPage<'a> { + Meta(MetaPage<&'a Page>), + Table(TablePage<&'a Page>), +} + +pub enum AnyPageMut<'a> { + Meta(MetaPage<&'a mut Page>), + Table(TablePage<&'a mut Page>), +} + +impl Page { + /// Convert the basic [`Page`] into a specific variant + /// based on its `flags` header. + pub fn as_variant(&self) -> AnyPage<'_> { + if self + .flags() + .contains(PageFlags::IsMeta) + { + AnyPage::Meta(MetaPage { page: self }) + } else { + AnyPage::Table(TablePage { page: self }) + } + } + + /// Convert the basic [`Page`] into a mutable specific variant + /// based on its `flags` header. + pub fn as_variant_mut(&mut self) -> AnyPageMut<'_> { + if self + .flags() + .contains(PageFlags::IsMeta) + { + AnyPageMut::Meta(MetaPage { page: self }) + } else { + AnyPageMut::Table(TablePage { page: self }) + } + } +} + +macro_rules! getter { + ( + $(#[$doc:meta])* + $getter:ident, + $ty:ty, + $start:expr + ) => { + $(#[$doc])* + pub fn $getter(&self) -> $ty { + <$ty as Field>::read( + self.cell($start, $start + size_of::<$ty>()) + ) + } + }; +} + +macro_rules! setter { + ( + $(#[$doc:meta])* + $setter:ident, + $ty:ty, + $start:expr + ) => { + $(#[$doc])* + pub fn $setter(&mut self, value: $ty) { + value.write( + self.mut_cell($start, $start + size_of::<$ty>()) + ) + } + }; +} + +macro_rules! field { + ( + $(#[$getter_doc:meta])* + $getter:ident, + $(#[$setter_doc:meta])* + $setter:ident, + $ty:ty, $start:expr + ) => { + $(#[$getter_doc])* + pub fn $getter(&self) -> $ty { + <$ty as Field>::read(self.cell($start, $start + size_of::<$ty>())) + } + + $(#[$setter_doc])* + pub fn $setter(&mut self, value: $ty) { + value.write(self.mut_cell($start, $start + size_of::<$ty>())) + } + }; +} + +impl Page { + /// Retrieve the page header `magic` bytes. + pub fn magic(&self) -> &[u8] { + self.cell(MAGIC_OFFSET, CHECKSUM_OFFSET) + } + + pub fn set_magic(&mut self) { + self.mut_cell(MAGIC_OFFSET, CHECKSUM_OFFSET) + .copy_from_slice(&MAGIC); + } + + /// Computes the current checksum of the page + pub fn compute_checksum(&self) -> u32 { + let mut digest = CRC32C.digest(); + digest.update(&self[..CHECKSUM_OFFSET]); + digest.update(&self[FLAGS_OFFSET..]); + digest.finalize() + } + + /// Returns the pages `overflow_offset` if any. + pub fn overflow_offset(&self) -> Option { + self.flags() + .contains(PageFlags::HasOverflow) + .then(|| self.overflow_offset_raw()) + } + + field!( + /// The stored CRC(Cyclic Redundancy Check) value. + checksum, + /// Set page `crc` to `value`. See: [Self::compute_checksum] + set_checksum, u32, CHECKSUM_OFFSET); + + field!( + /// The [`PageFlags`] of the page. + flags, + set_flags, PageFlags, FLAGS_OFFSET); + + field!( + /// Free space starting offset. + free_space_start, + set_free_space_start, + u16, + FREESPACE_START_OFFSET + ); + field!( + /// Free space end offset. + free_space_end, + set_free_space_end, + u16, + FREESPACE_END_OFFSET + ); + field!( + /// Amount of free space available in `page`. + free_space, + set_free_space, u16, FREESPACE_OFFSET); + field!( + /// Latest log sequence number(lsn) that modified the page. + latest_lsn, + set_lsn, u64, LSN_OFFSET); + field!( + /// Overflow offset of page. This value only has meaning when + /// [`PageFlags::HasOverflow`] is set. + overflow_offset_raw, + set_overflow_offset, + u32, + OVERFLOW_OFFSET_OFFSET + ); +} + +pub trait PageView: ops::Deref {} +impl PageView for T where T: ops::Deref {} + +pub trait PageViewMut: PageView + ops::DerefMut {} +impl PageViewMut for T where T: ops::DerefMut {} + +pub fn build() {} + +/// A metadata page. +/// +/// ```rust +/// use cryo::{Page, PageFlags}; +/// use cryo::storage::page::AnyPage; +/// +/// let page = Page::new(4096, PageFlags::IsMeta); +/// let AnyPage::Meta(meta) = page.as_variant() else { +/// panic!("big problem") +/// }; +/// +/// println!( +/// "page size: {}, format version: {}, tree root: {}, next page: {}", +/// meta.page_size(), +/// meta.format_version(), +/// meta.tree_root(), +/// meta.next_page() +/// ); +/// ``` +/// +/// ## Header +/// ```text +/// [..] -- standard page headers -- +/// [19..21] u16 page_size +/// [..] -- standard page headers -- +/// [29..30] u8 format_version +/// [30..34] u32 tree_root +/// [34..36] u16 next_page +/// [..] -- standard page headers -- +/// [64..] BODY +/// ``` +pub struct MetaPage

{ + pub(crate) page: P, +} + +impl MetaPage

{ + getter!( + /// The configured page size for the storage file. + page_size, + u16, PAGE_SIZE_OFFSET); + getter!( + /// The current format version of the storage file. + format_version, + u8, FORMAT_VERSION_OFFSET + ); + getter!( + /// The offset of the B-Tree root node + tree_root, + u32, BTREE_ROOT_OFFSET + ); + getter!( + /// The next logical page ID + next_page, + u16, NEXT_PAGE_OFFSET + ); +} + +impl MetaPage

{ + setter!(set_page_size, u16, PAGE_SIZE_OFFSET); + setter!(set_format_version, u8, FORMAT_VERSION_OFFSET); + setter!(set_tree_root, u32, BTREE_ROOT_OFFSET); + setter!(set_next_page, u16, NEXT_PAGE_OFFSET); +} + +/// A B-Tree node/page. +/// +/// ```rust +/// use cryo::{Page, PageFlags}; +/// use cryo::storage::page::AnyPage; +/// +/// let page = Page::new(4096, PageFlags::IsInternal); +/// let AnyPage::Table(table) = page.as_variant() else { +/// panic!("big problem") +/// }; +/// +/// println!( +/// "no. of keys: {}, sibling: (l: {}, r: {}), right pointer: {:?}, high key: {}", +/// table.num_keys(), +/// table.left_sibling_offset(), +/// table.right_sibling_offset(), +/// table.right_pointer(), +/// table.high_key(), +/// ); +/// ``` +/// +/// ## Header +/// ```text +/// [..] -- standard page headers -- +/// [19..21] u16 number_of_keys +/// [..] -- standard page headers -- +/// [29..33] u32 left_sibling_offset +/// [33..37] u32 right_sibling_offset +/// [37..41] u32 right_most_pointer (only valid if internal page) +/// [41..49] u64 node_high_key +/// [..] -- standard page headers -- +/// [64..] BODY +/// ``` +pub struct TablePage

{ + page: P, +} + +impl TablePage

{ + getter!( + /// The number of keys stored in the page. + num_keys, + u16, NUM_KEY_OFFSET); + getter!( + /// The offset of the left sibling + left_sibling_offset, + u32, + LEFT_SIBLING_OFFSET + ); + getter!( + /// The offset of the right sibling + right_sibling_offset, + u32, + RIGHT_SIBLING_OFFSET + ); + getter!( + /// The right most pointer; may not be valid. + right_pointer_raw, + u32, + RIGHT_MOST_POINTER_OFFSET + ); + getter!( + /// The highest key the current page can store + high_key, u64, NODE_HIGH_KEY_OFFSET); + + /// Returns the pages right most pointer if called on an internal page. + pub fn right_pointer(&self) -> Option { + self.flags() + .contains(PageFlags::IsInternal) + .then(|| self.right_pointer_raw()) + } +} + +impl TablePage

{ + setter!(set_num_keys, u16, NUM_KEY_OFFSET); + setter!(set_left_sibling_offset, u32, LEFT_SIBLING_OFFSET); + setter!(set_right_sibling_offset, u32, RIGHT_SIBLING_OFFSET); + setter!(set_right_pointer, u32, RIGHT_MOST_POINTER_OFFSET); + setter!(set_high_key, u64, NODE_HIGH_KEY_OFFSET); +} + +/// MutationScope is used to track the mutations made to an initial page and +/// report back the changed byte offsets as well as before and after byte +/// snapshots +#[derive(Debug, Clone)] +pub struct MutationScope { + page: Page, + initial: Box<[u8]>, + diff: Option>, +} + +/// Mutation provides information on a change that has occurred in a [`Page`]. +/// It tracks the offset, starting bytes and after bytes of the mutation. +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct Mutation { + /// Start and end offset range of the mutation + pub offset: MutationOffset, + /// Bytes stored at offset before change + pub before: Box<[u8]>, + /// Bytes stored at offset after change + pub after: Box<[u8]>, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct MutationOffset { + pub start: usize, + pub end: usize, +} + +impl Mutation { + /// Create a new [`Mutation`] + pub fn new( + start: usize, + end: usize, + before: impl Into>, + after: impl Into>, + ) -> Self { + Self { + offset: MutationOffset { start, end }, + before: before.into(), + after: after.into(), + } + } + + /// Replay the mutation on the [`Page`] + pub fn replay(&self, page: &mut impl PageViewMut) { + page.mut_cell(self.offset.start, self.offset.end) + .copy_from_slice(&self.after); + } +} + +impl MutationScope { + /// Initialize a new mutation scope + pub fn init(page: &Page) -> Self { + Self { + page: page.clone(), + initial: page.bytes.clone(), + diff: None, + } + } + + /// Replays the actions taken within the [`MutationScope`] onto the `page`. + /// This function requires [`MutationScope::diff`] be called before it + /// + pub fn replay(&self, page: &mut impl PageViewMut) { + if let Some(d) = &self.diff { + d.iter() + .for_each(|m| m.replay(page)); + } + } + + /// Calculates the difference between the initial [`Page`] contents and the + /// current [`Page`] contents. + pub fn diff(&mut self, max_gap: Option) -> Vec { + if let Some(d) = &self.diff { + return d.clone(); + } + + let after_bytes = &self.page.bytes; + let initial_bytes = &self.initial; + let mut out = Vec::new(); + let max_gap = max_gap.unwrap_or(DEFAULT_MERGE_MUTATION_GAP); + + let mut start = 0; + let mut end = 0; + let mut spotted = false; + trace!("page diff start"); + + for (i, (after, initial)) in after_bytes + .iter() + .zip(initial_bytes.iter()) + .enumerate() + { + if initial != after { + end = i; + + if !spotted { + spotted = true; + start = i; + } + + continue; + } + + if spotted { + end += 1; + trace!("page diff spotted: start={start} end={end}"); + out.push(Mutation::new( + start, + end, + self.initial[start..end].to_vec(), + self.cell(start, end).to_vec(), + )); + } + spotted = false; + } + + if spotted { + end += 1; + trace!("page diff spotted: start={start} end={end}"); + out.push(Mutation::new( + start, + end, + self.initial[start..end].to_vec(), + self.cell(start, end).to_vec(), + )); + } + trace!("page diff end: changes={}", out.len()); + + let out = self.coalesce(out, max_gap); + self.diff = Some(out.clone()); + out + } + + /// Merges adjacent mutations whose unchanged gap is `<= max_gap` bytes. + /// `mutations` must be sorted by offset (as produced by [`MutationScope::diff`]). + fn coalesce( + &self, + mut mutations: Vec, + max_gap: usize, + ) -> Vec { + let mut out: Vec = Vec::with_capacity(mutations.len()); + let initial_count = mutations.len(); + + for m in mutations.drain(..) { + let should_merge = match out.last() { + Some(last) => { + m.offset + .start + .saturating_sub(last.offset.end) + <= max_gap + } + None => false, + }; + + if should_merge { + let prev = out.pop().unwrap(); + out.push(self.merge(prev, m)) + } else { + out.push(m); + } + } + + trace!( + "merge mutations: initial={initial_count} after_merge={}", + out.len() + ); + + out + } + + /// Merges two [`Mutation`] together into a single mutation, filling the gap + /// between their offsets with current page contents. + fn merge(&self, first: Mutation, second: Mutation) -> Mutation { + let (left, right) = { + if first.offset.start > second.offset.start { + (second, first) + } else { + (first, second) + } + }; + + let mut after = left.after.to_vec(); + let mut before = left.before.to_vec(); + + let fill_bytes = self.cell(left.offset.end, right.offset.start); + after.extend(fill_bytes.iter()); + before.extend(fill_bytes.iter()); + + after.extend(right.after.iter()); + before.extend(right.before.iter()); + + trace!( + "mutation merge: left=(start={} end={}) right=(start={} end={})", + left.offset.start, + left.offset.end, + right.offset.start, + right.offset.end + ); + + Mutation::new(left.offset.start, right.offset.end, before, after) + } +} + +impl MutationScope { + /// Convert the basic [`Page`] into a specific variant + /// based on its `flags` header. + pub fn as_variant(&self) -> AnyPage<'_> { + if self + .flags() + .contains(PageFlags::IsMeta) + { + AnyPage::Meta(MetaPage { page: &self.page }) + } else { + AnyPage::Table(TablePage { page: &self.page }) + } + } + + /// Convert the basic [`Page`] into a mutable specific variant + /// based on its `flags` header. + pub fn as_variant_mut(&mut self) -> AnyPageMut<'_> { + if self + .flags() + .contains(PageFlags::IsMeta) + { + AnyPageMut::Meta(MetaPage { + page: &mut self.page, + }) + } else { + AnyPageMut::Table(TablePage { + page: &mut self.page, + }) + } + } +} + +impl ops::DerefMut for Page { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.bytes + } +} + +impl ops::Deref for Page { + type Target = [u8]; + + fn deref(&self) -> &Self::Target { + &self.bytes + } +} + +impl fmt::Display for Page { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "free_space(start={}, end={}, size={}), latest_lsn={}", + self.free_space_start(), + self.free_space_end(), + self.free_space(), + self.latest_lsn(), + ) + } +} + +impl fmt::Debug for Page { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self) + } +} + +impl ops::Deref for MetaPage

{ + type Target = Page; + + fn deref(&self) -> &Page { + &self.page + } +} + +impl ops::Deref for TablePage

{ + type Target = Page; + + fn deref(&self) -> &Page { + &self.page + } +} + +impl ops::DerefMut for MetaPage

{ + fn deref_mut(&mut self) -> &mut Page { + &mut self.page + } +} + +impl ops::DerefMut for TablePage

{ + fn deref_mut(&mut self) -> &mut Page { + &mut self.page + } +} + +impl ops::Deref for MutationScope { + type Target = Page; + + fn deref(&self) -> &Self::Target { + &self.page + } +} + +impl ops::DerefMut for MutationScope { + fn deref_mut(&mut self) -> &mut Page { + &mut self.page + } +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn page_field_access() { + let mut page = Page::build(vec![0; 4096]); + + page.set_free_space(4096); + page.set_flags(PageFlags::empty()); + assert_eq!(page.flags(), PageFlags::empty()); + assert_eq!(page.free_space(), 4096); + assert_ne!(page.bytes[..], vec![0; 4096][..]) + } + + #[test] + fn mutation_scope_diff_actual_changes() { + let page = Page::new(512, PageFlags::IsLeaf); + let mut scope = MutationScope::init(&page); + + let changes: Vec<(usize, [u8; 3])> = + vec![(90, [23, 34, 29]), (120, [12, 10, 0])]; + let expected: Vec<(usize, Vec)> = + vec![(90, vec![23, 34, 29]), (120, vec![12, 10])]; + + match scope.as_variant_mut() { + AnyPageMut::Table(mut p) => { + for (start, bytes) in changes { + p.mut_cell(start, start + bytes.len()) + .copy_from_slice(&bytes); + } + } + _ => assert!(false, "expected page to be leaf variant"), + } + + let diff = scope.diff(None); + let actual = diff + .iter() + .map(|m| (m.offset.start, m.after.to_vec())) + .collect::>(); + + assert_eq!(diff.len(), 2); + assert_eq!(actual, expected); + } + + #[test] + fn mutation_scope_diff_coalesce_gaps() { + let page = Page::new(512, PageFlags::IsLeaf); + let mut scope = MutationScope::init(&page); + + let changes: Vec<(usize, [u8; 3])> = + vec![(90, [23, 34, 29]), (96, [12, 10, 10]), (120, [8, 0, 10])]; + let expected: Vec<(usize, Vec)> = vec![ + (90, vec![23, 34, 29, 0, 0, 0, 12, 10, 10]), + (120, vec![8, 0, 10]), + ]; + + match scope.as_variant_mut() { + AnyPageMut::Table(mut p) => { + for (start, bytes) in changes { + p.mut_cell(start, start + bytes.len()) + .copy_from_slice(&bytes); + } + } + _ => assert!(false, "expected page to be leaf variant"), + } + + let diff = scope.diff(None); + let actual = diff + .iter() + .map(|m| (m.offset.start, m.after.to_vec())) + .collect::>(); + + assert_eq!(diff.len(), 2); + assert_eq!(actual, expected); + } +} diff --git a/src/pager.rs b/src/storage/pager.rs similarity index 60% rename from src/pager.rs rename to src/storage/pager.rs index 098cab9..bfa4e26 100644 --- a/src/pager.rs +++ b/src/storage/pager.rs @@ -1,8 +1,13 @@ -//! Pager and page-cache support for on-disk pages. -//! use crate::{ - Page, PageFlags, - page::{HEADER_SIZE, MAGIC}, + recovery::Lsn, + storage::page::{Mutation, MutationScope}, +}; + +use super::{ + Page, PageFlags, StorageError, + constants::page::{FORMAT_VERSION, META_PAGE_ID}, + error::Result, + page::{AnyPage, AnyPageMut}, }; use log::{debug, info, trace, warn}; use std::{ @@ -19,132 +24,34 @@ use std::{ thread::ThreadId, }; -const O_DIRECT: i32 = 0o40000; - /// Default size, in bytes, used when creating a new database file. pub const DEFAULT_PAGE_SIZE: u16 = 4096; -/// On-disk format version written into the root page of newly created files. -pub const FORMAT_VERSION: u8 = 1; - -/// Page identifier reserved for the root page. -/// -/// Page identifiers are one-based; page id `0` is invalid. -pub const ROOT_PAGE_ID: usize = 1; - -/// Loads a [`Page`] of `size` bytes from `reader`. -/// -/// A [`Page`] is valid when the `MAGIC` bytes are present in its -/// trailer and the stored checksum matches the checksum computed -/// when the page is loaded. -fn load_page( - page_id: usize, - size: usize, - reader: &mut (impl Read + Seek), -) -> io::Result { - info!("loading page {page_id} (size: {size})"); - if page_id == 0 { - return Err(io::Error::new( - io::ErrorKind::InvalidInput, - "page id can not be zero", - )); - } - - let offset = (page_id - 1) * size; - reader.seek(SeekFrom::Start(offset as u64))?; - - let mut buf = vec![0; size]; - reader.read_exact(&mut buf)?; - - let page = Page::build(buf); - if page.magic() != MAGIC.as_bytes() { - return Err(io::Error::new( - io::ErrorKind::InvalidData, - "corrupted data; bytes are not a valid page", - )); - } - if page.checksum() != page.compute_checksum() { - return Err(io::Error::new( - io::ErrorKind::InvalidData, - "corrupted data; crc check failed", - )); - } - - Ok(page) -} - -/// Writes a [`Page`] to `writer`. -/// -/// Before writing, this updates the [`Page`]'s magic bytes and recalculates -/// its checksum so the persisted [`Page`] can be validated during durability checks. -fn write_page( - page_id: usize, - size: usize, - writer: &mut (impl Write + Seek), - page: &mut Page, -) -> io::Result<()> { - info!("writing page {page_id} (size: {size})"); - if page_id == 0 { - return Err(io::Error::new( - io::ErrorKind::InvalidInput, - "page id can not be zero", - )); - } - - let offset = (page_id - 1) * size; - - page.set_magic(); - page.set_checksum(page.compute_checksum()); - writer.seek(SeekFrom::Start(offset as u64))?; - writer.write_all(&page[..])?; - - Ok(()) -} - -/// Create a new [`Page`]. -/// -/// The created page is initialized with page flags, free-space metadata, -/// magic bytes, and a checksum. When `root` is true, the root-only -/// metadata fields for page size and format version are also written. -fn create_page( - flags: PageFlags, - size: u16, - free_space_start: u16, - root: bool, -) -> Page { - info!("creating page of size {size} with {flags:?}"); - let mut page = Page::build(vec![0; size as usize]); - - if root { - page.set_page_size(size); - page.set_format_version(FORMAT_VERSION); - } - - page.set_flags(flags.bits()); - page.set_free_space_start(free_space_start); - page.set_free_space_end(size); - page.set_free_space(size - free_space_start); - page.set_magic(); - page.set_checksum(page.compute_checksum()); - - page -} +const STARTUP_CONTEXT: AccessContext = AccessContext::maintenance("startup"); /// [`FlushGuard`] defines a guarded function that should be run /// before a page is committed/flushed to disk. A page is only /// allowed to flush to disk if `before_flush` is successful. pub trait FlushGuard: Send + Sync { - fn before_flush(&self, page_id: u64, page: &Page) -> io::Result<()>; + fn before_flush(&self, page_id: u64, page: &Page) -> Result<()>; } pub struct NoopFlushGuard; -impl FlushGuard for NoopFlushGuard { - fn before_flush(&self, _page_id: u64, _page: &Page) -> io::Result<()> { - Ok(()) - } +/// [`ChangeGuard`] defines a guarded function that should be run before a page +/// change is applied to log the change. The guard will return a [`Lsn`] that is +/// then used to update the [`Page`] metadata. +pub trait ChangeGuard: Send + Sync { + fn before_change( + &self, + ctx: &mut AccessContext, + page_id: u64, + mutations: Vec, + ) -> Result>; } +pub struct NoopChangeGuard; + /// Describes how a thread is currently accessing a cached page. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum AccessMode { @@ -195,78 +102,6 @@ impl AccessContext { } } -/// Records one active access to a cached page. -/// -/// Handles are used for cache diagnostics only; pin counts are the source of truth -/// for eviction safety. -#[derive(Clone)] -pub struct PageHandle { - pub lsn: Option, - pub mode: AccessMode, - pub page_id: usize, - pub reason: Option<&'static str>, - pub thread_id: ThreadId, - pub txn_id: Option, -} - -impl PageHandle { - /// Adds this handle to a cached page's diagnostic handle list. - pub fn add(&self, page: &CachedPage) -> io::Result<()> { - page.handles - .lock() - .map_err(|_e| { - io::Error::new( - io::ErrorKind::PermissionDenied, - "failed to acquire lock on cached page handles list", - ) - })? - .push(self.clone()); - Ok(()) - } - - /// Removes this handle from a cached page's diagnostic handle list. - pub fn remove(&self, page: &CachedPage) -> io::Result<()> { - let mut handles = page - .handles - .lock() - .map_err(|_e| { - io::Error::new( - io::ErrorKind::PermissionDenied, - "failed to acquire lock on cached page handles list", - ) - })?; - - if let Some(pos) = handles - .iter() - .position(|h| h.thread_id == self.thread_id && h.mode == self.mode) - { - handles.swap_remove(pos); - } - Ok(()) - } -} - -impl fmt::Display for PageHandle { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!( - f, - "{:?}(page={}, txn={:?}, lsn={:?}, thread={:?}", - self.mode, self.page_id, self.txn_id, self.lsn, self.thread_id - )?; - - if let Some(reason) = self.reason { - write!(f, ", reason={reason}")?; - } - write!(f, ")") - } -} - -impl fmt::Debug for PageHandle { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}", self) - } -} - /// A [`CachedPage`] is a [`Page`] that has been loaded into memory. /// /// It stores the page contents together with cache bookkeeping used by the pager: @@ -323,31 +158,6 @@ impl CachedPage { } } -impl fmt::Display for CachedPage { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "[CACHED")?; - if self - .dirty - .load(Ordering::Acquire) - { - write!(f, "|DIRTY")?; - } - write!( - f, - "]{}", - self.page - .read() - .expect("failed to retrieve read lock") - ) - } -} - -impl fmt::Debug for CachedPage { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}", self) - } -} - /// A fixed-size page manager backed by a readable, writable, seekable store. /// /// The pager lazily loads pages from the backing store, caches them in memory, @@ -364,17 +174,12 @@ impl fmt::Debug for CachedPage { /// clock > pages > CachedPage::page > inner /// ``` /// -/// [`CachedPage::handles`] is only ever taken on its own (never while another -/// pager lock is held), so it sits outside this hierarchy. A lock later in the -/// chain must never be held while acquiring one earlier in the chain. -pub struct Pager -where - F: Read + Write + Seek, -{ +pub struct Pager { capacity: usize, - inner: sync::Mutex, - page_size: u16, + inner: sync::Mutex, + pub page_size: u16, flush_guard: sync::Arc, + change_guard: sync::Arc, clock: sync::Mutex, pages: sync::RwLock>>, @@ -387,47 +192,113 @@ struct ClockState { ring: Vec, } -/// Snapshot of cache metadata for one cached page. -#[derive(Clone)] -pub struct CacheInfo { - pub page_id: usize, - pub dirty: bool, - pub accessed: bool, - pub pin_count: usize, - pub handles: Vec, -} +impl Pager { + /// Opens an existing pager file or creates a new one. + /// + /// New files are initialized with a metadata(meta) page using + /// [`DEFAULT_PAGE_SIZE`]. Existing files read the meta page at the default + /// size first so the stored page size can be discovered. + pub fn open(path: impl Into, capacity: usize) -> Result { + let inner = OpenOptions::new() + .read(true) + .write(true) + .create(true) + // NOTE: This has some weird caveats that I need to research more about + .custom_flags(libc::O_DIRECT | libc::O_SYNC) + .open(path.into())?; + let len = inner.metadata()?.len(); -impl fmt::Display for CacheInfo { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!( - f, - "page {}: dirty={} accessed={} pins={} handles=[", - self.page_id, self.dirty, self.accessed, self.pin_count, - )?; - for h in self.handles.iter() { - write!(f, "\n\t{}", h)?; + let mut out = Self { + capacity, + change_guard: sync::Arc::new(NoopChangeGuard), + clock: sync::Mutex::new(ClockState { + hand: 0, + ring: vec![], + }), + flush_guard: sync::Arc::new(NoopFlushGuard), + inner: sync::Mutex::new(inner), + page_size: DEFAULT_PAGE_SIZE, + pages: sync::RwLock::new(HashMap::with_capacity(capacity)), + }; + + if len >= DEFAULT_PAGE_SIZE as u64 { + out.page_size = + out.page(META_PAGE_ID, &STARTUP_CONTEXT, |p| match p { + AnyPage::Meta(p) => { + if p.format_version() != FORMAT_VERSION { + return Err(StorageError::FormatVersion); + } + Ok(p.page_size()) + } + _ => Err(StorageError::CorruptedData( + "unable to locate database meta page".into(), + )), + })??; + } else { + let mut page = Page::new(out.page_size, PageFlags::IsMeta); + let AnyPageMut::Meta(ref mut meta) = page.as_variant_mut() else { + panic!("meta page was not created with flags set") + }; + meta.set_next_page((META_PAGE_ID + 1) as u16); + meta.set_format_version(FORMAT_VERSION); + meta.set_page_size(DEFAULT_PAGE_SIZE); + out.flush(META_PAGE_ID, meta)?; } - write!(f, "]") + Ok(out) } -} -impl fmt::Debug for CacheInfo { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}", self) + /// Allocates `Self::page_size` in the storage file. Returning the + /// new page_id. + pub fn allocate_page( + &self, + ctx: &mut AccessContext, + flags: PageFlags, + ) -> Result { + let page_id = self.mut_page(META_PAGE_ID, ctx, |p| match p { + AnyPageMut::Meta(mut p) => { + let page_id = p.next_page(); + p.set_next_page(page_id + 1); + + Ok(page_id as usize) + } + _ => Err(StorageError::CorruptedData(format!( + "expected {META_PAGE_ID} to be metadata page" + ))), + })??; + + let page_size = self.page_size as usize; + let offset = page_id * page_size; + + if !offset.is_multiple_of(page_size) { + warn!( + "page storage file size ({offset} bytes) is not a multiple of page size ({} bytes); file structure may be corrupted", + self.page_size + ) + } + + let mut page = Page::new(self.page_size, flags); + if let Some(lsn) = ctx.lsn { + page.set_lsn(lsn); + } + self.track(page_id, page, true)?; + + Ok(page_id) } -} -impl Pager -where - F: Read + Write + Seek, -{ /// Set the [`FlushGuard`] for the [`Pager`]. Ensuring the set /// guards [`FlushGuard::before_flush`] is called before any data is synced /// to disk. - pub fn set_guard(&mut self, guard: sync::Arc) { + pub fn set_flush_guard(&mut self, guard: sync::Arc) { self.flush_guard = guard; } + /// Set the [`ChangeGuard`] for the [`Pager`]. Ensuring the set guards + /// [`ChangeGuard::before_change`] is called before any data is synced to + /// disk. + pub fn set_change_guard(&mut self, guard: sync::Arc) { + self.change_guard = guard; + } + /// Access a [`Page`] with read access. /// /// The page is loaded into the cache if needed, pinned for the duration of @@ -435,9 +306,9 @@ where pub fn page( &self, page_id: usize, - ctx: AccessContext, - f: impl FnOnce(&Page) -> R, - ) -> io::Result { + ctx: &AccessContext, + f: impl FnOnce(AnyPage<'_>) -> R, + ) -> Result { trace!( "page {page_id} access start: mode={:?} txn={:?} lsn={:?} reason={:?}", AccessMode::Read, @@ -468,7 +339,7 @@ where "failed to acquire read lock on page", ) })?; - f(&page) + f(page.as_variant()) }; handle.remove(&page)?; page.unpin(); @@ -488,9 +359,9 @@ where pub fn mut_page( &self, page_id: usize, - ctx: AccessContext, - f: impl FnOnce(&mut Page) -> R, - ) -> io::Result { + ctx: &mut AccessContext, + f: impl FnOnce(AnyPageMut) -> R, + ) -> Result { if ctx.txn_id.is_none() && ctx.reason.is_none() { warn!( "mutating page {page_id} without transaction or maintenance context." @@ -527,12 +398,18 @@ where "failed to acquire write lock on page", ) })?; - let out = f(&mut page); + let mut scope = MutationScope::init(&page); + let out = f(scope.as_variant_mut()); + let lsn = self + .change_guard + .before_change(ctx, page_id as u64, scope.diff(None))?; + scope.replay(&mut page); + cached .dirty .store(true, Ordering::Release); - if let Some(lsn) = ctx.lsn { - page.set_lsn(lsn) + if let Some(lsn) = lsn { + page.set_lsn(lsn.into()); } out @@ -540,43 +417,16 @@ where handle.remove(&cached)?; cached.unpin(); trace!( - "page {page_id} access end: mode={:?} txn={:?}", + "page {page_id} access end: mode={:?} txn={:?} lsn={:?} reason={:?}", AccessMode::Write, ctx.txn_id, + ctx.lsn, + ctx.reason, ); Ok(out) } - /// Returns a snapshot of metadata for all currently cached pages. - pub fn info(&self) -> Vec { - let pages = self - .pages - .read() - .expect("failed to acquire read lock on pages map"); - - pages - .values() - .map(|p| CacheInfo { - page_id: p.page_id, - dirty: p - .dirty - .load(Ordering::Acquire), - accessed: p - .accessed - .load(Ordering::Acquire), - pin_count: p - .pin_count - .load(Ordering::Acquire), - handles: p - .handles - .lock() - .expect("can lock cached page handles") - .clone(), - }) - .collect() - } - /// Attempts to flush every page currently tracked by the cache. /// /// If `evict` is `false`, each dirty page is written to the backing store and @@ -612,7 +462,7 @@ where /// /// This function stops at the first error. Pages flushed before the error remain /// flushed, and pages evicted before the error remain evicted. - pub fn flush_all(&self, evict: bool) -> io::Result<()> { + pub fn flush_all(&self, evict: bool) -> Result<()> { let mut pages = self .pages .read() @@ -629,6 +479,8 @@ where // TODO: Instead of failing immediately collect the pages // that failed to flush and provide the information to the caller. // This allows them to retry flushing on the specific cases ? + // + // NOTE: For now `Self::info()` is enough to debug self.flush_page(page, evict)?; } Ok(()) @@ -655,14 +507,114 @@ where /// - [`io::ErrorKind::PermissionDenied`] if the cached page contents write lock /// cannot be acquired. /// - Any error returned by the underlying flush operation. - pub fn flush_page(&self, page_id: usize, evict: bool) -> io::Result<()> { + pub fn flush_page(&self, page_id: usize, evict: bool) -> Result<()> { let evicted = self.flush_page_maps_only(page_id, evict)?; if evicted { self.remove_from_ring(page_id); } Ok(()) } +} + +/// Records one active access to a cached page. +/// +/// Handles are used for cache diagnostics only; pin counts are the source of truth +/// for eviction safety. +#[derive(Clone)] +pub struct PageHandle { + pub lsn: Option, + pub mode: AccessMode, + pub page_id: usize, + pub reason: Option<&'static str>, + pub thread_id: ThreadId, + pub txn_id: Option, +} + +impl PageHandle { + /// Adds this handle to a cached page's diagnostic handle list. + pub fn add(&self, page: &CachedPage) -> Result<()> { + page.handles + .lock() + .map_err(|_e| { + io::Error::new( + io::ErrorKind::PermissionDenied, + "failed to acquire lock on cached page handles list", + ) + })? + .push(self.clone()); + Ok(()) + } + + /// Removes this handle from a cached page's diagnostic handle list. + pub fn remove(&self, page: &CachedPage) -> Result<()> { + let mut handles = page + .handles + .lock() + .map_err(|_e| { + io::Error::new( + io::ErrorKind::PermissionDenied, + "failed to acquire lock on cached page handles list", + ) + })?; + + if let Some(pos) = handles + .iter() + .position(|h| h.thread_id == self.thread_id && h.mode == self.mode) + { + handles.swap_remove(pos); + } + Ok(()) + } +} + +/// Snapshot of cache metadata for one cached page. +#[derive(Clone)] +pub struct CacheInfo { + pub page_id: usize, + pub dirty: bool, + pub accessed: bool, + pub latest_lsn: u64, + pub pin_count: usize, + pub handles: Vec, +} + +impl Pager { + /// Returns a snapshot of metadata for all currently cached pages. + pub fn info(&self) -> Vec { + let pages = self + .pages + .read() + .expect("failed to acquire read lock on pages map"); + + pages + .values() + .map(|p| CacheInfo { + page_id: p.page_id, + latest_lsn: p + .page + .read() + .expect("able to acquire read lock") + .latest_lsn(), + dirty: p + .dirty + .load(Ordering::Acquire), + accessed: p + .accessed + .load(Ordering::Acquire), + pin_count: p + .pin_count + .load(Ordering::Acquire), + handles: p + .handles + .lock() + .expect("can lock cached page handles") + .clone(), + }) + .collect() + } +} +impl Pager { /// Flush and optionally evict `page_id` from the `pages` map only. /// /// Returns `true` when the page was removed from the map so the caller can @@ -672,32 +624,24 @@ where &self, page_id: usize, evict: bool, - ) -> io::Result { + ) -> Result { info!("page flush attempt: page={page_id}, evict={evict}"); let mut pages = self .pages .write() - .map_err(|_| { - io::Error::new( - io::ErrorKind::PermissionDenied, - "failed to acquire write lock on page cache", - ) - })?; + .map_err(|_| StorageError::LockPoisoned)?; let Some(cached_page) = pages.get(&page_id) else { info!( "page flush attempt fail: page={page_id}, evict={evict} UNCACHED" ); - return Err(io::Error::other("untracked page")); + return Err(StorageError::CacheMiss(page_id)); }; if cached_page.is_pinned() { trace!( "page flush attempt fail: page={page_id}, evict={evict}, pin=true, accessed=?" ); - return Err(io::Error::new( - io::ErrorKind::ResourceBusy, - "page is in use", - )); + return Err(StorageError::PagePinned); } if cached_page @@ -711,31 +655,20 @@ where trace!( "page flush attempt fail: page={page_id}, evict={evict}, pin=false, accessed=true" ); - return Err(io::Error::new( - io::ErrorKind::ResourceBusy, - "page has been accessed recently", - )); + return Err(StorageError::PagePinned); } let mut page = cached_page .page .write() - .map_err(|_e| { - io::Error::new( - io::ErrorKind::PermissionDenied, - "failed to acquire write lock on cached page contents", - ) - })?; + .map_err(|_| StorageError::LockPoisoned)?; // Re-check; lock might have been acquired after pin if cached_page.is_pinned() { trace!( "page flush attempt fail: page={page_id}, evict={evict}, pin=true, accessed=false" ); - return Err(io::Error::new( - io::ErrorKind::ResourceBusy, - "page is in use", - )); + return Err(StorageError::PagePinned); } self.flush(page_id, &mut page)?; @@ -774,24 +707,25 @@ where } /// Write [`Page`] to the underlying disk storage. - fn flush(&self, page_id: usize, page: &mut Page) -> io::Result<()> { + fn flush(&self, page_id: usize, page: &mut Page) -> Result<()> { let page_lsn = page.latest_lsn(); info!("page flush requested: page_id={page_id} page_lsn={page_lsn}"); self.flush_guard .before_flush(page_id as u64, page)?; - let mut inner = self - .inner - .lock() - .map_err(|_e| { - io::Error::new( - io::ErrorKind::PermissionDenied, - "failed to acquire lock on pager state", - ) - })?; - write_page(page_id, self.page_size as usize, &mut *inner, page)?; - info!("page flushed: page_id={page_id} page_lsn={page_lsn}"); + let mut inner = self + .inner + .lock() + .map_err(|_| StorageError::LockPoisoned)?; + write_page(page_id, self.page_size as usize, &mut inner, page)?; + let flags = page.flags(); + info!( + "page flushed: page_id={page_id} page_lsn={page_lsn} meta={} leaf={} internal={}", + flags.contains(PageFlags::IsMeta), + flags.contains(PageFlags::IsLeaf), + flags.contains(PageFlags::IsInternal) + ); Ok(()) } @@ -799,16 +733,11 @@ where /// /// If loading a new page would exceed the configured cache capacity, this /// attempts to evict one unpinned page first. - fn get_or_load(&self, page_id: usize) -> io::Result> { + fn get_or_load(&self, page_id: usize) -> Result> { if let Some(cached_page) = self .pages .read() - .map_err(|_e| { - io::Error::new( - io::ErrorKind::PermissionDenied, - "failed to request read lock on pager state", - ) - })? + .map_err(|_| StorageError::LockPoisoned)? .get(&page_id) .cloned() { @@ -827,12 +756,7 @@ where let pages = self .pages .read() - .map_err(|_e| { - io::Error::new( - io::ErrorKind::PermissionDenied, - "failed to request read lock on pager state", - ) - })?; + .map_err(|_| StorageError::LockPoisoned)?; if let Some(cached_page) = pages.get(&page_id).cloned() { cached_page @@ -853,15 +777,9 @@ where let mut inner = self .inner .lock() - .map_err(|e| { - io::Error::new( - io::ErrorKind::PermissionDenied, - format!("failed to lock pager state: {e}"), - ) - })?; + .map_err(|_| StorageError::LockPoisoned)?; load_page(page_id, self.page_size as usize, &mut *inner)? }; - info!("loaded page {page_id}: {page}"); self.track(page_id, page, false) } @@ -881,27 +799,17 @@ where id: usize, page: Page, dirty: bool, - ) -> io::Result> { - trace!("page start tracking: page={id}, dirty={dirty}"); + ) -> Result> { + trace!("page request tracking: page={id}, dirty={dirty}"); let mut clock = self .clock .lock() - .map_err(|_e| { - io::Error::new( - io::ErrorKind::PermissionDenied, - "failed to acquire clock state lock", - ) - })?; + .map_err(|_| StorageError::LockPoisoned)?; let mut pages = self .pages .write() - .map_err(|_e| { - io::Error::new( - io::ErrorKind::PermissionDenied, - "failed to retrieve write lock", - ) - })?; + .map_err(|_| StorageError::LockPoisoned)?; // Another thread may have loaded and tracked this page while we were // reading it from the backing store. Prefer the existing entry so all @@ -910,31 +818,30 @@ where existing .accessed .store(true, Ordering::Release); + trace!("page already tracked: page={id} dirty={dirty}"); return Ok(existing); } let cached = sync::Arc::new(CachedPage::new(id, page, dirty)); pages.insert(id, cached.clone()); clock.ring.push(id); + trace!("page tracking: page={id} dirty={dirty}"); Ok(cached) } /// Evicts a single page from the page cache using a variant of /// the Clock Page Replacement algorithm: /// . - fn evict_one(&self) -> io::Result<()> { + fn evict_one(&self) -> Result<()> { let mut clock = self .clock .lock() - .map_err(|_e| { - io::Error::new( - io::ErrorKind::PermissionDenied, - "failed to acquire clock state lock", - ) - })?; + .map_err(|_| StorageError::LockPoisoned)?; if clock.ring.is_empty() { - return Err(io::Error::other("can not evict from empty cache")); + return Err(StorageError::NotAllowed( + "can not evict from empty cache", + )); } info!("page evict: candidate=\n\t{:?}", clock.ring); @@ -958,17 +865,13 @@ where // try to re-acquire the clock lock, causing a deadlock). Ring // cleanup is our responsibility here. match self.flush_page_maps_only(page_id, true) { - Err(e) - if matches!( - e.kind(), - io::ErrorKind::ResourceBusy - | io::ErrorKind::PermissionDenied - ) => - { + Err(StorageError::PagePinned | StorageError::LockPoisoned) => { clock.hand += 1; continue; } - Err(e) if e.kind() == io::ErrorKind::Other => { + Err( + StorageError::CacheMiss(..) | StorageError::InvalidPage(..), + ) => { clock.ring.swap_remove(hand); if clock.hand >= clock.ring.len() && !clock.ring.is_empty() { @@ -992,76 +895,97 @@ where } debug!("page evict fail: all pages are currently pinned"); - Err(io::Error::new( - io::ErrorKind::WouldBlock, - "all cached pages are pinned", - )) + Err(StorageError::AllInUse) } } -impl Pager { - /// Opens an existing pager file or creates a new one. - /// - /// New files are initialized with a root leaf page using - /// [`DEFAULT_PAGE_SIZE`]. Existing files read the root page at the default - /// size first so the stored page size can be discovered. - pub fn open(path: impl Into, capacity: usize) -> io::Result { - let mut inner = OpenOptions::new() - .read(true) - .write(true) - .create(true) - .custom_flags(O_DIRECT) - .open(path.into())?; - let len = inner.metadata()?.len(); +/// Loads a [`Page`] of `size` bytes from `reader`. +/// +/// A [`Page`] is valid when the `MAGIC` bytes are present in its +/// trailer and the stored checksum matches the checksum computed +/// when the page is loaded. +fn load_page( + page_id: usize, + size: usize, + reader: &mut (impl Read + Seek), +) -> Result { + info!("loading page {page_id} (size: {size})"); + if page_id == 0 { + return Err(StorageError::InvalidPage( + "page ID zero is not allowed".into(), + )); + } - let page_size: u16; - let root: Page; - let created: bool; + let offset = (page_id - 1) * size; + reader.seek(SeekFrom::Start(offset as u64))?; - if len < DEFAULT_PAGE_SIZE as u64 { - root = create_page( - PageFlags::IsRoot | PageFlags::IsLeaf, - DEFAULT_PAGE_SIZE, - HEADER_SIZE as u16, - true, - ); - created = true; - page_size = DEFAULT_PAGE_SIZE; - } else { - root = - load_page(ROOT_PAGE_ID, DEFAULT_PAGE_SIZE as usize, &mut inner) - .map_err(|e| { - io::Error::new( - io::ErrorKind::InvalidData, - format!("corrupted root information: {e}"), - ) - })?; - created = false; - page_size = root.page_size(); - } + let mut buf = vec![0; size]; + reader.read_exact(&mut buf)?; - let out = Self { - capacity, - clock: sync::Mutex::new(ClockState { - hand: 0, - ring: vec![], - }), - flush_guard: sync::Arc::new(NoopFlushGuard), - inner: sync::Mutex::new(inner), - page_size, - pages: sync::RwLock::new(HashMap::with_capacity(capacity)), - }; - trace!("pager initialize: root={root}"); - out.track(ROOT_PAGE_ID, root, created)?; + let page = Page::build(buf); + if let (_, Some(reason)) = page.valid() { + return Err(StorageError::InvalidPage(reason.into())); + } - Ok(out) + Ok(page) +} + +/// Durably persists a [`Page`] to the given `writer` file, guaranteeing that +/// the written bytes are safely flushed and stored on disk. +/// +/// Prior to writing, this refreshes the [`Page`]'s magic bytes and recomputes +/// its checksum, ensuring the persisted [`Page`] can be verified during +/// durability checks. +fn write_page( + page_id: usize, + size: usize, + writer: &mut File, + page: &mut Page, +) -> Result<()> { + info!("writing page {page_id} (size: {size})"); + if page_id == 0 { + return Err(StorageError::InvalidPage( + "page ID zero is not allowed".into(), + )); + } + + page.set_magic(); + page.set_checksum(page.compute_checksum()); + + let offset = (page_id - 1) * size; + let size = writer.metadata()?.len(); + + if offset > size as usize { + // If offset is past the written size, resize the file till offset + // and write page. + writer.set_len(offset as u64)?; + } + + writer.seek(SeekFrom::Start(offset as u64))?; + writer.write_all(&page[..])?; + writer.sync_all()?; + + Ok(()) +} + +impl FlushGuard for NoopFlushGuard { + fn before_flush(&self, _page_id: u64, _page: &Page) -> Result<()> { + Ok(()) + } +} + +impl ChangeGuard for NoopChangeGuard { + fn before_change( + &self, + _ctx: &mut AccessContext, + _page_id: u64, + _changes: Vec, + ) -> Result> { + Ok(None) } } -impl fmt::Display for Pager -where - F: Read + Write + Seek, -{ +impl fmt::Display for Pager { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { writeln!(f, "pager contents:")?; for i in self.info().iter() { @@ -1071,66 +995,161 @@ where } } +impl fmt::Display for PageHandle { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "{:?}(page={}, txn={:?}, lsn={:?}, thread={:?}", + self.mode, self.page_id, self.txn_id, self.lsn, self.thread_id + )?; + + if let Some(reason) = self.reason { + write!(f, ", reason={reason}")?; + } + write!(f, ")") + } +} + +impl fmt::Debug for PageHandle { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self) + } +} + +impl fmt::Display for CacheInfo { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "page {}: dirty={} accessed={} pins={} handles=[", + self.page_id, self.dirty, self.accessed, self.pin_count, + )?; + for h in self.handles.iter() { + write!(f, "\n\t{}", h)?; + } + write!(f, "]") + } +} + +impl fmt::Debug for CacheInfo { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self) + } +} + +impl fmt::Display for CachedPage { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "[CACHED")?; + if self + .dirty + .load(Ordering::Acquire) + { + write!(f, "|DIRTY")?; + } + write!( + f, + "]{}", + self.page + .read() + .expect("failed to retrieve read lock") + ) + } +} + +impl fmt::Debug for CachedPage { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self) + } +} + #[cfg(test)] mod tests { use super::*; - use std::io::Cursor; + use tempfile::TempDir; - fn pager_with_pages( - pages: impl IntoIterator, - ) -> Pager>> { - let mut inner = Cursor::new(Vec::new()); + const TEST_CONTEXT: AccessContext = AccessContext::maintenance("test"); + + fn temp_pager(capacity: usize) -> (TempDir, Pager) { + let dir = TempDir::new().expect("temp dir can be created"); + let path = dir.path().join("cryo.db"); + let pager = Pager::open(path, capacity).expect("pager can be created"); + (dir, pager) + } - for (id, mut page) in pages { - write_page(id, DEFAULT_PAGE_SIZE as usize, &mut inner, &mut page) - .expect("test page can be written"); + fn pager_with_pages( + pages: impl IntoIterator, + ) -> (TempDir, Pager, Vec) { + let (dir, pager) = temp_pager(8); + let mut ids = Vec::new(); + let mut ctx = AccessContext::maintenance("pager with pages"); + + for (num_keys, marker) in pages { + let page_id = pager + .allocate_page(&mut ctx, PageFlags::IsLeaf) + .expect("test page can be allocated"); + pager + .mut_page(page_id, &mut ctx, |page| match page { + AnyPageMut::Table(mut page) => { + let start = page.free_space_start() as usize; + page.set_num_keys(num_keys); + page.mut_cell(start, start + 1)[0] = marker; + } + _ => assert!(false, "setup failed: pager with pages"), + }) + .expect("test page can be initialized"); + pager + .flush_page(page_id, false) + .expect_err("first flush clears accessed bit"); + pager + .flush_page(page_id, true) + .expect("test page can be persisted and evicted"); + pager + .flush_page(META_PAGE_ID, false) + .expect_err("first flush clears accessed bit"); + pager + .flush_page(META_PAGE_ID, true) + .expect("test page can be persisted and evicted"); + ids.push(page_id); } - inner.set_position(0); + (dir, pager, ids) + } - Pager { - capacity: 8, - clock: sync::Mutex::new(ClockState { - hand: 0, - ring: vec![], - }), - flush_guard: sync::Arc::new(NoopFlushGuard), - inner: sync::Mutex::new(inner), - page_size: DEFAULT_PAGE_SIZE, - pages: sync::RwLock::new(HashMap::with_capacity(8)), - } + fn persisted_page(pager: &Pager, page_id: usize) -> Result { + let mut inner = pager + .inner + .lock() + .expect("test can lock pager backing store"); + load_page(page_id, DEFAULT_PAGE_SIZE as usize, &mut *inner) } - fn test_page(num_keys: u16, marker: u8) -> Page { - let mut page = create_page( - PageFlags::IsLeaf, - DEFAULT_PAGE_SIZE, - HEADER_SIZE as u16, - false, - ); - page.set_num_keys(num_keys); - page.mut_cell(HEADER_SIZE, HEADER_SIZE + 1)[0] = marker; - page.set_checksum(page.compute_checksum()); - page + struct FailingFlushGuard; + + impl FlushGuard for FailingFlushGuard { + fn before_flush(&self, _page_id: u64, _page: &Page) -> Result<()> { + Err(StorageError::NotAllowed("blocked by test guard")) + } } #[test] fn loads_multiple_pages_from_backing_store() { - let pager = pager_with_pages([ - (1, test_page(10, b'a')), - (2, test_page(20, b'b')), - (3, test_page(30, b'c')), - ]); + let (_dir, pager, ids) = + pager_with_pages([(10, b'a'), (20, b'b'), (30, b'c')]); for (id, expected_keys, expected_marker) in - [(1, 10, b'a'), (2, 20, b'b'), (3, 30, b'c')] + [(ids[0], 10, b'a'), (ids[1], 20, b'b'), (ids[2], 30, b'c')] { let (num_keys, marker) = pager - .page(id, AccessContext::anonymous(), |page| { - ( - page.num_keys(), - page.cell(HEADER_SIZE, HEADER_SIZE + 1)[0], - ) + .page(id, &TEST_CONTEXT, |page| match page { + AnyPage::Meta(_) => { + panic!("values should not be in meta page") + } + AnyPage::Table(p) => ( + p.num_keys(), + p.cell( + p.free_space_start() as usize, + p.free_space_start() as usize + 1, + )[0], + ), }) .expect("page exists in backing store"); @@ -1144,55 +1163,48 @@ mod tests { .map(|info| info.page_id) .collect::>(); cached_ids.sort_unstable(); - assert_eq!(cached_ids, vec![1, 2, 3]); + assert_eq!(cached_ids, ids); } #[test] - fn accessing_page_not_in_backing_store_returns_unexpected_eof() { - let pager = pager_with_pages([(1, test_page(1, b'a'))]); + fn accessing_page_not_in_backing_store_returns_io_error() { + let (_dir, pager, ids) = pager_with_pages([(1, b'a')]); + let missing = ids[0] + 1; let err = pager - .page(2, AccessContext::anonymous(), |_| ()) + .page(missing, &TEST_CONTEXT, |_| ()) .expect_err("missing page should not load"); - assert_eq!(err.kind(), io::ErrorKind::UnexpectedEof); + assert!(matches!(err, StorageError::Io(..))); assert!(pager.info().is_empty()); } - fn persisted_page( - pager: &Pager>>, - page_id: usize, - ) -> io::Result { - let mut inner = pager - .inner - .lock() - .expect("test can lock pager backing store"); - load_page(page_id, DEFAULT_PAGE_SIZE as usize, &mut *inner) - } - #[test] - fn flushing_untracked_page_returns_other() { - let pager = pager_with_pages([(1, test_page(1, b'a'))]); + fn flushing_untracked_page_returns_cache_miss() { + let (_dir, pager, ids) = pager_with_pages([(1, b'a')]); let err = pager - .flush_page(1, false) + .flush_page(ids[0], false) .expect_err("uncached page should not flush"); - assert_eq!(err.kind(), io::ErrorKind::Other); + assert!( + matches!(err, StorageError::CacheMiss(..)), + "should be cache miss" + ); } #[test] fn flushing_clean_page_with_evict_removes_it_from_cache() { - let pager = pager_with_pages([(1, test_page(1, b'a'))]); + let (_dir, pager, ids) = pager_with_pages([(1, b'a')]); pager - .page(1, AccessContext::anonymous(), |_| ()) + .page(ids[0], &TEST_CONTEXT, |_| ()) .expect("page can be loaded into cache"); assert_eq!(pager.info().len(), 1); pager - .flush_page(1, true) + .flush_page(ids[0], true) .expect("clean page can be evicted"); assert!(pager.info().is_empty()); @@ -1200,21 +1212,28 @@ mod tests { #[test] fn dirty_page_gets_second_chance_before_flush() { - let pager = pager_with_pages([(1, test_page(1, b'a'))]); + let (_dir, pager, ids) = pager_with_pages([(1, b'a')]); + let mut ctx = AccessContext::maintenance("test mutation"); pager - .mut_page(1, AccessContext::maintenance("test mutation"), |page| { - page.set_num_keys(42); - page.mut_cell(HEADER_SIZE, HEADER_SIZE + 1)[0] = b'z'; + .mut_page(ids[0], &mut ctx, |page| match page { + AnyPageMut::Table(mut page) => { + let start = page.free_space_start() as usize; + let end = start + 1; + + page.set_num_keys(42); + page.mut_cell(start, end)[0] = b'z'; + } + _ => panic!("values should be inserted into table"), }) .expect("page can be mutated"); let err = pager - .flush_page(1, false) + .flush_page(ids[0], false) .expect_err( "recently accessed dirty page should get a second chance", ); - assert_eq!(err.kind(), io::ErrorKind::ResourceBusy); + assert!(matches!(err, StorageError::PagePinned)); let info = pager.info(); assert_eq!(info.len(), 1); @@ -1222,7 +1241,7 @@ mod tests { assert!(!info[0].accessed); pager - .flush_page(1, false) + .flush_page(ids[0], false) .expect("dirty page can flush after second chance is cleared"); let info = pager.info(); @@ -1230,42 +1249,67 @@ mod tests { assert!(!info[0].dirty); let persisted = - persisted_page(&pager, 1).expect("flushed page is valid"); + persisted_page(&pager, ids[0]).expect("flushed page is valid"); + let AnyPage::Table(persisted) = persisted.as_variant() else { + panic!("should be a table") + }; assert_eq!(persisted.num_keys(), 42); - assert_eq!(persisted.cell(HEADER_SIZE, HEADER_SIZE + 1)[0], b'z'); + assert_eq!( + persisted.cell( + persisted.free_space_start() as usize, + persisted.free_space_start() as usize + 1 + )[0], + b'z' + ); } #[test] fn dirty_page_can_be_flushed_and_evicted() { - let pager = pager_with_pages([(1, test_page(1, b'a'))]); + let (_dir, pager, ids) = pager_with_pages([(1, b'a')]); + let mut ctx = AccessContext::maintenance("test mutation"); pager - .mut_page(1, AccessContext::maintenance("test mutation"), |page| { - page.set_num_keys(7); - page.mut_cell(HEADER_SIZE, HEADER_SIZE + 1)[0] = b'x'; + .mut_page(ids[0], &mut ctx, |page| match page { + AnyPageMut::Table(mut page) => { + let start = page.free_space_start() as usize; + let end = start + 1; + + page.set_num_keys(7); + page.mut_cell(start, end)[0] = b'x'; + } + _ => panic!("values should be inserted into table"), }) .expect("page can be mutated"); pager - .flush_page(1, false) + .flush_page(ids[0], false) .expect_err("first flush clears accessed bit"); pager - .flush_page(1, true) + .flush_page(ids[0], true) .expect("second flush writes and evicts dirty page"); assert!(pager.info().is_empty()); let persisted = - persisted_page(&pager, 1).expect("flushed page is valid"); + persisted_page(&pager, ids[0]).expect("flushed page is valid"); + let AnyPage::Table(persisted) = persisted.as_variant() else { + panic!("should be a table") + }; assert_eq!(persisted.num_keys(), 7); - assert_eq!(persisted.cell(HEADER_SIZE, HEADER_SIZE + 1)[0], b'x'); + assert_eq!( + persisted.cell( + persisted.free_space_start() as usize, + persisted.free_space_start() as usize + 1 + )[0], + b'x' + ); } #[test] fn pinned_dirty_page_is_resource_busy() { - let pager = pager_with_pages([(1, test_page(1, b'a'))]); + let (_dir, pager, ids) = pager_with_pages([(1, b'a')]); let cached = pager - .get_or_load(1) + .get_or_load(ids[0]) .expect("page can be loaded into cache"); cached.pin(); cached @@ -1276,77 +1320,98 @@ mod tests { .store(false, Ordering::Release); let err = pager - .flush_page(1, false) + .flush_page(ids[0], false) .expect_err("pinned dirty page should not flush"); cached.unpin(); - assert_eq!(err.kind(), io::ErrorKind::ResourceBusy); + assert!(matches!(err, StorageError::PagePinned)); assert!(pager.info()[0].dirty); } - struct FailingFlushGuard; - - impl FlushGuard for FailingFlushGuard { - fn before_flush(&self, _page_id: u64, _page: &Page) -> io::Result<()> { - Err(io::Error::other("blocked by test guard")) - } - } - #[test] fn flush_guard_error_prevents_write_and_keeps_page_dirty() { - let mut pager = pager_with_pages([(1, test_page(1, b'a'))]); - pager.set_guard(sync::Arc::new(FailingFlushGuard)); + let (_dir, mut pager, ids) = pager_with_pages([(1, b'a')]); + let mut ctx = AccessContext::maintenance("test mutation"); + pager.set_flush_guard(sync::Arc::new(FailingFlushGuard)); pager - .mut_page(1, AccessContext::maintenance("test mutation"), |page| { - page.set_num_keys(99); - page.mut_cell(HEADER_SIZE, HEADER_SIZE + 1)[0] = b'q'; + .mut_page(ids[0], &mut ctx, |page| match page { + AnyPageMut::Table(mut page) => { + let start = page.free_space_start() as usize; + let end = start + 1; + + page.set_num_keys(99); + page.mut_cell(start, end)[0] = b'1'; + } + _ => panic!("values should be inserted into table"), }) .expect("page can be mutated"); let cached = pager - .get_or_load(1) + .get_or_load(ids[0]) .expect("page remains cached after mutation"); cached .accessed .store(false, Ordering::Release); let err = pager - .flush_page(1, false) + .flush_page(ids[0], false) .expect_err("failing guard should block flush"); - assert_eq!(err.kind(), io::ErrorKind::Other); + assert!( + matches!(err, StorageError::NotAllowed(..)), + "actual {err:?}" + ); assert!(pager.info()[0].dirty); let persisted = - persisted_page(&pager, 1).expect("original page is valid"); + persisted_page(&pager, ids[0]).expect("original page is valid"); + let AnyPage::Table(persisted) = persisted.as_variant() else { + panic!("should be a table") + }; assert_eq!(persisted.num_keys(), 1); - assert_eq!(persisted.cell(HEADER_SIZE, HEADER_SIZE + 1)[0], b'a'); + assert_eq!( + persisted.cell( + persisted.free_space_start() as usize, + persisted.free_space_start() as usize + 1 + )[0], + b'a' + ); } #[test] fn flush_all_flushes_dirty_pages_and_keeps_them_cached() { - let pager = pager_with_pages([ - (1, test_page(1, b'a')), - (2, test_page(2, b'b')), - ]); + let (_dir, pager, ids) = pager_with_pages([(1, b'a'), (2, b'b')]); + let mut ctx = AccessContext::maintenance("test mutation"); pager - .mut_page(1, AccessContext::maintenance("test mutation"), |page| { - page.set_num_keys(11); - page.mut_cell(HEADER_SIZE, HEADER_SIZE + 1)[0] = b'x'; + .mut_page(ids[0], &mut ctx, |page| match page { + AnyPageMut::Table(mut page) => { + let start = page.free_space_start() as usize; + let end = start + 1; + + page.set_num_keys(11); + page.mut_cell(start, end)[0] = b'x'; + } + _ => panic!("values should be inserted into table"), }) .expect("page 1 can be mutated"); pager - .mut_page(2, AccessContext::maintenance("test mutation"), |page| { - page.set_num_keys(22); - page.mut_cell(HEADER_SIZE, HEADER_SIZE + 1)[0] = b'y'; + .mut_page(ids[1], &mut ctx, |page| match page { + AnyPageMut::Table(mut page) => { + let start = page.free_space_start() as usize; + let end = start + 1; + + page.set_num_keys(22); + page.mut_cell(start, end)[0] = b'y'; + } + _ => panic!("values should be inserted into table"), }) .expect("page 2 can be mutated"); - for page_id in [1, 2] { + for page_id in &ids { pager - .get_or_load(page_id) + .get_or_load(*page_id) .expect("page remains cached") .accessed .store(false, Ordering::Release); @@ -1359,32 +1424,49 @@ mod tests { let mut info = pager.info(); info.sort_by_key(|info| info.page_id); assert_eq!(info.len(), 2); - assert_eq!(info[0].page_id, 1); + assert_eq!(info[0].page_id, ids[0]); assert!(!info[0].dirty); - assert_eq!(info[1].page_id, 2); + assert_eq!(info[1].page_id, ids[1]); assert!(!info[1].dirty); - let persisted = persisted_page(&pager, 1).expect("page 1 was flushed"); + let persisted = + persisted_page(&pager, ids[0]).expect("page 1 was flushed"); + let AnyPage::Table(persisted) = persisted.as_variant() else { + panic!("should be a table") + }; assert_eq!(persisted.num_keys(), 11); - assert_eq!(persisted.cell(HEADER_SIZE, HEADER_SIZE + 1)[0], b'x'); + assert_eq!( + persisted.cell( + persisted.free_space_start() as usize, + persisted.free_space_start() as usize + 1 + )[0], + b'x' + ); - let persisted = persisted_page(&pager, 2).expect("page 2 was flushed"); + let persisted = + persisted_page(&pager, ids[1]).expect("page 2 was flushed"); + let AnyPage::Table(persisted) = persisted.as_variant() else { + panic!("should be a table") + }; assert_eq!(persisted.num_keys(), 22); - assert_eq!(persisted.cell(HEADER_SIZE, HEADER_SIZE + 1)[0], b'y'); + assert_eq!( + persisted.cell( + persisted.free_space_start() as usize, + persisted.free_space_start() as usize + 1 + )[0], + b'y' + ); } #[test] fn flush_all_evicts_clean_pages() { - let pager = pager_with_pages([ - (1, test_page(1, b'a')), - (2, test_page(2, b'b')), - ]); + let (_dir, pager, ids) = pager_with_pages([(1, b'a'), (2, b'b')]); pager - .page(1, AccessContext::anonymous(), |_| ()) + .page(ids[0], &TEST_CONTEXT, |_| ()) .expect("page 1 can be loaded"); pager - .page(2, AccessContext::anonymous(), |_| ()) + .page(ids[1], &TEST_CONTEXT, |_| ()) .expect("page 2 can be loaded"); assert_eq!(pager.info().len(), 2); @@ -1398,27 +1480,37 @@ mod tests { #[test] fn flush_all_evicts_dirty_pages_after_flush() { - let pager = pager_with_pages([ - (1, test_page(1, b'a')), - (2, test_page(2, b'b')), - ]); + let (_dir, pager, ids) = pager_with_pages([(1, b'a'), (2, b'b')]); + let mut ctx = AccessContext::maintenance("test mutation"); pager - .mut_page(1, AccessContext::maintenance("test mutation"), |page| { - page.set_num_keys(31); - page.mut_cell(HEADER_SIZE, HEADER_SIZE + 1)[0] = b'm'; + .mut_page(ids[0], &mut ctx, |page| match page { + AnyPageMut::Table(mut page) => { + let start = page.free_space_start() as usize; + let end = start + 1; + + page.set_num_keys(31); + page.mut_cell(start, end)[0] = b'm'; + } + _ => panic!("values should be inserted into table"), }) .expect("page 1 can be mutated"); pager - .mut_page(2, AccessContext::maintenance("test mutation"), |page| { - page.set_num_keys(32); - page.mut_cell(HEADER_SIZE, HEADER_SIZE + 1)[0] = b'n'; + .mut_page(ids[1], &mut ctx, |page| match page { + AnyPageMut::Table(mut page) => { + let start = page.free_space_start() as usize; + let end = start + 1; + + page.set_num_keys(32); + page.mut_cell(start, end)[0] = b'n'; + } + _ => panic!("values should be inserted into table"), }) .expect("page 2 can be mutated"); - for page_id in [1, 2] { + for page_id in &ids { pager - .get_or_load(page_id) + .get_or_load(*page_id) .expect("page remains cached") .accessed .store(false, Ordering::Release); @@ -1430,30 +1522,57 @@ mod tests { assert!(pager.info().is_empty()); - let persisted = persisted_page(&pager, 1).expect("page 1 was flushed"); + let persisted = + persisted_page(&pager, ids[0]).expect("page 1 was flushed"); + let AnyPage::Table(persisted) = persisted.as_variant() else { + panic!("should be a table") + }; assert_eq!(persisted.num_keys(), 31); - assert_eq!(persisted.cell(HEADER_SIZE, HEADER_SIZE + 1)[0], b'm'); + assert_eq!( + persisted.cell( + persisted.free_space_start() as usize, + persisted.free_space_start() as usize + 1 + )[0], + b'm' + ); - let persisted = persisted_page(&pager, 2).expect("page 2 was flushed"); + let persisted = + persisted_page(&pager, ids[1]).expect("page 2 was flushed"); + let AnyPage::Table(persisted) = persisted.as_variant() else { + panic!("should be a table") + }; assert_eq!(persisted.num_keys(), 32); - assert_eq!(persisted.cell(HEADER_SIZE, HEADER_SIZE + 1)[0], b'n'); + assert_eq!( + persisted.cell( + persisted.free_space_start() as usize, + persisted.free_space_start() as usize + 1 + )[0], + b'n' + ); } #[test] fn flush_all_returns_resource_busy_for_recently_accessed_dirty_page() { - let pager = pager_with_pages([(1, test_page(1, b'a'))]); + let (_dir, pager, ids) = pager_with_pages([(1, b'a')]); + let mut ctx = AccessContext::maintenance("test mutation"); pager - .mut_page(1, AccessContext::maintenance("test mutation"), |page| { - page.set_num_keys(44); - page.mut_cell(HEADER_SIZE, HEADER_SIZE + 1)[0] = b'r'; + .mut_page(ids[0], &mut ctx, |page| match page { + AnyPageMut::Table(mut page) => { + let start = page.free_space_start() as usize; + let end = start + 1; + + page.set_num_keys(44); + page.mut_cell(start, end)[0] = b'r'; + } + _ => panic!("values should be inserted into table"), }) .expect("page can be mutated"); let err = pager .flush_all(false) .expect_err("recently accessed dirty page should not flush"); - assert_eq!(err.kind(), io::ErrorKind::ResourceBusy); + assert!(matches!(err, StorageError::PagePinned)); let info = pager.info(); assert_eq!(info.len(), 1); @@ -1467,19 +1586,28 @@ mod tests { assert!(!pager.info()[0].dirty); let persisted = - persisted_page(&pager, 1).expect("flushed page is valid"); + persisted_page(&pager, ids[0]).expect("flushed page is valid"); + let AnyPage::Table(persisted) = persisted.as_variant() else { + panic!("should be a table") + }; assert_eq!(persisted.num_keys(), 44); - assert_eq!(persisted.cell(HEADER_SIZE, HEADER_SIZE + 1)[0], b'r'); + assert_eq!( + persisted.cell( + persisted.free_space_start() as usize, + persisted.free_space_start() as usize + 1 + )[0], + b'r' + ); } #[test] fn accessing_page_zero_is_invalid() { - let pager = pager_with_pages([(1, test_page(1, b'a'))]); + let (_dir, pager, _ids) = pager_with_pages([(1, b'a')]); let err = pager - .page(0, AccessContext::anonymous(), |_| ()) + .page(0, &TEST_CONTEXT, |_| ()) .expect_err("page id zero is invalid"); - assert_eq!(err.kind(), io::ErrorKind::InvalidInput); + assert!(matches!(err, StorageError::InvalidPage(..))); } } diff --git a/src/wal.rs b/src/wal.rs index c68b15f..bbc62f1 100644 --- a/src/wal.rs +++ b/src/wal.rs @@ -10,1602 +10,4 @@ use std::{ use log::{info, trace, warn}; -use crate::pager::FlushGuard; -use crate::read_be; - -const MAGIC: &str = "PD"; -const MAGIC_SIZE: usize = MAGIC.len(); - -const RECORD_FORMAT_VERSION: u8 = 1; -const RECORD_FORMAT_SIZE: usize = size_of::(); - -const FLAGS_SIZE: usize = size_of::(); -const LSN_SIZE: usize = size_of::(); -const CHECKSUM_SIZE: usize = size_of::(); -const PAYLOAD_LEN_SIZE: usize = size_of::(); - -const HEADER_SIZE: usize = MAGIC_SIZE - + RECORD_FORMAT_SIZE - + FLAGS_SIZE - + LSN_SIZE - + CHECKSUM_SIZE - + PAYLOAD_LEN_SIZE; - -/// The file extension used for WAL generation segment files. -const WAL_EXTENSION: &str = "wal"; - -bitflags! { - #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] - pub struct RecordFlags: u8 {} -} - -fn read_exact_or_eof( - reader: &mut impl Read, - buf: &mut [u8], -) -> io::Result { - let mut read = 0; - - while read < buf.len() { - match reader.read(&mut buf[read..])? { - 0 if read == 0 => return Ok(false), - 0 => { - return Err(io::Error::new( - io::ErrorKind::UnexpectedEof, - "partial WAL frame", - )); - } - n => read += n, - } - } - - Ok(true) -} - -/// Scans a single `generation`'s `reader` for all valid [`Record`] entries -/// starting from byte `offset`. -/// -/// Returns the entries found along with the next [`Lsn`] within this generation -/// (i.e. the byte offset immediately past the last valid frame). A trailing -/// partial/corrupt frame is treated as the end of the valid log and the reader -/// is rewound to the last known-good position. -fn scan_records_from( - reader: &mut (impl Read + Seek), - generation: u32, - offset: u32, -) -> io::Result<(Lsn, Vec)> { - let mut offset = offset; - let mut records = Vec::new(); - reader.seek(SeekFrom::Start(offset as u64))?; - - loop { - match Record::read(reader) { - Ok(Some((stored_lsn, record))) => { - let lsn = Lsn::new(generation, offset); - if u64::from(lsn) != stored_lsn { - warn!( - "WAL LSN does not match offset!! expected={lsn} \ - stored={}", - Lsn::from(stored_lsn) - ); - } - offset = reader.stream_position()? as u32; - records.push(RecordEntry { lsn, record }); - } - Ok(None) => break, - Err(e) - if matches!( - e.kind(), - io::ErrorKind::UnexpectedEof | io::ErrorKind::InvalidData - ) => - { - reader.seek(SeekFrom::Start(offset as u64))?; - break; - } - Err(e) => return Err(e), - } - } - - Ok((Lsn::new(generation, offset), records)) -} - -/// Returns the path of the `.wal` segment inside `dir`. -fn generation_path(dir: &Path, generation: u32) -> PathBuf { - dir.join(format!("{generation}.{WAL_EXTENSION}")) -} - -/// Discovers all WAL generation numbers present in `dir`. -/// -/// A generation file is any file named `.wal` where `N` parses as a `u32`. -/// The returned generations are sorted ascending. -fn discover_generations(dir: &Path) -> io::Result> { - let mut generations = Vec::new(); - - for entry in std::fs::read_dir(dir)? { - let entry = entry?; - let path = entry.path(); - - if path - .extension() - .and_then(|ext| ext.to_str()) - != Some(WAL_EXTENSION) - { - continue; - } - - if let Some(generation) = path - .file_stem() - .and_then(|stem| stem.to_str()) - .and_then(|stem| stem.parse::().ok()) - { - generations.push(generation); - } - } - - generations.sort_unstable(); - Ok(generations) -} - -/// [`Lsn`] is a physical log address. -/// -/// It is encoded as a `u64` where the high 32 bits are the WAL `generation` -/// and the low 32 bits are the byte `offset` within that generation's file. -/// -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub struct Lsn { - generation: u32, - offset: u32, -} - -impl Lsn { - /// Construct an [`Lsn`] from its `generation` and byte `offset`. - pub const fn new(generation: u32, offset: u32) -> Self { - Self { generation, offset } - } - - /// The WAL generation this address points into. - pub const fn generation(self) -> u32 { - self.generation - } - - /// The byte offset within the generation file. - pub const fn offset(self) -> u32 { - self.offset - } - - /// The [`Lsn`] of the record that would follow a record of `len` bytes - /// written at `self`, staying within the same generation. - /// - /// Returns `None` when the addition would overflow the 32-bit offset field, - /// i.e. the generation has grown beyond 4 GiB. The caller must rotate to a - /// new generation before appending further. - fn advanced_by(self, len: u32) -> Option { - let offset = self.offset.checked_add(len)?; - Some(Lsn { - generation: self.generation, - offset, - }) - } - - /// The first [`Lsn`] of the generation following `self`. - fn next_generation(self) -> Lsn { - Lsn { - generation: self.generation + 1, - offset: 0, - } - } -} - -impl From for u64 { - fn from(value: Lsn) -> Self { - ((value.generation as u64) << 32) | (value.offset as u64) - } -} - -impl From for Lsn { - fn from(value: u64) -> Lsn { - let generation = (value >> 32) as u32; - let offset = (value & 0xffff_ffff) as u32; - Lsn { generation, offset } - } -} - -impl std::fmt::Display for Lsn { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}:{}", self.generation, self.offset) - } -} - -#[derive(Debug, Clone)] -pub struct RecordEntry { - lsn: Lsn, - record: Record, -} - -impl PartialOrd for RecordEntry { - fn partial_cmp(&self, other: &Self) -> Option { - self.lsn - .partial_cmp(&other.lsn) - } -} - -impl PartialEq for RecordEntry { - fn eq(&self, other: &Self) -> bool { - self.lsn.eq(&other.lsn) - } -} - -impl RecordEntry { - /// The physical [`Lsn`] at which this record is stored. - pub fn lsn(&self) -> Lsn { - self.lsn - } - - /// The [`Record`] payload. - pub fn record(&self) -> &Record { - &self.record - } -} - -impl From<(Lsn, Record)> for RecordEntry { - fn from((lsn, record): (Lsn, Record)) -> Self { - RecordEntry { lsn, record } - } -} - -/// [`Record`] is an entry in the Write-Ahead Log. -/// -/// The [`Record`] keeps track of actions taken against pages in memory so they -/// can be redone during recovery or undone when a transaction aborts. -/// -/// Record Layout: -/// -/// [0..2] bytes[2] magic -/// [2..3] u8 format -/// [3..4] u8 flags -/// [4..12] u64 lsn -/// [12..16] u32 crc -/// [16..20] u32 payload_len -/// [20..] bytes payload -#[derive(Debug, Clone, Serialize, Deserialize)] -pub enum Record { - /// Marks the beginning of a transaction. - Begin { txn_id: u64, prev_lsn: Option }, - - /// Describes a byte-range update made to a page. - /// - /// The `before` bytes are used to undo the update and the `after` bytes are - /// used to redo it. - Update { - txn_id: u64, - page_id: u64, - offset: u16, - before: Vec, - after: Vec, - prev_lsn: Option, - }, - - /// Marks a transaction as committed. - Commit { txn_id: u64, prev_lsn: Option }, - - /// Marks a transaction as aborted. - Abort { txn_id: u64, prev_lsn: Option }, - - /// Describes an undo action that has already been applied. - /// - /// Compensation records are redo-only and point at the next log record that - /// should be undone for the transaction. - Compensation { - txn_id: u64, - page_id: u64, - offset: u16, - after: Vec, - undo_next_lsn: Option, - prev_lsn: Option, - }, - - /// Marks the end of a transaction's log records. - End { txn_id: u64, prev_lsn: Option }, - - /// Marks the beginning of a checkpoint. - BeginCheckpoint, - - /// Marks the end of a checkpoint. - EndCheckpoint, -} - -impl Record { - /// Reads a single [`Record`] from `reader`. Returning the stored `lsn` - /// and [`Record`] payload. - /// - /// The record is read from the reader's current position and validated against - /// the expected on-disk record format. - /// - /// ## Errors - /// - /// This function returns an error when bytes cannot be read from `reader` or - /// when the bytes read do not describe a valid [`Record`]. - pub fn read(reader: &mut impl Read) -> io::Result> { - let mut magic = [0; MAGIC_SIZE]; - if !read_exact_or_eof(reader, &mut magic)? { - return Ok(None); - } - if magic != MAGIC.as_bytes() { - return Err(io::Error::new( - io::ErrorKind::InvalidData, - "invalid WAL record magic", - )); - } - - let version = read_be!(reader, u8); - if version != RECORD_FORMAT_VERSION { - return Err(io::Error::new( - io::ErrorKind::InvalidData, - "unsupported WAL record version", - )); - } - - let flags = read_be!(reader, u8); - let _flags = RecordFlags::from_bits(flags).ok_or_else(|| { - io::Error::new( - io::ErrorKind::InvalidData, - "corrupted WAL record flags", - ) - })?; - - let lsn = read_be!(reader, u64); - let crc = read_be!(reader, u32); - let payload_len = read_be!(reader, u32); - - if payload_len > u16::MAX as u32 { - return Err(io::Error::new( - io::ErrorKind::InvalidData, - "WAL payload too large", - )); - } - - let mut payload = vec![0; payload_len as usize]; - reader.read_exact(&mut payload)?; - - let actual_crc = crate::CRC32C.checksum(&payload); - if actual_crc != crc { - return Err(io::Error::new( - io::ErrorKind::InvalidData, - "WAL record checksum mismatch", - )); - } - - let record: Record = postcard::from_bytes(&payload[..]) - .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; - record.validate(None)?; - - Ok(Some((lsn, record))) - } - - #[allow(clippy::len_without_is_empty)] - pub fn len(&self) -> usize { - HEADER_SIZE - + self - .as_bytes() - .expect("should be able to marshal record") - .len() - } - - pub fn txn_id(&self) -> Option { - match self { - Self::Begin { txn_id, .. } - | Self::Update { txn_id, .. } - | Self::Commit { txn_id, .. } - | Self::Abort { txn_id, .. } - | Self::Compensation { txn_id, .. } - | Self::End { txn_id, .. } => Some(*txn_id), - Self::BeginCheckpoint | Self::EndCheckpoint => None, - } - } - - pub fn page_id(&self) -> Option { - match self { - Self::Update { page_id, .. } - | Self::Compensation { page_id, .. } => Some(*page_id), - _ => None, - } - } - - pub fn prev_lsn(&self) -> Option { - match self { - Self::Begin { prev_lsn, .. } - | Self::Update { prev_lsn, .. } - | Self::Commit { prev_lsn, .. } - | Self::Abort { prev_lsn, .. } - | Self::Compensation { prev_lsn, .. } - | Self::End { prev_lsn, .. } => *prev_lsn, - Self::BeginCheckpoint | Self::EndCheckpoint => None, - } - } - - pub fn kind(&self) -> &'static str { - match self { - Self::Begin { .. } => "begin", - Self::Update { .. } => "update", - Self::Commit { .. } => "commit", - Self::Abort { .. } => "abort", - Self::Compensation { .. } => "clr", - Self::End { .. } => "end", - Self::BeginCheckpoint => "begin_checkpoint", - Self::EndCheckpoint => "end_checkpoint", - } - } - - pub fn as_bytes(&self) -> io::Result> { - let payload = postcard::to_allocvec(self) - .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; - if payload.len() > u16::MAX as usize { - return Err(io::Error::new( - io::ErrorKind::InvalidInput, - "WAL record too large", - )); - } - - Ok(payload.into()) - } - - /// Validate that this record is structurally sound before it is applied to - /// or read from the log. - /// - /// These checks reject records that recovery could never apply correctly: - /// - /// - `Update.before` and `Update.after` must have equal length so an undo - /// can restore exactly the bytes a redo replaced. - /// - `Update`/`Compensation` payloads must be non-empty (a zero-length - /// change carries no redo/undo information). - /// - `Compensation` records are redo-only and must carry an - /// `undo_next_lsn` so undo can continue past the compensated action. - /// - /// A `page_size` hint, when provided, additionally requires the changed - /// byte range (`offset + len`) to fit within a single page. - pub fn validate(&self, page_size: Option) -> io::Result<()> { - let invalid = |msg: &str| { - io::Error::new(io::ErrorKind::InvalidData, msg.to_string()) - }; - - match self { - Self::Update { - offset, - before, - after, - .. - } => { - if before.len() != after.len() { - return Err(invalid( - "WAL Update before/after length mismatch", - )); - } - if after.is_empty() { - return Err(invalid("WAL Update carries no bytes")); - } - Self::check_range(*offset, after.len(), page_size)?; - } - Self::Compensation { - offset, - after, - undo_next_lsn, - .. - } => { - if after.is_empty() { - return Err(invalid("WAL Compensation carries no bytes")); - } - if undo_next_lsn.is_none() { - return Err(invalid( - "WAL Compensation missing undo_next_lsn", - )); - } - Self::check_range(*offset, after.len(), page_size)?; - } - _ => {} - } - - Ok(()) - } - - /// Ensure a changed byte range `[offset, offset + len)` fits within a page. - fn check_range( - offset: u16, - len: usize, - page_size: Option, - ) -> io::Result<()> { - let Some(page_size) = page_size else { - return Ok(()); - }; - - let end = offset as usize + len; - if end > page_size as usize { - return Err(io::Error::new( - io::ErrorKind::InvalidData, - format!( - "WAL record byte range {offset}+{len} exceeds page size \ - {page_size}" - ), - )); - } - - Ok(()) - } -} - -/// Mutable state protected by the [`Logger`]'s lock. -struct Inner { - /// Directory containing the `.wal` generation files. - dir: PathBuf, - /// Append handle for the current (highest) generation. - writer: File, - /// The generation currently being appended to. - current_generation: u32, - /// Records appended but not yet flushed to disk. - buffer: VecDeque, - /// The [`Lsn`] the next appended record will occupy. - next_lsn: Lsn, - /// The [`Lsn`] of the last record durably written to disk, if any. - flushed_lsn: Option, -} - -/// A directory-backed Write-Ahead Log. -/// -/// The log is stored as a sequence of append-only generation files named -/// `.wal`. Physical [`Lsn`]s encode `(generation, offset)`, so lookups can -/// seek directly to the owning generation file and byte offset. New appends -/// always go to the highest generation; [`Logger::rotate`] starts a new one. -/// -/// All state is held behind a [`Mutex`] so the public API only requires shared -/// (`&self`) access and the logger can be shared across threads. -pub struct Logger { - inner: Mutex, -} - -impl Logger { - /// Open (or create) a WAL [`Logger`] backed by the directory at `path`. - /// - /// The directory is scanned for `.wal` generation files. The highest - /// generation is opened for appending and `next_lsn`/`flushed_lsn` are - /// resumed from its valid prefix. When the directory contains no generation - /// files, generation `0` is created. - pub fn open(path: impl AsRef) -> io::Result { - let dir = path.as_ref().to_path_buf(); - std::fs::create_dir_all(&dir)?; - - let generations = discover_generations(&dir)?; - let current_generation = generations - .last() - .copied() - .unwrap_or(0); - - let mut writer = OpenOptions::new() - .read(true) - .write(true) - .create(true) - .truncate(false) - .open(generation_path(&dir, current_generation))?; - - let (next_lsn, records) = - scan_records_from(&mut writer, current_generation, 0)?; - let flushed_lsn = records - .last() - .map(|entry| entry.lsn); - - // Position the append handle at the end of the valid prefix so a - // trailing partial frame is overwritten by the next append. - writer.seek(SeekFrom::Start(next_lsn.offset() as u64))?; - - info!( - "wal open: dir={} current_generation={current_generation} \ - next_lsn={next_lsn} flushed_lsn={flushed_lsn:?}", - dir.display() - ); - - Ok(Self { - inner: Mutex::new(Inner { - dir, - writer, - current_generation, - buffer: VecDeque::new(), - next_lsn, - flushed_lsn, - }), - }) - } - - fn lock(&self) -> io::Result> { - self.inner - .lock() - .map_err(|_| io::Error::other("wal: lock poisoned")) - } - - /// The generation currently being appended to. - pub fn current_generation(&self) -> io::Result { - Ok(self - .lock()? - .current_generation) - } - - /// The [`Lsn`] of the last record durably written to disk, if any. - pub fn flushed_lsn(&self) -> io::Result> { - Ok(self.lock()?.flushed_lsn) - } - - /// The [`Lsn`] the next appended record will occupy. - pub fn next_lsn(&self) -> io::Result { - Ok(self.lock()?.next_lsn) - } - - /// Retrieve the [`Record`] stored at `lsn`. - /// - /// The record is served from the in-memory buffer when it has not yet been - /// flushed, otherwise it is read from the generation file addressed by - /// `lsn.generation()` at byte offset `lsn.offset()`. - pub fn get(&self, lsn: Lsn) -> io::Result> { - let inner = self.lock()?; - - if let Ok(pos) = inner - .buffer - .binary_search_by(|r| r.lsn.cmp(&lsn)) - { - return Ok(Some(inner.buffer[pos].clone())); - } - - if lsn >= inner.next_lsn { - return Ok(None); - } - - // Anything at or after the first buffered record but not found in the - // buffer does not correspond to a real record boundary. - if let Some(first_buffered) = inner - .buffer - .front() - .map(|r| r.lsn) - && lsn >= first_buffered - { - return Err(io::Error::new( - io::ErrorKind::InvalidInput, - "wal: attempt to retrieve record outside of known range", - )); - } - - let mut reader = inner.open_generation_reader(lsn.generation())?; - reader.seek(SeekFrom::Start(lsn.offset() as u64))?; - - let Some((stored_lsn, record)) = Record::read(&mut reader)? else { - return Ok(None); - }; - if stored_lsn != u64::from(lsn) { - return Err(io::Error::new( - io::ErrorKind::InvalidData, - format!( - "wal: unexpected LSN. expected={lsn}, got={}", - Lsn::from(stored_lsn) - ), - )); - } - - Ok(Some((lsn, record).into())) - } - - /// Retrieve every record from `lsn` onwards, in order. - /// - /// Traversal follows the physical layout: it reads the generation - /// containing `lsn` from `lsn.offset()`, then continues through each - /// subsequent on-disk generation, and finally appends any buffered records - /// that have not yet been flushed. - pub fn records_from(&self, lsn: Lsn) -> io::Result> { - let inner = self.lock()?; - let mut records = Vec::new(); - - // Read flushed records across generations, starting from `lsn`. - let mut generation = lsn.generation(); - let mut offset = lsn.offset(); - while generation <= inner.current_generation { - let mut reader = inner.open_generation_reader(generation)?; - let (_next, mut found) = - scan_records_from(&mut reader, generation, offset)?; - records.append(&mut found); - - generation += 1; - offset = 0; - } - - // Append buffered (not-yet-flushed) records at or after `lsn`. - records.extend( - inner - .buffer - .iter() - .filter(|entry| entry.lsn >= lsn) - .cloned(), - ); - - Ok(records) - } - - /// Append `record` to the WAL, returning its assigned [`Lsn`]. - /// - /// The append is buffered in memory and only reaches disk on a call to - /// [`Logger::flush_through`]. - pub fn append(&self, record: Record) -> io::Result { - record.validate(None)?; - - let mut inner = self.lock()?; - let lsn = inner.next_lsn; - inner.next_lsn = lsn - .advanced_by(record.len() as u32) - .ok_or_else(|| { - io::Error::other( - "WAL generation offset overflow: rotate to a new generation before appending", - ) - })?; - - info!( - "wal append: lsn={lsn} txn={:?} page={:?} kind={}", - record.txn_id(), - record.page_id(), - record.kind(), - ); - - inner - .buffer - .push_back((lsn, record).into()); - - Ok(lsn) - } - - /// Read every flushed [`Record`] in the current generation from its start. - pub fn read_all(&self) -> io::Result> { - let inner = self.lock()?; - let (_next, records) = { - let mut reader = - inner.open_generation_reader(inner.current_generation)?; - scan_records_from(&mut reader, inner.current_generation, 0)? - }; - Ok(records) - } - - /// Flush all buffered records up to and including `target_lsn` to disk. - /// - /// Records after `target_lsn` remain buffered. This writes to the OS file - /// but does not guarantee durability; use [`Logger::sync_all`] for that. - pub fn flush_through(&self, target_lsn: Lsn) -> io::Result<()> { - let mut inner = self.lock()?; - inner.flush_through(target_lsn) - } - - /// Start a new generation, directing subsequent appends to it. - /// - /// Buffered records are flushed and synced into the current generation - /// first so the previous generation is complete and durable before the - /// boundary. The new generation's addresses start at offset `0`. - pub fn rotate(&self) -> io::Result { - let mut inner = self.lock()?; - - let pending = inner - .buffer - .back() - .map(|entry| entry.lsn); - if let Some(last) = pending { - inner.flush_through(last)?; - } - inner.writer.sync_all()?; - - let next_generation = inner.current_generation + 1; - let writer = OpenOptions::new() - .read(true) - .write(true) - .create(true) - .truncate(false) - .open(generation_path(&inner.dir, next_generation))?; - - let next_lsn = inner - .next_lsn - .next_generation(); - inner.writer = writer; - inner.current_generation = next_generation; - inner.next_lsn = next_lsn; - - info!("wal rotate: new generation={next_generation}"); - Ok(next_generation) - } - - /// Durably persist all flushed WAL bytes (equivalent to `fsync`). - pub fn sync_all(&self) -> io::Result<()> { - let inner = self.lock()?; - inner.writer.sync_all() - } -} - -impl Inner { - /// Open a fresh read handle for `generation`'s segment file. - fn open_generation_reader( - &self, - generation: u32, - ) -> io::Result> { - let file = OpenOptions::new() - .read(true) - .open(generation_path(&self.dir, generation))?; - Ok(BufReader::new(file)) - } - - /// Flush buffered records up to and including `target_lsn`. - fn flush_through(&mut self, target_lsn: Lsn) -> io::Result<()> { - if let Some(flushed_lsn) = self.flushed_lsn - && target_lsn <= flushed_lsn - { - trace!( - "wal flush skipped: target_lsn={target_lsn} \ - flushed_lsn={flushed_lsn}" - ); - return Ok(()); - } - - info!( - "wal flush start: target_lsn={target_lsn} \ - current_flushed={:?}", - self.flushed_lsn - ); - - let mut flushed_until = self.flushed_lsn; - - while let Some(entry) = self.buffer.pop_front() { - if entry.lsn > target_lsn { - self.buffer.push_front(entry); - break; - } - - self.write(entry.lsn, &entry.record)?; - flushed_until = Some(entry.lsn); - } - - self.writer.flush()?; - self.flushed_lsn = flushed_until; - - info!("wal flush complete: flushed_lsn={:?}", self.flushed_lsn); - Ok(()) - } - - /// Encode and write a single record frame at `lsn.offset()` in the current - /// generation file. - fn write(&mut self, lsn: Lsn, record: &Record) -> io::Result<()> { - debug_assert_eq!(lsn.generation(), self.current_generation); - - let payload = record.as_bytes()?; - let crc = crate::CRC32C.checksum(&payload); - - self.writer - .seek(SeekFrom::Start(lsn.offset() as u64))?; - self.writer - .write_all(MAGIC.as_bytes())?; - self.writer - .write_all(&[RECORD_FORMAT_VERSION])?; - self.writer - .write_all(&[RecordFlags::empty().bits()])?; - self.writer.write_all( - u64::from(lsn) - .to_be_bytes() - .as_ref(), - )?; - self.writer - .write_all(crc.to_be_bytes().as_ref())?; - self.writer.write_all( - (payload.len() as u32) - .to_be_bytes() - .as_ref(), - )?; - self.writer - .write_all(&payload)?; - - Ok(()) - } -} - -/// A [`FlushGuard`] that enforces the write-ahead rule: a page may only be -/// flushed once the WAL is durable through the page's `pageLSN`. -pub struct WalFlushGuard { - wal: sync::Arc, -} - -impl WalFlushGuard { - /// Create a guard that flushes `wal` before dependent pages are written. - pub fn new(wal: sync::Arc) -> Self { - Self { wal } - } -} - -impl FlushGuard for WalFlushGuard { - fn before_flush( - &self, - _page_id: u64, - page: &crate::Page, - ) -> io::Result<()> { - let lsn = Lsn::from(page.latest_lsn()); - self.wal.flush_through(lsn)?; - self.wal.sync_all() - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::Page; - use tempfile::TempDir; - - /// Open a [`Logger`] backed by a fresh temporary directory, returning the - /// guard so the directory lives for the duration of the test. - fn temp_logger() -> (TempDir, Logger) { - let dir = TempDir::new().expect("temp dir can be created"); - let logger = Logger::open(dir.path()).expect("logger can be created"); - (dir, logger) - } - - fn update_record(prev_lsn: Option) -> Record { - Record::Update { - txn_id: 10, - page_id: 7, - offset: 42, - before: vec![b'a', b'b', b'c'], - after: vec![b'x', b'y', b'z'], - prev_lsn, - } - } - - #[test] - fn record_metadata_helpers_return_expected_values() { - let update = update_record(Some(3)); - assert_eq!(update.txn_id(), Some(10)); - assert_eq!(update.page_id(), Some(7)); - assert_eq!(update.prev_lsn(), Some(3)); - assert_eq!(update.kind(), "update"); - - let clr = Record::Compensation { - txn_id: 11, - page_id: 8, - offset: 9, - after: vec![1, 2, 3], - undo_next_lsn: Some(4), - prev_lsn: Some(5), - }; - assert_eq!(clr.txn_id(), Some(11)); - assert_eq!(clr.page_id(), Some(8)); - assert_eq!(clr.prev_lsn(), Some(5)); - assert_eq!(clr.kind(), "clr"); - - let checkpoint = Record::BeginCheckpoint; - assert_eq!(checkpoint.txn_id(), None); - assert_eq!(checkpoint.page_id(), None); - assert_eq!(checkpoint.prev_lsn(), None); - assert_eq!(checkpoint.kind(), "begin_checkpoint"); - } - - #[test] - fn append_buffers_records_until_flush_through() { - let (_dir, logger) = temp_logger(); - - let begin = Record::Begin { - txn_id: 1, - prev_lsn: None, - }; - let expected_begin_lsn = Lsn::new(0, 0); - let expected_update_lsn = expected_begin_lsn - .advanced_by(begin.len() as u32) - .unwrap(); - let begin_lsn = logger - .append(begin) - .expect("begin can be appended"); - let update_lsn = logger - .append(update_record(Some(begin_lsn.into()))) - .expect("update can be appended"); - - assert_eq!(begin_lsn, expected_begin_lsn); - assert_eq!(update_lsn, expected_update_lsn); - assert_eq!(logger.flushed_lsn().unwrap(), None); - assert_eq!( - logger - .lock() - .unwrap() - .buffer - .len(), - 2 - ); - - logger - .flush_through(begin_lsn) - .expect("flush through first record succeeds"); - - assert_eq!(logger.flushed_lsn().unwrap(), Some(begin_lsn)); - assert_eq!( - logger - .lock() - .unwrap() - .buffer - .len(), - 1 - ); - - let records = logger - .read_all() - .expect("flushed WAL records can be read"); - assert_eq!(records.len(), 1); - assert_eq!(records[0].lsn, begin_lsn); - assert_eq!(records[0].record.kind(), "begin"); - - logger - .flush_through(update_lsn) - .expect("flush through second record succeeds"); - - assert_eq!(logger.flushed_lsn().unwrap(), Some(update_lsn)); - assert!( - logger - .lock() - .unwrap() - .buffer - .is_empty() - ); - - let records = logger - .read_all() - .expect("all flushed WAL records can be read"); - assert_eq!(records.len(), 2); - assert_eq!(records[0].lsn, begin_lsn); - assert_eq!(records[1].lsn, update_lsn); - assert_eq!(records[1].record.kind(), "update"); - assert_eq!(records[1].record.prev_lsn(), Some(begin_lsn.into())); - } - - #[test] - fn logger_new_scans_existing_records_and_resumes_lsn_numbering() { - let dir = TempDir::new().expect("temp dir can be created"); - - let begin_lsn; - let commit_lsn; - let commit_len; - { - let logger = - Logger::open(dir.path()).expect("logger can be created"); - begin_lsn = logger - .append(Record::Begin { - txn_id: 1, - prev_lsn: None, - }) - .expect("begin can be appended"); - let commit = Record::Commit { - txn_id: 1, - prev_lsn: Some(begin_lsn.into()), - }; - commit_len = commit.len() as u32; - commit_lsn = logger - .append(commit) - .expect("commit can be appended"); - logger - .flush_through(commit_lsn) - .expect("records can be flushed"); - logger - .sync_all() - .expect("records can be synced"); - } - - let reopened = - Logger::open(dir.path()).expect("logger can scan existing WAL"); - - assert_eq!( - reopened - .flushed_lsn() - .unwrap(), - Some(commit_lsn) - ); - assert_eq!( - reopened.next_lsn().unwrap(), - commit_lsn - .advanced_by(commit_len) - .unwrap() - ); - assert!( - reopened - .lock() - .unwrap() - .buffer - .is_empty() - ); - - let end_lsn = reopened - .append(Record::End { - txn_id: 1, - prev_lsn: Some(commit_lsn.into()), - }) - .expect("end can be appended after reopening"); - assert_eq!( - end_lsn, - commit_lsn - .advanced_by(commit_len) - .unwrap() - ); - } - - #[test] - fn record_read_rejects_invalid_magic_version_and_checksum() { - use std::io::Cursor; - - let mut invalid_magic = Cursor::new(vec![b'X', b'X']); - let err = Record::read(&mut invalid_magic) - .expect_err("invalid magic should be rejected"); - assert_eq!(err.kind(), io::ErrorKind::InvalidData); - - let mut invalid_version = - Cursor::new(vec![b'P', b'D', RECORD_FORMAT_VERSION + 1]); - let err = Record::read(&mut invalid_version) - .expect_err("invalid version should be rejected"); - assert_eq!(err.kind(), io::ErrorKind::InvalidData); - - let dir = TempDir::new().expect("temp dir can be created"); - let path = generation_path(dir.path(), 0); - { - let logger = - Logger::open(dir.path()).expect("logger can be created"); - let lsn = logger - .append(Record::Begin { - txn_id: 1, - prev_lsn: None, - }) - .expect("record can be appended"); - logger - .flush_through(lsn) - .expect("record can be flushed"); - logger - .sync_all() - .expect("record can be synced"); - } - - let mut bytes = std::fs::read(&path).expect("wal file can be read"); - let last = bytes.len() - 1; - bytes[last] ^= 0xff; - - let mut corrupted = Cursor::new(bytes); - let err = Record::read(&mut corrupted) - .expect_err("checksum mismatch should be rejected"); - assert_eq!(err.kind(), io::ErrorKind::InvalidData); - } - - #[test] - fn scan_existing_stops_at_trailing_partial_frame() { - use std::io::Cursor; - - let dir = TempDir::new().expect("temp dir can be created"); - let path = generation_path(dir.path(), 0); - - let begin_lsn; - { - let logger = - Logger::open(dir.path()).expect("logger can be created"); - begin_lsn = logger - .append(Record::Begin { - txn_id: 1, - prev_lsn: None, - }) - .expect("begin can be appended"); - logger - .flush_through(begin_lsn) - .expect("record can be flushed"); - logger - .sync_all() - .expect("record can be synced"); - } - - let mut bytes = std::fs::read(&path).expect("wal file can be read"); - bytes.extend_from_slice(MAGIC.as_bytes()); - - let mut cursor = Cursor::new(bytes); - let (_next_lsn, lsns) = scan_records_from(&mut cursor, 0, 0) - .expect("trailing partial frame is treated as end of valid WAL"); - let last_lsn = lsns.last().unwrap().lsn; - - assert_eq!(last_lsn, begin_lsn); - } - - #[test] - fn get_retrieves_flushed_and_buffered_records_by_lsn() { - let (_dir, logger) = temp_logger(); - - let begin_lsn = logger - .append(Record::Begin { - txn_id: 1, - prev_lsn: None, - }) - .expect("begin can be appended"); - let update_lsn = logger - .append(update_record(Some(begin_lsn.into()))) - .expect("update can be appended"); - let commit_lsn = logger - .append(Record::Commit { - txn_id: 1, - prev_lsn: Some(update_lsn.into()), - }) - .expect("commit can be appended"); - - logger - .flush_through(update_lsn) - .expect("first two records can be flushed"); - - let begin = logger - .get(begin_lsn) - .expect("flushed begin lookup succeeds") - .expect("flushed begin is found"); - assert_eq!(begin.lsn, begin_lsn); - assert_eq!(begin.record.kind(), "begin"); - - let update = logger - .get(update_lsn) - .expect("flushed update lookup succeeds") - .expect("flushed update is found"); - assert_eq!(update.lsn, update_lsn); - assert_eq!(update.record.kind(), "update"); - assert_eq!(update.record.prev_lsn(), Some(begin_lsn.into())); - - let commit = logger - .get(commit_lsn) - .expect("buffered commit lookup succeeds") - .expect("buffered commit is found"); - assert_eq!(commit.lsn, commit_lsn); - assert_eq!(commit.record.kind(), "commit"); - assert_eq!(commit.record.prev_lsn(), Some(update_lsn.into())); - - let eof = logger.next_lsn().unwrap(); - assert!( - logger - .get(eof) - .expect("lookup at EOF succeeds") - .is_none() - ); - } - - #[test] - fn records_from_reads_both_flushed_and_buffered() { - let (_dir, logger) = temp_logger(); - - let begin_lsn = logger - .append(Record::Begin { - txn_id: 1, - prev_lsn: None, - }) - .expect("begin can be appended"); - let update_lsn = logger - .append(update_record(Some(begin_lsn.into()))) - .expect("update can be appended"); - let commit_lsn = logger - .append(Record::Commit { - txn_id: 1, - prev_lsn: Some(update_lsn.into()), - }) - .expect("commit can be appended"); - let end_lsn = logger - .append(Record::End { - txn_id: 1, - prev_lsn: Some(commit_lsn.into()), - }) - .expect("end can be appended"); - - logger - .flush_through(update_lsn) - .expect("first two records can be flushed"); - - let records = logger - .records_from(begin_lsn) - .expect("flushed and buffered suffix can be read"); - assert_eq!( - records - .iter() - .map(|entry| entry.lsn) - .collect::>(), - vec![begin_lsn, update_lsn, commit_lsn, end_lsn] - ); - } - - #[test] - fn records_from_traverses_multiple_generations() { - let (_dir, logger) = temp_logger(); - - // Generation 0. - let g0_begin = logger - .append(Record::Begin { - txn_id: 1, - prev_lsn: None, - }) - .expect("begin can be appended"); - let g0_commit = logger - .append(Record::Commit { - txn_id: 1, - prev_lsn: Some(g0_begin.into()), - }) - .expect("commit can be appended"); - logger - .flush_through(g0_commit) - .expect("generation 0 can be flushed"); - - // Roll to generation 1. - let new_gen = logger - .rotate() - .expect("wal can rotate"); - assert_eq!(new_gen, 1); - - let g1_begin = logger - .append(Record::Begin { - txn_id: 2, - prev_lsn: None, - }) - .expect("begin can be appended in new generation"); - assert_eq!(g1_begin, Lsn::new(1, 0)); - - // Buffered record still in generation 1. - let g1_commit = logger - .append(Record::Commit { - txn_id: 2, - prev_lsn: Some(g1_begin.into()), - }) - .expect("commit can be appended in new generation"); - logger - .flush_through(g1_begin) - .expect("only the first generation-1 record is flushed"); - - // Traverse from the very first LSN across both generations, including - // the still-buffered final record. - let records = logger - .records_from(g0_begin) - .expect("records can be traversed across generations"); - assert_eq!( - records - .iter() - .map(|entry| entry.lsn) - .collect::>(), - vec![g0_begin, g0_commit, g1_begin, g1_commit] - ); - - // A generation-crossing lookup by LSN resolves to the right file. - let fetched = logger - .get(g1_begin) - .expect("cross-generation get succeeds") - .expect("record is found"); - assert_eq!(fetched.lsn, g1_begin); - assert_eq!(fetched.record.txn_id(), Some(2)); - } - - #[test] - fn wal_flush_guard_flushes_through_page_lsn() { - let dir = TempDir::new().expect("temp dir can be created"); - let wal = sync::Arc::new( - Logger::open(dir.path()).expect("logger can be created"), - ); - - let lsn = wal - .append(Record::Begin { - txn_id: 1, - prev_lsn: None, - }) - .expect("begin can be appended"); - - let guard = WalFlushGuard::new(wal.clone()); - let mut page = Page::build(vec![0; 4096]); - page.set_lsn(lsn.into()); - - guard - .before_flush(1, &page) - .expect("guard can flush WAL through page LSN"); - - assert_eq!(wal.flushed_lsn().unwrap(), Some(lsn)); - assert!( - wal.lock() - .unwrap() - .buffer - .is_empty() - ); - } - - /// A commit is only "durable" once its record has been flushed through - /// and synced. - #[test] - fn commit_is_durable_before_being_reported() { - let dir = TempDir::new().expect("temp dir can be created"); - - let commit_lsn; - { - let logger = - Logger::open(dir.path()).expect("logger can be created"); - let begin = logger - .append(Record::Begin { - txn_id: 1, - prev_lsn: None, - }) - .expect("begin can be appended"); - commit_lsn = logger - .append(Record::Commit { - txn_id: 1, - prev_lsn: Some(begin.into()), - }) - .expect("commit can be appended"); - - // Commit protocol: force WAL through the Commit record, then sync. - logger - .flush_through(commit_lsn) - .expect("commit record can be flushed"); - logger - .sync_all() - .expect("commit record can be synced"); - - assert!( - logger - .flushed_lsn() - .unwrap() - .unwrap() - >= commit_lsn, - "commit must be flushed before success is reported" - ); - } - - // Reopen: the committed record survived without an explicit End. - let reopened = - Logger::open(dir.path()).expect("logger can reopen after commit"); - let commit = reopened - .get(commit_lsn) - .expect("commit lookup succeeds") - .expect("commit is durable"); - assert_eq!(commit.record.kind(), "commit"); - } - - #[test] - fn append_rejects_update_with_mismatched_before_after() { - let (_dir, logger) = temp_logger(); - - let bad = Record::Update { - txn_id: 1, - page_id: 1, - offset: 0, - before: vec![1, 2, 3], - after: vec![9, 9], - prev_lsn: None, - }; - - let err = logger - .append(bad) - .expect_err("mismatched before/after must be rejected"); - assert_eq!(err.kind(), io::ErrorKind::InvalidData); - } - - #[test] - fn append_rejects_compensation_without_undo_next() { - let (_dir, logger) = temp_logger(); - - let bad = Record::Compensation { - txn_id: 1, - page_id: 1, - offset: 0, - after: vec![1, 2, 3], - undo_next_lsn: None, - prev_lsn: None, - }; - - let err = logger - .append(bad) - .expect_err("redo-only CLR without undo_next must be rejected"); - assert_eq!(err.kind(), io::ErrorKind::InvalidData); - } - - #[test] - fn validate_rejects_byte_range_beyond_page_size() { - let update = Record::Update { - txn_id: 1, - page_id: 1, - offset: 4094, - before: vec![0, 0, 0, 0], - after: vec![1, 1, 1, 1], - prev_lsn: None, - }; - - assert!(update.validate(None).is_ok()); - let err = update - .validate(Some(4096)) - .expect_err("range past page size must be rejected"); - assert_eq!(err.kind(), io::ErrorKind::InvalidData); - } - - #[test] - fn compensation_undo_next_lsn_drives_undo_traversal() { - let (_dir, logger) = temp_logger(); - - let begin = logger - .append(Record::Begin { - txn_id: 1, - prev_lsn: None, - }) - .expect("begin can be appended"); - let update = logger - .append(update_record(Some(begin.into()))) - .expect("update can be appended"); - - // Compensate the update: its undo_next_lsn points before the update, - // i.e. at the Begin record. - let clr = logger - .append(Record::Compensation { - txn_id: 1, - page_id: 7, - offset: 42, - after: vec![b'a', b'b', b'c'], - undo_next_lsn: Some(begin.into()), - prev_lsn: Some(update.into()), - }) - .expect("clr can be appended"); - - logger - .flush_through(clr) - .expect("records can be flushed"); - - let clr_entry = logger - .get(clr) - .expect("clr lookup succeeds") - .expect("clr is found"); - let Record::Compensation { undo_next_lsn, .. } = clr_entry.record() - else { - panic!("expected compensation record"); - }; - - // Undo resumes at undo_next_lsn, which resolves to the Begin record. - let resume = Lsn::from(undo_next_lsn.expect("clr carries undo_next")); - assert_eq!(resume, begin); - let resumed = logger - .get(resume) - .expect("resume lookup succeeds") - .expect("resume record is found"); - assert_eq!(resumed.record().kind(), "begin"); - } - - #[test] - fn flush_through_preserves_records_after_target() { - let (_dir, logger) = temp_logger(); - - let begin = logger - .append(Record::Begin { - txn_id: 1, - prev_lsn: None, - }) - .expect("begin can be appended"); - let update = logger - .append(update_record(Some(begin.into()))) - .expect("update can be appended"); - let commit = logger - .append(Record::Commit { - txn_id: 1, - prev_lsn: Some(update.into()), - }) - .expect("commit can be appended"); - - logger - .flush_through(update) - .expect("flush through the update only"); - - assert_eq!(logger.flushed_lsn().unwrap(), Some(update)); - // The commit remains buffered and is still retrievable by LSN. - assert_eq!( - logger - .lock() - .unwrap() - .buffer - .len(), - 1 - ); - let buffered = logger - .get(commit) - .expect("buffered commit lookup succeeds") - .expect("commit still buffered"); - assert_eq!(buffered.record().kind(), "commit"); - - // The on-disk prefix stops at the flushed target. - let on_disk = logger - .read_all() - .expect("flushed prefix can be read"); - assert_eq!( - on_disk - .iter() - .map(|e| e.lsn()) - .collect::>(), - vec![begin, update] - ); - } -} +use crate::{FlushGuard, read_be, storage};