From d525cd127d2383e050fe835cd2659e526fafd370 Mon Sep 17 00:00:00 2001 From: Davis Raymond Muro Date: Sat, 4 Jul 2026 20:29:17 -0700 Subject: [PATCH 01/31] pager: add a way to allocate new pages --- src/pager.rs | 475 ++++++++++++++++++++++++++++----------------------- 1 file changed, 258 insertions(+), 217 deletions(-) diff --git a/src/pager.rs b/src/pager.rs index 098cab9..3148de2 100644 --- a/src/pager.rs +++ b/src/pager.rs @@ -73,14 +73,16 @@ fn load_page( Ok(page) } -/// Writes a [`Page`] to `writer`. +/// Durably persists a [`Page`] to the given `writer` file, guaranteeing that +/// the written bytes are safely flushed and stored on disk. /// -/// Before writing, this updates the [`Page`]'s magic bytes and recalculates -/// its checksum so the persisted [`Page`] can be validated during durability checks. +/// 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 (impl Write + Seek), + writer: &mut File, page: &mut Page, ) -> io::Result<()> { info!("writing page {page_id} (size: {size})"); @@ -91,12 +93,21 @@ fn write_page( )); } - let offset = (page_id - 1) * size; - 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(()) } @@ -367,12 +378,9 @@ impl fmt::Debug for CachedPage { /// [`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, + inner: sync::Mutex, page_size: u16, flush_guard: sync::Arc, @@ -417,10 +425,81 @@ impl fmt::Debug for CacheInfo { } } -impl Pager -where - F: Read + Write + Seek, -{ +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 inner = OpenOptions::new() + .read(true) + .write(true) + .create(true) + .custom_flags(O_DIRECT) + .open(path.into())?; + let len = inner.metadata()?.len(); + + let mut 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: DEFAULT_PAGE_SIZE, + pages: sync::RwLock::new(HashMap::with_capacity(capacity)), + }; + + if len >= DEFAULT_PAGE_SIZE as u64 { + let page_size = out.page( + ROOT_PAGE_ID, + AccessContext::maintenance("startup"), + |p| p.page_size(), + )?; + out.page_size = page_size; + } else { + let mut root = create_page( + PageFlags::IsRoot, + DEFAULT_PAGE_SIZE, + HEADER_SIZE as u16, + true, + ); + out.flush(ROOT_PAGE_ID, &mut root)?; + } + Ok(out) + } + + /// Allocates `Self::page_size` in the storage file. Returning the + /// new page_id. + pub fn allocate_page(&self, flags: PageFlags) -> io::Result { + let mut inner = self + .inner + .lock() + .map_err(|_| { + io::Error::new( + io::ErrorKind::PermissionDenied, + "page allocate: mutex poisoned", + ) + })?; + let size = inner.seek(SeekFrom::End(0))?; + + if size % self.page_size as u64 != 0 { + warn!( + "page storage file size ({size} bytes) is not a multiple of page size ({} bytes); file structure may be corrupted", + self.page_size + ) + } + + let page_id = (size as usize / self.page_size as usize) + 1; + let page = + create_page(flags, self.page_size, HEADER_SIZE as u16, false); + self.track(page_id, page, true)?; + + Ok(page_id) + } + /// Set the [`FlushGuard`] for the [`Pager`]. Ensuring the set /// guards [`FlushGuard::before_flush`] is called before any data is synced /// to disk. @@ -790,7 +869,7 @@ where "failed to acquire lock on pager state", ) })?; - write_page(page_id, self.page_size as usize, &mut *inner, page)?; + write_page(page_id, self.page_size as usize, &mut inner, page)?; info!("page flushed: page_id={page_id} page_lsn={page_lsn}"); Ok(()) } @@ -999,69 +1078,7 @@ where } } -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(); - - let page_size: u16; - let root: Page; - let created: bool; - - 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 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)?; - - Ok(out) - } -} - -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() { @@ -1074,56 +1091,70 @@ where #[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()); + 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(); + + for (num_keys, marker) in pages { + let page_id = pager + .allocate_page(PageFlags::IsLeaf) + .expect("test page can be allocated"); + pager + .mut_page( + page_id, + AccessContext::maintenance("test setup"), + |page| { + page.set_num_keys(num_keys); + page.mut_cell(HEADER_SIZE, HEADER_SIZE + 1)[0] = marker; + }, + ) + .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"); + 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) -> 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) } - 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) -> io::Result<()> { + Err(io::Error::other("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| { @@ -1144,38 +1175,28 @@ 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'))]); + let (_dir, pager, ids) = pager_with_pages([(1, b'a')]); + let missing = ids[0] + 1; let err = pager - .page(2, AccessContext::anonymous(), |_| ()) + .page(missing, AccessContext::anonymous(), |_| ()) .expect_err("missing page should not load"); assert_eq!(err.kind(), io::ErrorKind::UnexpectedEof); 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'))]); + 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); @@ -1183,16 +1204,16 @@ mod tests { #[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], AccessContext::anonymous(), |_| ()) .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,17 +1221,21 @@ 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')]); 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], + AccessContext::maintenance("test mutation"), + |page| { + page.set_num_keys(42); + page.mut_cell(HEADER_SIZE, HEADER_SIZE + 1)[0] = b'z'; + }, + ) .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", ); @@ -1222,7 +1247,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 +1255,46 @@ 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"); assert_eq!(persisted.num_keys(), 42); assert_eq!(persisted.cell(HEADER_SIZE, HEADER_SIZE + 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')]); 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], + AccessContext::maintenance("test mutation"), + |page| { + page.set_num_keys(7); + page.mut_cell(HEADER_SIZE, HEADER_SIZE + 1)[0] = b'x'; + }, + ) .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"); assert_eq!(persisted.num_keys(), 7); assert_eq!(persisted.cell(HEADER_SIZE, HEADER_SIZE + 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,7 +1305,7 @@ 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(); @@ -1284,69 +1313,70 @@ mod tests { 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'))]); + let (_dir, mut pager, ids) = pager_with_pages([(1, b'a')]); pager.set_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], + AccessContext::maintenance("test mutation"), + |page| { + page.set_num_keys(99); + page.mut_cell(HEADER_SIZE, HEADER_SIZE + 1)[0] = b'q'; + }, + ) .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!(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"); assert_eq!(persisted.num_keys(), 1); assert_eq!(persisted.cell(HEADER_SIZE, HEADER_SIZE + 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')]); 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], + AccessContext::maintenance("test mutation"), + |page| { + page.set_num_keys(11); + page.mut_cell(HEADER_SIZE, HEADER_SIZE + 1)[0] = b'x'; + }, + ) .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], + AccessContext::maintenance("test mutation"), + |page| { + page.set_num_keys(22); + page.mut_cell(HEADER_SIZE, HEADER_SIZE + 1)[0] = b'y'; + }, + ) .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 +1389,31 @@ 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"); assert_eq!(persisted.num_keys(), 11); assert_eq!(persisted.cell(HEADER_SIZE, HEADER_SIZE + 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"); assert_eq!(persisted.num_keys(), 22); assert_eq!(persisted.cell(HEADER_SIZE, HEADER_SIZE + 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], AccessContext::anonymous(), |_| ()) .expect("page 1 can be loaded"); pager - .page(2, AccessContext::anonymous(), |_| ()) + .page(ids[1], AccessContext::anonymous(), |_| ()) .expect("page 2 can be loaded"); assert_eq!(pager.info().len(), 2); @@ -1398,27 +1427,33 @@ 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')]); + eprintln!("pager: {pager} ids: {ids:?}"); 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], + AccessContext::maintenance("test mutation"), + |page| { + page.set_num_keys(31); + page.mut_cell(HEADER_SIZE, HEADER_SIZE + 1)[0] = b'm'; + }, + ) .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], + AccessContext::maintenance("test mutation"), + |page| { + page.set_num_keys(32); + page.mut_cell(HEADER_SIZE, HEADER_SIZE + 1)[0] = b'n'; + }, + ) .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,24 +1465,30 @@ 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"); assert_eq!(persisted.num_keys(), 31); assert_eq!(persisted.cell(HEADER_SIZE, HEADER_SIZE + 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"); assert_eq!(persisted.num_keys(), 32); assert_eq!(persisted.cell(HEADER_SIZE, HEADER_SIZE + 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')]); 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], + AccessContext::maintenance("test mutation"), + |page| { + page.set_num_keys(44); + page.mut_cell(HEADER_SIZE, HEADER_SIZE + 1)[0] = b'r'; + }, + ) .expect("page can be mutated"); let err = pager @@ -1467,14 +1508,14 @@ 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"); assert_eq!(persisted.num_keys(), 44); assert_eq!(persisted.cell(HEADER_SIZE, HEADER_SIZE + 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(), |_| ()) From 0a94da133b417bab99f8d0bd0f01a70ff20f8bfd Mon Sep 17 00:00:00 2001 From: Davis Raymond Muro Date: Sat, 4 Jul 2026 22:27:33 -0700 Subject: [PATCH 02/31] page: restructure page header This changes the header significantly; reserving 64 bytes at the start of all `Page`s in the file. The header is now different different on the type of `Page`: - Meta Page: The first page of any storage file. Contains information about the storage file (global variabls and such) - Leaf & Internal Page: These are going to be B+ or Blink Tree Nodes --- src/page.rs | 214 ++++++++++++++++++++++++++++++++++++++------------- src/pager.rs | 178 ++++++++++++++++++++++++------------------ 2 files changed, 263 insertions(+), 129 deletions(-) diff --git a/src/page.rs b/src/page.rs index 430d02d..370f032 100644 --- a/src/page.rs +++ b/src/page.rs @@ -4,7 +4,11 @@ use bitflags::bitflags; use crate::CRC32C; -pub const CHECKSUM_OFFSET: usize = 0; +pub const MAGIC_SIZE: usize = 8; +pub const MAGIC: [u8; MAGIC_SIZE] = [25, 3, 20, 26, 7, 4, 8, 0]; +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; @@ -23,20 +27,38 @@ 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 PAGE_SIZE_OFFSET: usize = LSN_OFFSET + LSN_SIZE; -pub const PAGE_SIZE_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 = PAGE_SIZE_OFFSET + PAGE_SIZE_SIZE; +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 HEADER_SIZE: usize = 100; +pub const BTREE_ROOT_OFFSET: usize = + FORMAT_VERSION_OFFSET + FORMAT_VERSION_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 MAGIC: &str = "CRYOGENIC"; -pub const MAGIC_SIZE: usize = MAGIC.len(); -pub const MAGIC_OFFSET: usize = HEADER_SIZE - MAGIC_SIZE; +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; macro_rules! read_be { ($page:expr, $ty:ty, $start:expr, $end: expr) => { @@ -56,50 +78,99 @@ macro_rules! write_be { }; } macro_rules! field { - ($getter:ident, $setter:ident, $ty:ty, $start:expr, $end:expr) => { + ($getter:ident, $setter:ident, $ty:ty, $start:expr) => { pub fn $getter(&self) -> $ty { - read_be!(self, $ty, $start, $end) + read_be!(self, $ty, $start, $start + size_of::<$ty>()) } pub fn $setter(&mut self, value: $ty) { - write_be!(self, $start, $end, value) + write_be!(self, $start, $start + size_of::<$ty>(), 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 +/// ## Page Header +/// +/// ### Common +/// ```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 +/// [..] +/// [21..29] u64 latest_lsn +/// [..] +/// [49..53] u32 overflow_offset (only valid if has_overflow bit is set) +/// [..] reserved +/// [64..] body start +/// ``` +/// +/// ### Meta Page: +/// ```text +/// [..] +/// [19..21] u16 page_size +/// [..] +/// [29..30] u8 format_version +/// [30..34] u32 tree_root +/// [..] +/// [64..] body start +/// ``` +/// +/// ### Table Page +/// ``` text +/// [..] +/// [19..21] u16 number_of_keys +/// [..] +/// [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 (only valid if internal page) +/// [..] +/// [64..] body start +/// ``` +/// /// #[derive(Clone)] pub struct Page { inner: Box<[u8]>, } +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub enum PageKind { + Meta, + Leaf, + Root, + Internal, + Overflow, + Free, +} + 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; + 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 PageFlags { + pub fn is_set(&self, other: PageFlags) -> bool { + (*self & other).bits() == 1 } } impl Page { - /// Create a new [`Page`] + /// Build a page from `bytes` /// /// ## Note /// @@ -117,11 +188,36 @@ impl Page { Self { inner } } + /// Create a new [`Page`] of `size` size. + pub fn new(size: u16, flags: PageFlags) -> Self { + let inner = vec![0; size as usize]; + let mut out = Self::build(inner); + + out.set_flags(flags.bits()); + out.set_free_space_start(HEADER_SIZE as u16); + out.set_free_space_end(size); + out.set_free_space(size - HEADER_SIZE as u16); + out.set_magic(); + out.set_checksum(out.compute_checksum()); + + if flags.is_set(PageFlags::IsMeta) { + out.set_page_size(size); + out.set_format_version(FORMAT_VERSION); + } + + out + } + /// Immutable view into the held [`Page`] pub fn cell(&self, start: usize, end: usize) -> &[u8] { &self[start..end] } + /// Immutable view into the held [`Page`] + pub fn cell_from(&self, start: usize) -> &[u8] { + &self[start..] + } + /// Mutable view into the held [`Page`] pub fn mut_cell(&mut self, start: usize, end: usize) -> &mut [u8] { &mut self[start..end] @@ -133,53 +229,65 @@ impl Page { } pub fn magic(&self) -> &[u8] { - self.cell(MAGIC_OFFSET, HEADER_SIZE) + self.cell(MAGIC_OFFSET, CHECKSUM_OFFSET) } pub fn set_magic(&mut self) { - self.mut_cell(MAGIC_OFFSET, HEADER_SIZE) - .copy_from_slice(MAGIC.as_bytes()); + self.mut_cell(MAGIC_OFFSET, CHECKSUM_OFFSET) + .copy_from_slice(&MAGIC); + } + + /// 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) } - field!(checksum, set_checksum, u32, CHECKSUM_OFFSET, FLAGS_OFFSET); - field!(flags, set_flags, u8, FLAGS_OFFSET, FREESPACE_START_OFFSET); + field!(checksum, set_checksum, u32, CHECKSUM_OFFSET); + field!(flags, set_flags, u8, FLAGS_OFFSET); field!( free_space_start, set_free_space_start, u16, - FREESPACE_START_OFFSET, - FREESPACE_END_OFFSET + FREESPACE_START_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 + FREESPACE_END_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!(free_space, set_free_space, u16, FREESPACE_OFFSET); + field!(num_keys, set_num_keys, u16, NUM_KEY_OFFSET); + field!(page_size, set_page_size, u16, PAGE_SIZE_OFFSET); + field!(latest_lsn, set_lsn, u64, LSN_OFFSET); + field!(left_sibling, set_left_sibling, u32, LEFT_SIBLING_OFFSET); + field!(right_sibling, set_right_sibling, u32, RIGHT_SIBLING_OFFSET); + field!(high_key, set_high_key, u64, NODE_HIGH_KEY_OFFSET); field!( - page_size, - set_page_size, - u16, - PAGE_SIZE_OFFSET, - FORMAT_VERSION_OFFSET + right_pointer, + set_right_pointer, + u32, + RIGHT_MOST_POINTER_OFFSET ); + field!(overflow, set_overflow, u32, OVERFLOW_OFFSET_OFFSET); field!( format_version, set_format_version, u8, - FORMAT_VERSION_OFFSET, - FORMAT_VERSION_OFFSET + FORMAT_VERSION_SIZE + FORMAT_VERSION_OFFSET ); + field!(tree_root, set_tree_root, u32, BTREE_ROOT_OFFSET); } impl ops::DerefMut for Page { diff --git a/src/pager.rs b/src/pager.rs index 3148de2..d9f67ff 100644 --- a/src/pager.rs +++ b/src/pager.rs @@ -1,9 +1,6 @@ //! Pager and page-cache support for on-disk pages. //! -use crate::{ - Page, PageFlags, - page::{HEADER_SIZE, MAGIC}, -}; +use crate::{Page, PageFlags}; use log::{debug, info, trace, warn}; use std::{ collections::HashMap, @@ -24,13 +21,10 @@ 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; +pub const META_PAGE_ID: usize = 1; /// Loads a [`Page`] of `size` bytes from `reader`. /// @@ -57,17 +51,8 @@ fn load_page( 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", - )); + if let (_, Some(reason)) = page.valid() { + return Err(io::Error::new(io::ErrorKind::InvalidData, reason)); } Ok(page) @@ -112,35 +97,6 @@ fn write_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 -} - /// [`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. @@ -454,19 +410,14 @@ impl Pager { if len >= DEFAULT_PAGE_SIZE as u64 { let page_size = out.page( - ROOT_PAGE_ID, + META_PAGE_ID, AccessContext::maintenance("startup"), |p| p.page_size(), )?; out.page_size = page_size; } else { - let mut root = create_page( - PageFlags::IsRoot, - DEFAULT_PAGE_SIZE, - HEADER_SIZE as u16, - true, - ); - out.flush(ROOT_PAGE_ID, &mut root)?; + let mut meta_page = Page::new(DEFAULT_PAGE_SIZE, PageFlags::IsMeta); + out.flush(META_PAGE_ID, &mut meta_page)?; } Ok(out) } @@ -493,8 +444,7 @@ impl Pager { } let page_id = (size as usize / self.page_size as usize) + 1; - let page = - create_page(flags, self.page_size, HEADER_SIZE as u16, false); + let page = Page::new(self.page_size, flags); self.track(page_id, page, true)?; Ok(page_id) @@ -1115,8 +1065,9 @@ mod tests { page_id, AccessContext::maintenance("test setup"), |page| { + let start = page.free_space_start() as usize; page.set_num_keys(num_keys); - page.mut_cell(HEADER_SIZE, HEADER_SIZE + 1)[0] = marker; + page.mut_cell(start, start + 1)[0] = marker; }, ) .expect("test page can be initialized"); @@ -1160,7 +1111,10 @@ mod tests { .page(id, AccessContext::anonymous(), |page| { ( page.num_keys(), - page.cell(HEADER_SIZE, HEADER_SIZE + 1)[0], + page.cell( + page.free_space_start() as usize, + page.free_space_start() as usize + 1, + )[0], ) }) .expect("page exists in backing store"); @@ -1229,7 +1183,10 @@ mod tests { AccessContext::maintenance("test mutation"), |page| { page.set_num_keys(42); - page.mut_cell(HEADER_SIZE, HEADER_SIZE + 1)[0] = b'z'; + page.mut_cell( + page.free_space_start() as usize, + page.free_space_start() as usize + 1, + )[0] = b'z'; }, ) .expect("page can be mutated"); @@ -1257,7 +1214,13 @@ mod tests { let persisted = persisted_page(&pager, ids[0]).expect("flushed page is valid"); 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] @@ -1270,7 +1233,10 @@ mod tests { AccessContext::maintenance("test mutation"), |page| { page.set_num_keys(7); - page.mut_cell(HEADER_SIZE, HEADER_SIZE + 1)[0] = b'x'; + page.mut_cell( + page.free_space_start() as usize, + page.free_space_start() as usize + 1, + )[0] = b'x'; }, ) .expect("page can be mutated"); @@ -1287,7 +1253,13 @@ mod tests { let persisted = persisted_page(&pager, ids[0]).expect("flushed page is valid"); 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] @@ -1324,7 +1296,10 @@ mod tests { AccessContext::maintenance("test mutation"), |page| { page.set_num_keys(99); - page.mut_cell(HEADER_SIZE, HEADER_SIZE + 1)[0] = b'q'; + page.mut_cell( + page.free_space_start() as usize, + page.free_space_start() as usize + 1, + )[0] = b'q'; }, ) .expect("page can be mutated"); @@ -1346,7 +1321,13 @@ mod tests { let persisted = persisted_page(&pager, ids[0]).expect("original page is valid"); 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] @@ -1359,7 +1340,10 @@ mod tests { AccessContext::maintenance("test mutation"), |page| { page.set_num_keys(11); - page.mut_cell(HEADER_SIZE, HEADER_SIZE + 1)[0] = b'x'; + page.mut_cell( + page.free_space_start() as usize, + page.free_space_start() as usize + 1, + )[0] = b'x'; }, ) .expect("page 1 can be mutated"); @@ -1369,7 +1353,10 @@ mod tests { AccessContext::maintenance("test mutation"), |page| { page.set_num_keys(22); - page.mut_cell(HEADER_SIZE, HEADER_SIZE + 1)[0] = b'y'; + page.mut_cell( + page.free_space_start() as usize, + page.free_space_start() as usize + 1, + )[0] = b'y'; }, ) .expect("page 2 can be mutated"); @@ -1397,12 +1384,24 @@ mod tests { let persisted = persisted_page(&pager, ids[0]).expect("page 1 was flushed"); 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, ids[1]).expect("page 2 was flushed"); 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] @@ -1436,7 +1435,10 @@ mod tests { AccessContext::maintenance("test mutation"), |page| { page.set_num_keys(31); - page.mut_cell(HEADER_SIZE, HEADER_SIZE + 1)[0] = b'm'; + page.mut_cell( + page.free_space_start() as usize, + page.free_space_start() as usize + 1, + )[0] = b'm'; }, ) .expect("page 1 can be mutated"); @@ -1446,7 +1448,10 @@ mod tests { AccessContext::maintenance("test mutation"), |page| { page.set_num_keys(32); - page.mut_cell(HEADER_SIZE, HEADER_SIZE + 1)[0] = b'n'; + page.mut_cell( + page.free_space_start() as usize, + page.free_space_start() as usize + 1, + )[0] = b'n'; }, ) .expect("page 2 can be mutated"); @@ -1468,12 +1473,24 @@ mod tests { let persisted = persisted_page(&pager, ids[0]).expect("page 1 was flushed"); 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, ids[1]).expect("page 2 was flushed"); 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] @@ -1486,7 +1503,10 @@ mod tests { AccessContext::maintenance("test mutation"), |page| { page.set_num_keys(44); - page.mut_cell(HEADER_SIZE, HEADER_SIZE + 1)[0] = b'r'; + page.mut_cell( + page.free_space_start() as usize, + page.free_space_start() as usize + 1, + )[0] = b'r'; }, ) .expect("page can be mutated"); @@ -1510,7 +1530,13 @@ mod tests { let persisted = persisted_page(&pager, ids[0]).expect("flushed page is valid"); 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] From 38ed44772698ea12e2248fb88fca2e13b553c7cd Mon Sep 17 00:00:00 2001 From: Davis Raymond Muro Date: Sat, 4 Jul 2026 20:43:46 -0700 Subject: [PATCH 03/31] pager: provide latest lsn in diagnostics --- src/pager.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/pager.rs b/src/pager.rs index d9f67ff..941e542 100644 --- a/src/pager.rs +++ b/src/pager.rs @@ -357,6 +357,7 @@ pub struct CacheInfo { pub page_id: usize, pub dirty: bool, pub accessed: bool, + pub latest_lsn: u64, pub pin_count: usize, pub handles: Vec, } @@ -588,6 +589,11 @@ impl Pager { .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), @@ -658,6 +664,8 @@ impl Pager { // 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(()) From 5db985fd8ffb69c42e8df908f9c9d07c11f4606e Mon Sep 17 00:00:00 2001 From: Davis Raymond Muro Date: Sat, 4 Jul 2026 22:36:01 -0700 Subject: [PATCH 04/31] pager: ensure loaded meta page is valid --- src/page.rs | 10 ---------- src/pager.rs | 32 +++++++++++++++++++++++++------- 2 files changed, 25 insertions(+), 17 deletions(-) diff --git a/src/page.rs b/src/page.rs index 370f032..5f8a006 100644 --- a/src/page.rs +++ b/src/page.rs @@ -139,16 +139,6 @@ pub struct Page { inner: Box<[u8]>, } -#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] -pub enum PageKind { - Meta, - Leaf, - Root, - Internal, - Overflow, - Free, -} - bitflags! { /// [`PageFlags`] is a set of all possible flags to a page #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] diff --git a/src/pager.rs b/src/pager.rs index 941e542..abd591e 100644 --- a/src/pager.rs +++ b/src/pager.rs @@ -1,6 +1,6 @@ //! Pager and page-cache support for on-disk pages. //! -use crate::{Page, PageFlags}; +use crate::{Page, PageFlags, page::FORMAT_VERSION}; use log::{debug, info, trace, warn}; use std::{ collections::HashMap, @@ -385,8 +385,8 @@ impl fmt::Debug for CacheInfo { 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 + /// 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) -> io::Result { let inner = OpenOptions::new() @@ -410,12 +410,30 @@ impl Pager { }; if len >= DEFAULT_PAGE_SIZE as u64 { - let page_size = out.page( + out.page_size = out.page( META_PAGE_ID, AccessContext::maintenance("startup"), - |p| p.page_size(), - )?; - out.page_size = page_size; + |p| -> io::Result { + if PageFlags::from_bits(p.flags()) + .expect("is valid flag bits") + .is_set(PageFlags::IsMeta) + { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "unable to locate database meta page", + )); + } + + if p.format_version() != FORMAT_VERSION { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "unsupported format version", + )); + } + + Ok(p.page_size()) + }, + )??; } else { let mut meta_page = Page::new(DEFAULT_PAGE_SIZE, PageFlags::IsMeta); out.flush(META_PAGE_ID, &mut meta_page)?; From 2c74cabe5a0325d41840a172e7dd9a951e156949 Mon Sep 17 00:00:00 2001 From: Davis Raymond Muro Date: Sun, 5 Jul 2026 01:14:20 -0700 Subject: [PATCH 05/31] lib: define storage cell interface --- src/lib.rs | 201 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 201 insertions(+) diff --git a/src/lib.rs b/src/lib.rs index 42a96f1..f572ed0 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,5 +1,8 @@ +use std::io; + use crc::Crc; +pub mod btree; pub mod page; pub mod pager; pub mod recovery; @@ -24,3 +27,201 @@ 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, 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 = u32::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>; +} From a89c3ddfb760e16657afbef3b482a277202d81f2 Mon Sep 17 00:00:00 2001 From: Davis Raymond Muro Date: Sun, 5 Jul 2026 04:00:30 -0700 Subject: [PATCH 06/31] pager: allocate page ID based on tracked ID --- src/page.rs | 13 +++++------ src/pager.rs | 66 +++++++++++++++++++++++++++++++++++----------------- 2 files changed, 51 insertions(+), 28 deletions(-) diff --git a/src/page.rs b/src/page.rs index 5f8a006..8cc7074 100644 --- a/src/page.rs +++ b/src/page.rs @@ -42,6 +42,9 @@ 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::(); @@ -116,6 +119,7 @@ macro_rules! field { /// [..] /// [29..30] u8 format_version /// [30..34] u32 tree_root +/// [34..36] u16 next_page /// [..] /// [64..] body start /// ``` @@ -153,12 +157,6 @@ bitflags! { } } -impl PageFlags { - pub fn is_set(&self, other: PageFlags) -> bool { - (*self & other).bits() == 1 - } -} - impl Page { /// Build a page from `bytes` /// @@ -190,7 +188,7 @@ impl Page { out.set_magic(); out.set_checksum(out.compute_checksum()); - if flags.is_set(PageFlags::IsMeta) { + if flags.contains(PageFlags::IsMeta) { out.set_page_size(size); out.set_format_version(FORMAT_VERSION); } @@ -278,6 +276,7 @@ impl Page { FORMAT_VERSION_OFFSET ); field!(tree_root, set_tree_root, u32, BTREE_ROOT_OFFSET); + field!(next_page, set_next_page, u16, NEXT_PAGE_OFFSET); } impl ops::DerefMut for Page { diff --git a/src/pager.rs b/src/pager.rs index abd591e..97e67db 100644 --- a/src/pager.rs +++ b/src/pager.rs @@ -414,9 +414,9 @@ impl Pager { META_PAGE_ID, AccessContext::maintenance("startup"), |p| -> io::Result { - if PageFlags::from_bits(p.flags()) + if !PageFlags::from_bits(p.flags()) .expect("is valid flag bits") - .is_set(PageFlags::IsMeta) + .contains(PageFlags::IsMeta) { return Err(io::Error::new( io::ErrorKind::InvalidData, @@ -435,35 +435,43 @@ impl Pager { }, )??; } else { - let mut meta_page = Page::new(DEFAULT_PAGE_SIZE, PageFlags::IsMeta); - out.flush(META_PAGE_ID, &mut meta_page)?; + let mut meta = Page::new(out.page_size, PageFlags::IsMeta); + meta.set_next_page((META_PAGE_ID + 1) as u16); + out.flush(META_PAGE_ID, &mut meta)?; } Ok(out) } /// Allocates `Self::page_size` in the storage file. Returning the /// new page_id. - pub fn allocate_page(&self, flags: PageFlags) -> io::Result { - let mut inner = self - .inner - .lock() - .map_err(|_| { - io::Error::new( - io::ErrorKind::PermissionDenied, - "page allocate: mutex poisoned", - ) - })?; - let size = inner.seek(SeekFrom::End(0))?; + pub fn allocate_page( + &self, + ctx: AccessContext, + flags: PageFlags, + ) -> io::Result { + let page_id = self.mut_page(META_PAGE_ID, ctx, |p| { + let page_id = p.next_page(); + p.set_next_page(page_id + 1); + if let Some(lsn) = ctx.lsn { + p.set_lsn(lsn); + } + page_id + })? as usize; + + let page_size = self.page_size as usize; + let offset = page_id * page_size; - if size % self.page_size as u64 != 0 { + if !offset.is_multiple_of(page_size) { warn!( - "page storage file size ({size} bytes) is not a multiple of page size ({} bytes); file structure may be corrupted", + "page storage file size ({offset} bytes) is not a multiple of page size ({} bytes); file structure may be corrupted", self.page_size ) } - let page_id = (size as usize / self.page_size as usize) + 1; - let page = Page::new(self.page_size, flags); + 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) @@ -846,7 +854,14 @@ impl Pager { ) })?; write_page(page_id, self.page_size as usize, &mut inner, page)?; - info!("page flushed: page_id={page_id} page_lsn={page_lsn}"); + let flags = + PageFlags::from_bits(page.flags()).expect("flags is parseable"); + 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(()) } @@ -1084,7 +1099,10 @@ mod tests { for (num_keys, marker) in pages { let page_id = pager - .allocate_page(PageFlags::IsLeaf) + .allocate_page( + AccessContext::maintenance("pager with pages"), + PageFlags::IsLeaf, + ) .expect("test page can be allocated"); pager .mut_page( @@ -1103,6 +1121,12 @@ mod tests { 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); } From 22a9d3c5ac4f72078726ed92245dfc80f6833011 Mon Sep 17 00:00:00 2001 From: Davis Raymond Muro Date: Sun, 5 Jul 2026 18:23:12 -0700 Subject: [PATCH 07/31] page: add `Page::reset` allowing pages to be reset --- src/page.rs | 25 +++++++++++++++---------- src/pager.rs | 2 +- 2 files changed, 16 insertions(+), 11 deletions(-) diff --git a/src/page.rs b/src/page.rs index 8cc7074..1f869d2 100644 --- a/src/page.rs +++ b/src/page.rs @@ -180,20 +180,25 @@ impl Page { pub fn new(size: u16, flags: PageFlags) -> Self { let inner = vec![0; size as usize]; let mut out = Self::build(inner); + out.reset(size, flags); + out + } + + pub fn reset(&mut self, size: u16, flags: PageFlags) { + self.mut_cell(0, size as usize) + .copy_from_slice(vec![0; size as usize].as_ref()); - out.set_flags(flags.bits()); - out.set_free_space_start(HEADER_SIZE as u16); - out.set_free_space_end(size); - out.set_free_space(size - HEADER_SIZE as u16); - out.set_magic(); - out.set_checksum(out.compute_checksum()); + self.set_flags(flags.bits()); + 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()); if flags.contains(PageFlags::IsMeta) { - out.set_page_size(size); - out.set_format_version(FORMAT_VERSION); + self.set_page_size(size); + self.set_format_version(FORMAT_VERSION); } - - out } /// Immutable view into the held [`Page`] diff --git a/src/pager.rs b/src/pager.rs index 97e67db..dac6dbd 100644 --- a/src/pager.rs +++ b/src/pager.rs @@ -337,7 +337,7 @@ impl fmt::Debug for CachedPage { pub struct Pager { capacity: usize, inner: sync::Mutex, - page_size: u16, + pub page_size: u16, flush_guard: sync::Arc, clock: sync::Mutex, From 180c10025321c560b4067a40be8611342728f91b Mon Sep 17 00:00:00 2001 From: Davis Raymond Muro Date: Sun, 5 Jul 2026 18:42:27 -0700 Subject: [PATCH 08/31] btree: initial `Tree` & `Cursor` implementation --- src/btree/cursor.rs | 466 ++++++++++++++++++++++++++++++++++++++++++++ src/btree/mod.rs | 89 +++++++++ src/lib.rs | 7 +- 3 files changed, 560 insertions(+), 2 deletions(-) create mode 100644 src/btree/cursor.rs create mode 100644 src/btree/mod.rs diff --git a/src/btree/cursor.rs b/src/btree/cursor.rs new file mode 100644 index 0000000..87a7cb4 --- /dev/null +++ b/src/btree/cursor.rs @@ -0,0 +1,466 @@ +use std::{io, sync::Arc}; + +use log::trace; + +use crate::{ + AccessContext, KEYCELL_SIZE, Key, KeyCell, Page, PageFlags, ValueCell, + btree::TreeInner, page::HEADER_SIZE, +}; + +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 [`Tree`] + pub fn from_root(tree: &Arc) -> io::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: AccessContext, + key: &Key, + value: Vec, + ) -> io::Result> { + while !self.find(ctx, key)? { + trace!( + "tree cursor search: key={key} current_page={} depth={}", + self.current_page, + self.breadcrumbs.len() + ); + } + + 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) -> io::Result> { + while !self.find( + AccessContext::maintenance("tree cursor search locate leaf node"), + key, + )? { + trace!( + "tree cursor search: key={key} current_page={} depth={}", + self.current_page, + self.breadcrumbs.len() + ); + } + + let out = self.read_value( + AccessContext::maintenance("tree cursor search read value"), + 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)) + } +} + +impl Cursor { + /// Inserts new value cell in the page cursor is currently in + fn insert_new( + &self, + ctx: AccessContext, + cell: &ValueCell, + ) -> io::Result<()> { + self.tree + .pager + .mut_page(self.current_page, ctx, |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(io::Error::new( + io::ErrorKind::StorageFull, + "cursor insert to page would overflow", + )); + } + + 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(io::Error::other("page is heavily fragmented")); + } + + let key = KeyCell { + key: cell.key, + offset: new_space_end as u32, + }; + + 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(io::Error::other( + "called insert new on existing key", + )), + } + })? + } + + /// Update `old` in the page [`Cursor`] is in with `updated`. + fn insert_existing( + &self, + ctx: AccessContext, + old: &CursorValue, + updated: &ValueCell, + ) -> io::Result<()> { + self.tree + .pager + .mut_page(self.current_page, ctx, |page| { + let mut key = self + .key_range(old.idx, old.idx + 1, page)? + .pop() + .expect("should have old key"); + + 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; + + let mut new_space = free_space + old.val_cell.len(); + let new_space_end = free_space_end - updated.len(); + + if updated.len() > new_space { + return Err(io::Error::new( + io::ErrorKind::StorageFull, + "cursor insert to page would overflow", + )); + } + + if new_space_end < free_space_start { + return Err(io::Error::other("page is heavily fragmented")); + } + + key.offset = new_space_end as u32; + let key_bytes: [u8; KEYCELL_SIZE] = (&key).into(); + let value_bytes: Box<[u8]> = updated.into(); + let key_offset = HEADER_SIZE + (old.idx * KEYCELL_SIZE); + new_space -= value_bytes.len(); + + page.mut_cell(new_space_end, free_space_end) + .copy_from_slice(&value_bytes); + page.mut_cell(key_offset, key_offset + KEYCELL_SIZE) + .copy_from_slice(&key_bytes); + + page.set_free_space_end(new_space_end as u16); + page.set_free_space(new_space as u16); + + 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, + ) -> io::Result> { + self.tree + .pager + .page(self.current_page, ctx, |page| { + let Some(flags) = PageFlags::from_bits(page.flags()) else { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("corrupted page {} flags", self.current_page), + )); + }; + + if !flags.contains(PageFlags::IsLeaf) { + return Err(io::Error::new( + io::ErrorKind::Unsupported, + format!( + "attempt to read non-leaf page {}", + self.current_page + ), + )); + } + + let count = page.num_keys() as usize; + let keys = self.key_range(0, count, 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), + )?; + + Ok(Some((pos, value).into())) + } + Err(_) => Ok(None), + } + })? + } + + /// 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(&mut self, ctx: AccessContext, key: &Key) -> io::Result { + let next_location = + self.tree + .pager + .page(self.current_page, ctx, |page| { + let Some(flags) = PageFlags::from_bits(page.flags()) else { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!( + "corrupted page {} flags", + self.current_page + ), + )); + }; + + 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(), + } + } else { + keys[pos - 1].clone() + } + } + }; + Ok(Some(next_page)) + })??; + + 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) + } + + /// 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: &Page, + ) -> io::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::>>(); + + for r in keys.drain(..) { + out.push(r?); + } + + Ok(out) + } +} + +#[cfg(test)] +mod test { + use tempfile::TempDir; + + use crate::{AccessContext, btree::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) + } + + #[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 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( + AccessContext::maintenance("cursor insert test"), + &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 record_1: (u32, &str) = (10, "abc"); + let record_2: (u32, &str) = (10, "lorem ipsum"); + + let out = tree + .cursor() + .expect("can init cursor") + .insert( + AccessContext::maintenance("cursor insert overwrite"), + &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( + AccessContext::maintenance("cursor insert overwrite"), + &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()); + } +} diff --git a/src/btree/mod.rs b/src/btree/mod.rs new file mode 100644 index 0000000..fe4a6f8 --- /dev/null +++ b/src/btree/mod.rs @@ -0,0 +1,89 @@ +pub mod cursor; + +use std::{io, path::PathBuf, sync::Arc}; + +pub use cursor::Cursor; + +use crate::{AccessContext, PageFlags, Pager, pager::META_PAGE_ID}; + +/// [Tree] is a wrapping structure that signifies a `Blink-Tree` index-organized +/// table that can be traversed by [`Cursor`]. +pub struct Tree { + inner: Arc, +} + +/// Provides standardized access for [`Cursor`] to navigate the [`Tree`] +pub struct TreeInner { + pager: Pager, +} + +impl TreeInner { + /// Returns the root of the [`Tree`] + pub fn root(&self) -> io::Result { + Ok(self.pager.page( + META_PAGE_ID, + AccessContext::maintenance("btree locate root"), + |p| p.tree_root(), + )? as usize) + } + + /// Set current tree root to `root` + pub fn set_root(&self, ctx: AccessContext, root: u32) -> io::Result<()> { + self.pager + .mut_page(META_PAGE_ID, ctx, |page| { + page.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: AccessContext, + flags: PageFlags, + ) -> io::Result<()> { + let new_root = self + .pager + .allocate_page(ctx, flags | PageFlags::IsRoot)?; + self.set_root(ctx, new_root as u32)?; + Ok(()) + } +} + +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, + ) -> io::Result { + let tree = Self { + inner: Arc::new(TreeInner { + pager: Pager::open(path, cache_size)?, + }), + }; + + let root = tree.inner.pager.page( + META_PAGE_ID, + AccessContext::maintenance("load tree from disk"), + |p| p.tree_root(), + )?; + + if root == 0 { + tree.inner.create_root( + AccessContext::maintenance("initialize tree"), + PageFlags::IsLeaf, + )?; + } + + Ok(tree) + } + + /// Creates a new [`Cursor`] that can be used to traverse + /// the tree. + pub fn cursor(&self) -> io::Result { + Cursor::from_root(&self.inner) + } +} diff --git a/src/lib.rs b/src/lib.rs index f572ed0..19a1d5d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -55,8 +55,11 @@ pub(crate) struct KeyCell { } impl KeyCell { - pub(crate) fn with_key(key: Key) -> Self { - Self { key, offset: 0 } + pub(crate) fn with_key(key: &Key) -> Self { + Self { + key: *key, + offset: 0, + } } pub(crate) fn from_bytes(bytes: &[u8]) -> io::Result { From ab77bba5dfe9800482dae9724e2c95a212ee2e13 Mon Sep 17 00:00:00 2001 From: Davis Raymond Muro Date: Sun, 5 Jul 2026 19:57:51 -0700 Subject: [PATCH 09/31] btree: add test for tree leaf split --- src/btree/cursor.rs | 76 ++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 75 insertions(+), 1 deletion(-) diff --git a/src/btree/cursor.rs b/src/btree/cursor.rs index 87a7cb4..9694465 100644 --- a/src/btree/cursor.rs +++ b/src/btree/cursor.rs @@ -361,7 +361,7 @@ impl Cursor { mod test { use tempfile::TempDir; - use crate::{AccessContext, btree::Tree}; + use crate::{AccessContext, KEYCELL_SIZE, KeyCell, ValueCell, btree::Tree}; fn temp_tree() -> (TempDir, Tree) { let dir = TempDir::new().expect("can create tempdir"); @@ -371,6 +371,65 @@ mod test { (dir, tree) } + fn filled_leaf_root() -> (TempDir, Tree) { + let (dir, tree) = temp_tree(); + + let root = tree + .inner + .root() + .expect("can retrieve tree root"); + assert!(root != 0, "root should be a valid page ID"); + tree.inner + .pager + .mut_page( + root, + AccessContext::maintenance("test leaf split"), + |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(); @@ -463,4 +522,19 @@ mod test { assert_eq!(actual.key, record_2.0); assert_eq!(actual.value.as_ref(), record_2.1.as_bytes()); } + + #[test] + fn cursor_insert_splits_tree_if_needed() { + let (_dir, tree) = filled_leaf_root(); + let record: (u32, &str) = (10, "abc"); + + tree.cursor() + .expect("can init cursor") + .insert( + AccessContext::maintenance("cursor insert test"), + &record.0, + record.1.as_bytes().to_vec(), + ) + .expect("can insert into filled leaf"); + } } From 905d34a59f820714799c4cb41e41fbf769416342 Mon Sep 17 00:00:00 2001 From: Davis Raymond Muro Date: Mon, 6 Jul 2026 08:25:51 -0700 Subject: [PATCH 10/31] doc: fix cargo doc warnings --- src/btree/cursor.rs | 2 +- src/page.rs | 2 +- src/pager.rs | 3 --- src/wal.rs | 14 +++++++------- 4 files changed, 9 insertions(+), 12 deletions(-) diff --git a/src/btree/cursor.rs b/src/btree/cursor.rs index 9694465..e026e04 100644 --- a/src/btree/cursor.rs +++ b/src/btree/cursor.rs @@ -29,7 +29,7 @@ impl From<(usize, ValueCell)> for CursorValue { } impl Cursor { - /// Initialize a [`Cursor`] at `root` position of [`Tree`] + /// Initialize a [`Cursor`] at `root` position of [`super::Tree`] pub fn from_root(tree: &Arc) -> io::Result { Ok(Self { breadcrumbs: vec![], diff --git a/src/page.rs b/src/page.rs index 1f869d2..6fb80fc 100644 --- a/src/page.rs +++ b/src/page.rs @@ -162,7 +162,7 @@ impl Page { /// /// ## Note /// - /// Suggest utilizing a Vec, Box<[u8]> or a [u8; N] to avoid + /// 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 diff --git a/src/pager.rs b/src/pager.rs index dac6dbd..b5a7353 100644 --- a/src/pager.rs +++ b/src/pager.rs @@ -331,9 +331,6 @@ 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 { capacity: usize, inner: sync::Mutex, diff --git a/src/wal.rs b/src/wal.rs index c68b15f..4d8374e 100644 --- a/src/wal.rs +++ b/src/wal.rs @@ -259,13 +259,13 @@ impl From<(Lsn, Record)> for RecordEntry { /// /// 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 +/// `[`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. From 8975c92e8f681b1d8e5f03b49d48ce3866873029 Mon Sep 17 00:00:00 2001 From: Davis Raymond Muro Date: Mon, 6 Jul 2026 08:32:53 -0700 Subject: [PATCH 11/31] docs: add Github CI badge --- README.md | 1 + 1 file changed, 1 insertion(+) 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. From f55ded60f2b635d0354c29132b7e59517e184962 Mon Sep 17 00:00:00 2001 From: Davis Raymond Muro Date: Mon, 6 Jul 2026 09:10:21 -0700 Subject: [PATCH 12/31] storage: consolidate page handling modules into `storage` --- src/btree/cursor.rs | 2 +- src/btree/mod.rs | 5 ++- src/lib.rs | 6 ++-- src/main.rs | 2 +- src/storage/constants.rs | 73 ++++++++++++++++++++++++++++++++++++++ src/storage/mod.rs | 6 ++++ src/{ => storage}/page.rs | 61 ++----------------------------- src/{ => storage}/pager.rs | 17 ++++----- src/wal.rs | 3 +- 9 files changed, 98 insertions(+), 77 deletions(-) create mode 100644 src/storage/constants.rs create mode 100644 src/storage/mod.rs rename src/{ => storage}/page.rs (77%) rename src/{ => storage}/pager.rs (99%) diff --git a/src/btree/cursor.rs b/src/btree/cursor.rs index e026e04..a56458d 100644 --- a/src/btree/cursor.rs +++ b/src/btree/cursor.rs @@ -4,7 +4,7 @@ use log::trace; use crate::{ AccessContext, KEYCELL_SIZE, Key, KeyCell, Page, PageFlags, ValueCell, - btree::TreeInner, page::HEADER_SIZE, + btree::TreeInner, storage::constants::page::HEADER_SIZE, }; pub struct Cursor { diff --git a/src/btree/mod.rs b/src/btree/mod.rs index fe4a6f8..5ae89b1 100644 --- a/src/btree/mod.rs +++ b/src/btree/mod.rs @@ -1,10 +1,13 @@ +//! TODO: Move into storage module pub mod cursor; use std::{io, path::PathBuf, sync::Arc}; pub use cursor::Cursor; -use crate::{AccessContext, PageFlags, Pager, pager::META_PAGE_ID}; +use crate::{ + AccessContext, PageFlags, Pager, storage::constants::page::META_PAGE_ID, +}; /// [Tree] is a wrapping structure that signifies a `Blink-Tree` index-organized /// table that can be traversed by [`Cursor`]. diff --git a/src/lib.rs b/src/lib.rs index 19a1d5d..0a2b150 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,13 +3,11 @@ use std::io; use crc::Crc; pub mod btree; -pub mod page; -pub mod pager; pub mod recovery; +pub mod storage; pub mod wal; -pub use page::{Page, PageFlags}; -pub use pager::{AccessContext, Pager}; +pub use storage::{AccessContext, FlushGuard, Page, PageFlags, Pager}; pub use wal::{Logger, Lsn, Record, RecordEntry, RecordFlags, WalFlushGuard}; /// https://reveng.sourceforge.io/crc-catalogue/all.htm diff --git a/src/main.rs b/src/main.rs index 334acb9..a39a932 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,7 +1,7 @@ use std::sync::Arc; use clap::Parser; -use cryo::pager::{AccessContext, Pager}; +use cryo::{AccessContext, Pager}; use env_logger::Env; #[derive(Parser, Debug, Clone)] diff --git a/src/storage/constants.rs b/src/storage/constants.rs new file mode 100644 index 0000000..aa145ba --- /dev/null +++ b/src/storage/constants.rs @@ -0,0 +1,73 @@ +/// O_DIRECT: https://man7.org/linux/man-pages/man2/open.2.html +pub const O_DIRECT: i32 = 0o40000; + +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; + + 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/mod.rs b/src/storage/mod.rs new file mode 100644 index 0000000..d905744 --- /dev/null +++ b/src/storage/mod.rs @@ -0,0 +1,6 @@ +pub mod constants; +pub mod page; +pub mod pager; + +pub use page::{Page, PageFlags}; +pub use pager::{AccessContext, FlushGuard, Pager}; diff --git a/src/page.rs b/src/storage/page.rs similarity index 77% rename from src/page.rs rename to src/storage/page.rs index 6fb80fc..4e2332e 100644 --- a/src/page.rs +++ b/src/storage/page.rs @@ -4,64 +4,7 @@ use bitflags::bitflags; use crate::CRC32C; -pub const MAGIC_SIZE: usize = 8; -pub const MAGIC: [u8; MAGIC_SIZE] = [25, 3, 20, 26, 7, 4, 8, 0]; -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; +use super::constants::page::*; macro_rules! read_be { ($page:expr, $ty:ty, $start:expr, $end: expr) => { @@ -73,6 +16,7 @@ macro_rules! read_be { ) }; } + macro_rules! write_be { ($page:expr, $start:expr, $end: expr, $slice: expr) => { $page @@ -80,6 +24,7 @@ macro_rules! write_be { .copy_from_slice($slice.to_be_bytes().as_ref()) }; } + macro_rules! field { ($getter:ident, $setter:ident, $ty:ty, $start:expr) => { pub fn $getter(&self) -> $ty { diff --git a/src/pager.rs b/src/storage/pager.rs similarity index 99% rename from src/pager.rs rename to src/storage/pager.rs index b5a7353..f1a421c 100644 --- a/src/pager.rs +++ b/src/storage/pager.rs @@ -1,6 +1,10 @@ -//! Pager and page-cache support for on-disk pages. -//! -use crate::{Page, PageFlags, page::FORMAT_VERSION}; +use super::{ + Page, PageFlags, + constants::{ + O_DIRECT, + page::{FORMAT_VERSION, META_PAGE_ID}, + }, +}; use log::{debug, info, trace, warn}; use std::{ collections::HashMap, @@ -16,16 +20,9 @@ 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; -/// Page identifier reserved for the root page. -/// -/// Page identifiers are one-based; page id `0` is invalid. -pub const META_PAGE_ID: usize = 1; - /// Loads a [`Page`] of `size` bytes from `reader`. /// /// A [`Page`] is valid when the `MAGIC` bytes are present in its diff --git a/src/wal.rs b/src/wal.rs index 4d8374e..bdfe69d 100644 --- a/src/wal.rs +++ b/src/wal.rs @@ -10,8 +10,7 @@ use std::{ use log::{info, trace, warn}; -use crate::pager::FlushGuard; -use crate::read_be; +use crate::{FlushGuard, read_be}; const MAGIC: &str = "PD"; const MAGIC_SIZE: usize = MAGIC.len(); From fd4b7cee56710bf6acfae19eeb5d0b1fa6a973e8 Mon Sep 17 00:00:00 2001 From: Davis Raymond Muro Date: Mon, 6 Jul 2026 10:18:40 -0700 Subject: [PATCH 13/31] page: provide more structured views into `Page` This splits out the table and meta page handling from the `Page`, leaving it as simple as possible. This way the accessed API is actually controlled and you don't have to worry about the layout or accessing something that doesn't exist for that page. --- src/storage/page.rs | 523 ++++++++++++++++++++++++++++++++----------- src/storage/pager.rs | 240 ++++++++++++-------- 2 files changed, 542 insertions(+), 221 deletions(-) diff --git a/src/storage/page.rs b/src/storage/page.rs index 4e2332e..4416da7 100644 --- a/src/storage/page.rs +++ b/src/storage/page.rs @@ -6,42 +6,51 @@ use crate::CRC32C; use super::constants::page::*; -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"), - ) - }; +trait Field: Sized { + fn read(bytes: &[u8]) -> Self; + fn write(&self, out: &mut [u8]); } -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! 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); -macro_rules! field { - ($getter:ident, $setter:ident, $ty:ty, $start:expr) => { - pub fn $getter(&self) -> $ty { - read_be!(self, $ty, $start, $start + size_of::<$ty>()) - } +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; + } +} - pub fn $setter(&mut self, value: $ty) { - write_be!(self, $start, $start + size_of::<$ty>(), value) - } - }; +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 -/// -/// ### Common /// ```text /// [0..8] bytes[8] magic /// [8..12] u32 checksum @@ -49,123 +58,191 @@ macro_rules! field { /// [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 start -/// ``` -/// -/// ### Meta Page: -/// ```text -/// [..] -/// [19..21] u16 page_size -/// [..] -/// [29..30] u8 format_version -/// [30..34] u32 tree_root -/// [34..36] u16 next_page -/// [..] -/// [64..] body start +/// [64..] BODY /// ``` -/// -/// ### Table Page -/// ``` text -/// [..] -/// [19..21] u16 number_of_keys -/// [..] -/// [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 (only valid if internal page) -/// [..] -/// [64..] body start -/// ``` -/// -/// #[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 = 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; - } + bytes: Box<[u8]>, } impl Page { /// Build a page from `bytes` /// - /// ## Note + /// ## Panics /// - /// Suggest utilizing a `Vec`, `Box<[u8]>` or a `[u8; N]` to avoid - /// any copying that might be done. + /// 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 inner: Box<[u8]> = bytes.into(); + let bytes: Box<[u8]> = bytes.into(); assert!( - inner.len() >= 512 && inner.len() <= u16::MAX as usize, - "bytes is not page size len" + Self::is_valid_size(bytes.len()), + "size is not a power of two greater than or equal to 512" ); - Self { inner } + 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 inner = vec![0; size as usize]; - let mut out = Self::build(inner); - out.reset(size, flags); + let mut out = Self::build(vec![0; size as usize]); + out.format(size, flags); out } - pub fn reset(&mut self, size: u16, flags: PageFlags) { + /// 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.bits()); + 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 flags.contains(PageFlags::IsMeta) { - self.set_page_size(size); - self.set_format_version(FORMAT_VERSION); + if self.checksum() != self.compute_checksum() { + return (false, Some("corrupted data, checksum is not valid")); } + + (true, None) } - /// Immutable view into the held [`Page`] + /// Returns an immutable slice into the page bytes from `[start...end)`. pub fn cell(&self, start: usize, end: usize) -> &[u8] { &self[start..end] } - /// Immutable view into the held [`Page`] + /// Returns an immutable slice into the page bytes from `[start...PAGE_END]`. pub fn cell_from(&self, start: usize) -> &[u8] { &self[start..] } - /// Mutable view into the held [`Page`] + /// 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] } - /// Computes the current checksum of the package - pub fn compute_checksum(&self) -> u32 { - CRC32C.checksum(&self[FLAGS_OFFSET..]) + 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) } @@ -175,63 +252,219 @@ impl Page { .copy_from_slice(&MAGIC); } - /// 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")); - } + /// 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() + } - (true, None) + /// Returns the pages `overflow_offset` if any. + pub fn overflow_offset(&self) -> Option { + self.flags() + .contains(PageFlags::HasOverflow) + .then(|| self.overflow_offset_raw()) } - field!(checksum, set_checksum, u32, CHECKSUM_OFFSET); - field!(flags, set_flags, u8, FLAGS_OFFSET); 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!(free_space, set_free_space, u16, FREESPACE_OFFSET); - field!(num_keys, set_num_keys, u16, NUM_KEY_OFFSET); - field!(page_size, set_page_size, u16, PAGE_SIZE_OFFSET); - field!(latest_lsn, set_lsn, u64, LSN_OFFSET); - field!(left_sibling, set_left_sibling, u32, LEFT_SIBLING_OFFSET); - field!(right_sibling, set_right_sibling, u32, RIGHT_SIBLING_OFFSET); - field!(high_key, set_high_key, u64, NODE_HIGH_KEY_OFFSET); field!( - right_pointer, - set_right_pointer, + /// 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, - RIGHT_MOST_POINTER_OFFSET + OVERFLOW_OFFSET_OFFSET ); - field!(overflow, set_overflow, u32, OVERFLOW_OFFSET_OFFSET); - field!( +} + +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, - set_format_version, - u8, - FORMAT_VERSION_OFFSET + 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 ); - field!(tree_root, set_tree_root, u32, BTREE_ROOT_OFFSET); - field!(next_page, set_next_page, u16, NEXT_PAGE_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); } impl ops::DerefMut for Page { fn deref_mut(&mut self) -> &mut Self::Target { - &mut self.inner + &mut self.bytes } } @@ -239,7 +472,7 @@ impl ops::Deref for Page { type Target = [u8]; fn deref(&self) -> &Self::Target { - &self.inner + &self.bytes } } @@ -247,11 +480,11 @@ 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(), + "free_space(start={}, end={}, size={}), latest_lsn={}", self.free_space_start(), self.free_space_end(), self.free_space(), + self.latest_lsn(), ) } } @@ -262,6 +495,34 @@ impl fmt::Debug for Page { } } +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 + } +} + #[cfg(test)] mod test { use super::*; @@ -271,9 +532,9 @@ mod test { let mut page = Page::build(vec![0; 4096]); page.set_free_space(4096); - page.set_flags(64); - assert_eq!(page.flags(), 64); + page.set_flags(PageFlags::empty()); + assert_eq!(page.flags(), PageFlags::empty()); assert_eq!(page.free_space(), 4096); - assert_ne!(page.inner[..], vec![0; 4096][..]) + assert_ne!(page.bytes[..], vec![0; 4096][..]) } } diff --git a/src/storage/pager.rs b/src/storage/pager.rs index f1a421c..253cfc5 100644 --- a/src/storage/pager.rs +++ b/src/storage/pager.rs @@ -1,3 +1,5 @@ +use crate::storage::page::{AnyPage, AnyPageMut}; + use super::{ Page, PageFlags, constants::{ @@ -408,30 +410,32 @@ impl Pager { META_PAGE_ID, AccessContext::maintenance("startup"), |p| -> io::Result { - if !PageFlags::from_bits(p.flags()) - .expect("is valid flag bits") - .contains(PageFlags::IsMeta) - { - return Err(io::Error::new( - io::ErrorKind::InvalidData, - "unable to locate database meta page", - )); - } - - if p.format_version() != FORMAT_VERSION { - return Err(io::Error::new( - io::ErrorKind::InvalidData, - "unsupported format version", - )); + match p { + AnyPage::Meta(p) => { + if p.format_version() != FORMAT_VERSION { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "unsupported format version", + )); + } + Ok(p.page_size()) + } + _ => { + Err(io::Error::new( + io::ErrorKind::InvalidData, + "unable to locate database meta page", + )) + } } - - Ok(p.page_size()) }, )??; } else { - let mut meta = Page::new(out.page_size, PageFlags::IsMeta); + 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); - out.flush(META_PAGE_ID, &mut meta)?; + out.flush(META_PAGE_ID, meta)?; } Ok(out) } @@ -443,13 +447,16 @@ impl Pager { ctx: AccessContext, flags: PageFlags, ) -> io::Result { - let page_id = self.mut_page(META_PAGE_ID, ctx, |p| { - let page_id = p.next_page(); - p.set_next_page(page_id + 1); - if let Some(lsn) = ctx.lsn { - p.set_lsn(lsn); + 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); + if let Some(lsn) = ctx.lsn { + p.set_lsn(lsn); + } + page_id } - page_id + _ => panic!("meta page id returned a non meta page"), })? as usize; let page_size = self.page_size as usize; @@ -486,7 +493,7 @@ impl Pager { &self, page_id: usize, ctx: AccessContext, - f: impl FnOnce(&Page) -> R, + f: impl FnOnce(AnyPage<'_>) -> R, ) -> io::Result { trace!( "page {page_id} access start: mode={:?} txn={:?} lsn={:?} reason={:?}", @@ -518,7 +525,7 @@ impl Pager { "failed to acquire read lock on page", ) })?; - f(&page) + f(page.as_variant()) }; handle.remove(&page)?; page.unpin(); @@ -539,7 +546,7 @@ impl Pager { &self, page_id: usize, ctx: AccessContext, - f: impl FnOnce(&mut Page) -> R, + f: impl FnOnce(AnyPageMut) -> R, ) -> io::Result { if ctx.txn_id.is_none() && ctx.reason.is_none() { warn!( @@ -577,7 +584,7 @@ impl Pager { "failed to acquire write lock on page", ) })?; - let out = f(&mut page); + let out = f(page.as_variant_mut()); cached .dirty .store(true, Ordering::Release); @@ -848,8 +855,7 @@ impl Pager { ) })?; write_page(page_id, self.page_size as usize, &mut inner, page)?; - let flags = - PageFlags::from_bits(page.flags()).expect("flags is parseable"); + let flags = page.flags(); info!( "page flushed: page_id={page_id} page_lsn={page_lsn} meta={} leaf={} internal={}", flags.contains(PageFlags::IsMeta), @@ -1102,10 +1108,13 @@ mod tests { .mut_page( page_id, AccessContext::maintenance("test setup"), - |page| { - let start = page.free_space_start() as usize; - page.set_num_keys(num_keys); - page.mut_cell(start, start + 1)[0] = marker; + |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"); @@ -1152,14 +1161,17 @@ mod tests { [(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( - page.free_space_start() as usize, - page.free_space_start() as usize + 1, + .page(id, AccessContext::anonymous(), |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"); @@ -1225,12 +1237,15 @@ mod tests { .mut_page( ids[0], AccessContext::maintenance("test mutation"), - |page| { - page.set_num_keys(42); - page.mut_cell( - page.free_space_start() as usize, - page.free_space_start() as usize + 1, - )[0] = b'z'; + |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"); @@ -1257,6 +1272,9 @@ mod tests { let persisted = 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( @@ -1275,12 +1293,15 @@ mod tests { .mut_page( ids[0], AccessContext::maintenance("test mutation"), - |page| { - page.set_num_keys(7); - page.mut_cell( - page.free_space_start() as usize, - page.free_space_start() as usize + 1, - )[0] = b'x'; + |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"); @@ -1296,6 +1317,9 @@ mod tests { let persisted = 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( @@ -1338,12 +1362,15 @@ mod tests { .mut_page( ids[0], AccessContext::maintenance("test mutation"), - |page| { - page.set_num_keys(99); - page.mut_cell( - page.free_space_start() as usize, - page.free_space_start() as usize + 1, - )[0] = b'q'; + |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"); @@ -1364,6 +1391,9 @@ mod tests { let persisted = 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( @@ -1382,12 +1412,15 @@ mod tests { .mut_page( ids[0], AccessContext::maintenance("test mutation"), - |page| { - page.set_num_keys(11); - page.mut_cell( - page.free_space_start() as usize, - page.free_space_start() as usize + 1, - )[0] = b'x'; + |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"); @@ -1395,12 +1428,15 @@ mod tests { .mut_page( ids[1], AccessContext::maintenance("test mutation"), - |page| { - page.set_num_keys(22); - page.mut_cell( - page.free_space_start() as usize, - page.free_space_start() as usize + 1, - )[0] = b'y'; + |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"); @@ -1427,6 +1463,9 @@ mod tests { 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( @@ -1438,6 +1477,9 @@ mod tests { 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( @@ -1477,12 +1519,15 @@ mod tests { .mut_page( ids[0], AccessContext::maintenance("test mutation"), - |page| { - page.set_num_keys(31); - page.mut_cell( - page.free_space_start() as usize, - page.free_space_start() as usize + 1, - )[0] = b'm'; + |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"); @@ -1490,12 +1535,15 @@ mod tests { .mut_page( ids[1], AccessContext::maintenance("test mutation"), - |page| { - page.set_num_keys(32); - page.mut_cell( - page.free_space_start() as usize, - page.free_space_start() as usize + 1, - )[0] = b'n'; + |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"); @@ -1516,6 +1564,9 @@ mod tests { 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( @@ -1527,6 +1578,9 @@ mod tests { 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( @@ -1545,12 +1599,15 @@ mod tests { .mut_page( ids[0], AccessContext::maintenance("test mutation"), - |page| { - page.set_num_keys(44); - page.mut_cell( - page.free_space_start() as usize, - page.free_space_start() as usize + 1, - )[0] = b'r'; + |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"); @@ -1573,6 +1630,9 @@ mod tests { let persisted = 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( From 9b2b34c11d53b23f36ce54e80574434921226019 Mon Sep 17 00:00:00 2001 From: Davis Raymond Muro Date: Mon, 6 Jul 2026 12:57:03 -0700 Subject: [PATCH 14/31] btree: utilize `TreeInner` for easier page access --- src/btree/cursor.rs | 178 ++++++++++++++++++++++---------------------- src/btree/mod.rs | 95 ++++++++++++++++++----- 2 files changed, 166 insertions(+), 107 deletions(-) diff --git a/src/btree/cursor.rs b/src/btree/cursor.rs index a56458d..582d4d7 100644 --- a/src/btree/cursor.rs +++ b/src/btree/cursor.rs @@ -3,8 +3,9 @@ use std::{io, sync::Arc}; use log::trace; use crate::{ - AccessContext, KEYCELL_SIZE, Key, KeyCell, Page, PageFlags, ValueCell, - btree::TreeInner, storage::constants::page::HEADER_SIZE, + AccessContext, KEYCELL_SIZE, Key, KeyCell, PageFlags, ValueCell, + btree::TreeInner, + storage::{constants::page::HEADER_SIZE, page::PageView}, }; pub struct Cursor { @@ -30,7 +31,7 @@ impl From<(usize, ValueCell)> for CursorValue { impl Cursor { /// Initialize a [`Cursor`] at `root` position of [`super::Tree`] - pub fn from_root(tree: &Arc) -> io::Result { + pub(crate) fn from_root(tree: &Arc) -> io::Result { Ok(Self { breadcrumbs: vec![], current_page: tree.root()?, @@ -110,10 +111,9 @@ impl Cursor { cell: &ValueCell, ) -> io::Result<()> { self.tree - .pager - .mut_page(self.current_page, ctx, |page| { + .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 keys = self.key_range(0, key_count, &page)?; let new_count = key_count + 1; let free_space = page.free_space() as usize; @@ -181,10 +181,9 @@ impl Cursor { updated: &ValueCell, ) -> io::Result<()> { self.tree - .pager - .mut_page(self.current_page, ctx, |page| { + .mut_table_page(ctx, self.current_page, |mut page| { let mut key = self - .key_range(old.idx, old.idx + 1, page)? + .key_range(old.idx, old.idx + 1, &page)? .pop() .expect("should have old key"); @@ -236,15 +235,8 @@ impl Cursor { key: &Key, ) -> io::Result> { self.tree - .pager - .page(self.current_page, ctx, |page| { - let Some(flags) = PageFlags::from_bits(page.flags()) else { - return Err(io::Error::new( - io::ErrorKind::InvalidData, - format!("corrupted page {} flags", self.current_page), - )); - }; - + .table_page(ctx, self.current_page, |page| { + let flags = page.flags(); if !flags.contains(PageFlags::IsLeaf) { return Err(io::Error::new( io::ErrorKind::Unsupported, @@ -256,7 +248,7 @@ impl Cursor { } let count = page.num_keys() as usize; - let keys = self.key_range(0, count, page)?; + let keys = self.key_range(0, count, &page)?; match keys.binary_search(&KeyCell::with_key(key)) { Ok(pos) => { @@ -277,43 +269,40 @@ impl Cursor { /// /// This function will return `true` once a `Leaf` page is located. fn find(&mut self, ctx: AccessContext, key: &Key) -> io::Result { - let next_location = - self.tree - .pager - .page(self.current_page, ctx, |page| { - let Some(flags) = PageFlags::from_bits(page.flags()) else { - return Err(io::Error::new( - io::ErrorKind::InvalidData, - format!( - "corrupted page {} flags", - self.current_page - ), - )); - }; - - if flags.contains(PageFlags::IsLeaf) { - return Ok(None); - } + let next_location = self.tree.table_page( + ctx, + self.current_page, + |page| -> io::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(), - } - } else { - keys[pos - 1].clone() + 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( + io::Error::new( + io::ErrorKind::InvalidData, + "key position unknown", + ), + )?, } + } else { + keys[pos - 1].clone() } - }; - Ok(Some(next_page)) - })??; + } + }; + Ok(Some(next_page)) + }, + )??; if let Some(page) = next_location { self.breadcrumbs @@ -335,7 +324,7 @@ impl Cursor { &self, start_index: usize, end_index: usize, - page: &Page, + page: &impl PageView, ) -> io::Result> { let start_offset = HEADER_SIZE + (start_index * KEYCELL_SIZE); let end_offset = HEADER_SIZE + (end_index * KEYCELL_SIZE); @@ -361,7 +350,10 @@ impl Cursor { mod test { use tempfile::TempDir; - use crate::{AccessContext, KEYCELL_SIZE, KeyCell, ValueCell, btree::Tree}; + use crate::{ + AccessContext, KEYCELL_SIZE, KeyCell, ValueCell, btree::Tree, + storage::page::AnyPageMut, + }; fn temp_tree() -> (TempDir, Tree) { let dir = TempDir::new().expect("can create tempdir"); @@ -384,45 +376,51 @@ mod test { .mut_page( root, AccessContext::maintenance("test leaf split"), - |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) + |p| match p { + AnyPageMut::Table(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; - } + initial_start += KEYCELL_SIZE; + } - p.set_free_space_end(initial_start as u16); + p.set_free_space_end(initial_start as u16); + } + _ => panic!("expected a table page"), }, ) .expect("can mutate page"); diff --git a/src/btree/mod.rs b/src/btree/mod.rs index 5ae89b1..dc7bd9f 100644 --- a/src/btree/mod.rs +++ b/src/btree/mod.rs @@ -6,7 +6,11 @@ use std::{io, path::PathBuf, sync::Arc}; pub use cursor::Cursor; use crate::{ - AccessContext, PageFlags, Pager, storage::constants::page::META_PAGE_ID, + AccessContext, Page, PageFlags, Pager, + storage::{ + constants::page::META_PAGE_ID, + page::{AnyPage, AnyPageMut, MetaPage, TablePage}, + }, }; /// [Tree] is a wrapping structure that signifies a `Blink-Tree` index-organized @@ -16,26 +20,24 @@ pub struct Tree { } /// Provides standardized access for [`Cursor`] to navigate the [`Tree`] -pub struct TreeInner { +pub(crate) struct TreeInner { pager: Pager, } impl TreeInner { /// Returns the root of the [`Tree`] pub fn root(&self) -> io::Result { - Ok(self.pager.page( - META_PAGE_ID, - AccessContext::maintenance("btree locate root"), - |p| p.tree_root(), - )? as usize) + self.meta_page( + AccessContext::maintenance("tree locate root page id"), + |p| p.tree_root() as usize, + ) } /// Set current tree root to `root` pub fn set_root(&self, ctx: AccessContext, root: u32) -> io::Result<()> { - self.pager - .mut_page(META_PAGE_ID, ctx, |page| { - page.set_tree_root(root); - }) + 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. @@ -55,6 +57,70 @@ impl TreeInner { } } +impl TreeInner { + pub fn meta_page( + &self, + ctx: AccessContext, + f: impl FnOnce(MetaPage<&Page>) -> R, + ) -> io::Result { + self.pager + .page(META_PAGE_ID, ctx, |p| match p { + AnyPage::Meta(p) => Ok(f(p)), + _ => Err(io::Error::new( + io::ErrorKind::InvalidData, + "expected metadata page, got table instead", + )), + })? + } + + pub fn mut_meta_page( + &self, + ctx: AccessContext, + f: impl FnOnce(MetaPage<&mut Page>) -> R, + ) -> io::Result { + self.pager + .mut_page(META_PAGE_ID, ctx, |p| match p { + AnyPageMut::Meta(p) => Ok(f(p)), + _ => Err(io::Error::new( + io::ErrorKind::InvalidData, + "expected metadata page, got table instead", + )), + })? + } + + pub fn table_page( + &self, + ctx: AccessContext, + page_id: usize, + f: impl FnOnce(TablePage<&Page>) -> R, + ) -> io::Result { + self.pager + .page(page_id, ctx, |p| match p { + AnyPage::Table(p) => Ok(f(p)), + _ => Err(io::Error::new( + io::ErrorKind::InvalidData, + "expected table page, got metadata instead", + )), + })? + } + + pub fn mut_table_page( + &self, + ctx: AccessContext, + page_id: usize, + f: impl FnOnce(TablePage<&mut Page>) -> R, + ) -> io::Result { + self.pager + .mut_page(page_id, ctx, |p| match p { + AnyPageMut::Table(p) => Ok(f(p)), + _ => Err(io::Error::new( + io::ErrorKind::InvalidData, + "expected table page, got metadata instead", + )), + })? + } +} + impl Tree { /// Load/Create a [`Tree`] at the given `path`. Initiating table meta page /// and tree root if not initiated. @@ -68,12 +134,7 @@ impl Tree { }), }; - let root = tree.inner.pager.page( - META_PAGE_ID, - AccessContext::maintenance("load tree from disk"), - |p| p.tree_root(), - )?; - + let root = tree.inner.root()?; if root == 0 { tree.inner.create_root( AccessContext::maintenance("initialize tree"), From 0780e123d458336c1d35d9d8c60f0c0982e69ae6 Mon Sep 17 00:00:00 2001 From: Davis Raymond Muro Date: Mon, 6 Jul 2026 13:49:41 -0700 Subject: [PATCH 15/31] lib: fix an issue where value len would be parsed as u32 --- src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib.rs b/src/lib.rs index 0a2b150..67da928 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -144,7 +144,7 @@ impl ValueCell { )); } - let len = u32::from_be_bytes( + let len = u16::from_be_bytes( bytes[VALUECELL_VALUE_LEN_OFFSET..VALUECELL_HEADER_SIZE] .try_into() .expect("is len sized bytes"), From f9a5b0132717a06a2b980da24e3737ba2204fe30 Mon Sep 17 00:00:00 2001 From: Davis Raymond Muro Date: Mon, 6 Jul 2026 13:50:34 -0700 Subject: [PATCH 16/31] cursor: add reclaim(basic), remove logic --- src/btree/cursor.rs | 259 ++++++++++++++++++++++++++++--------------- src/storage/pager.rs | 10 +- 2 files changed, 176 insertions(+), 93 deletions(-) diff --git a/src/btree/cursor.rs b/src/btree/cursor.rs index 582d4d7..50875a2 100644 --- a/src/btree/cursor.rs +++ b/src/btree/cursor.rs @@ -1,11 +1,15 @@ use std::{io, sync::Arc}; -use log::trace; +use log::{trace, warn}; use crate::{ - AccessContext, KEYCELL_SIZE, Key, KeyCell, PageFlags, ValueCell, + AccessContext, KEYCELL_SIZE, Key, KeyCell, Page, PageFlags, + VALUECELL_KEY_SIZE, VALUECELL_VALUE_LEN_SIZE, ValueCell, btree::TreeInner, - storage::{constants::page::HEADER_SIZE, page::PageView}, + storage::{ + constants::page::HEADER_SIZE, + page::{PageView, TablePage}, + }, }; pub struct Cursor { @@ -104,6 +108,55 @@ impl Cursor { } 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(&mut self, ctx: AccessContext, key: &Key) -> io::Result { + let next_location = self.tree.table_page( + ctx, + self.current_page, + |page| -> io::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( + io::Error::new( + io::ErrorKind::InvalidData, + "key position unknown", + ), + )?, + } + } else { + keys[pos - 1].clone() + } + } + }; + Ok(Some(next_page)) + }, + )??; + + 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, @@ -180,47 +233,32 @@ impl Cursor { old: &CursorValue, updated: &ValueCell, ) -> io::Result<()> { - self.tree - .mut_table_page(ctx, self.current_page, |mut page| { - let mut key = self + let inserted = self.tree + .mut_table_page(ctx, self.current_page, |mut page| -> io::Result { + let key = self .key_range(old.idx, old.idx + 1, &page)? .pop() .expect("should have old key"); - 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; - - let mut new_space = free_space + old.val_cell.len(); - let new_space_end = free_space_end - updated.len(); - - if updated.len() > new_space { - return Err(io::Error::new( - io::ErrorKind::StorageFull, - "cursor insert to page would overflow", - )); - } - - if new_space_end < free_space_start { - return Err(io::Error::other("page is heavily fragmented")); + 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) } + })??; - key.offset = new_space_end as u32; - let key_bytes: [u8; KEYCELL_SIZE] = (&key).into(); - let value_bytes: Box<[u8]> = updated.into(); - let key_offset = HEADER_SIZE + (old.idx * KEYCELL_SIZE); - new_space -= value_bytes.len(); - - page.mut_cell(new_space_end, free_space_end) - .copy_from_slice(&value_bytes); - page.mut_cell(key_offset, key_offset + KEYCELL_SIZE) - .copy_from_slice(&key_bytes); - - page.set_free_space_end(new_space_end as u16); - page.set_free_space(new_space as u16); - - Ok(()) - })? + if !inserted { + self.insert_new(ctx, updated)?; + } + Ok(()) } /// Attempts to read [`CursorValue`] of associated `key` in page. @@ -264,55 +302,6 @@ 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(&mut self, ctx: AccessContext, key: &Key) -> io::Result { - let next_location = self.tree.table_page( - ctx, - self.current_page, - |page| -> io::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( - io::Error::new( - io::ErrorKind::InvalidData, - "key position unknown", - ), - )?, - } - } else { - keys[pos - 1].clone() - } - } - }; - Ok(Some(next_page)) - }, - )??; - - 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) - } - /// Retrieves list of [`KeyCell`] starting from logical `start_index` up till /// `end_index`. This function returns [R_start..R_end). /// @@ -344,6 +333,102 @@ impl Cursor { 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, + ) -> io::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(); + + 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, + ) -> io::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(); + + 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(initial_space - KEYCELL_SIZE as u16); + + Ok(()) + } + Err(_) => Err(io::Error::new( + io::ErrorKind::InvalidInput, + "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 free_space_end = page.free_space_end() as usize; + let value_bytes: Box<[u8]> = updated.into(); + let mut offset = key.offset as usize; + + if offset == free_space_end && diff > 0 { + offset += diff; + page.set_free_space_end(offset as u16); + } + + page.mut_cell(offset, offset + value_bytes.len()) + .clone_from_slice(&value_bytes); + page.set_free_space(new_space as u16); + } } #[cfg(test)] diff --git a/src/storage/pager.rs b/src/storage/pager.rs index 253cfc5..02b2c4d 100644 --- a/src/storage/pager.rs +++ b/src/storage/pager.rs @@ -420,12 +420,10 @@ impl Pager { } Ok(p.page_size()) } - _ => { - Err(io::Error::new( - io::ErrorKind::InvalidData, - "unable to locate database meta page", - )) - } + _ => Err(io::Error::new( + io::ErrorKind::InvalidData, + "unable to locate database meta page", + )), } }, )??; From 30dd3a3594148af165b5181d69343caf051a607f Mon Sep 17 00:00:00 2001 From: Davis Raymond Muro Date: Mon, 6 Jul 2026 14:14:29 -0700 Subject: [PATCH 17/31] cursor: protect against infinite recursion --- src/btree/cursor.rs | 113 +++++++++++++++++++++++++++++++++----------- 1 file changed, 85 insertions(+), 28 deletions(-) diff --git a/src/btree/cursor.rs b/src/btree/cursor.rs index 50875a2..fd728a7 100644 --- a/src/btree/cursor.rs +++ b/src/btree/cursor.rs @@ -1,6 +1,9 @@ use std::{io, sync::Arc}; -use log::{trace, warn}; +use log::{debug, trace, warn}; + +/// Maximum hops allowed +const MAX_HOPS: usize = 64; use crate::{ AccessContext, KEYCELL_SIZE, Key, KeyCell, Page, PageFlags, @@ -52,14 +55,10 @@ impl Cursor { key: &Key, value: Vec, ) -> io::Result> { - while !self.find(ctx, key)? { - trace!( - "tree cursor search: key={key} current_page={} depth={}", - self.current_page, - self.breadcrumbs.len() - ); - } - + self.find( + AccessContext::maintenance("tree cursor locate insert location"), + key, + )?; let new_cell = ValueCell { key: *key, value: value.into(), @@ -80,19 +79,13 @@ impl Cursor { /// 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) -> io::Result> { - while !self.find( - AccessContext::maintenance("tree cursor search locate leaf node"), + self.find( + AccessContext::maintenance("tree cursor locate key position"), key, - )? { - trace!( - "tree cursor search: key={key} current_page={} depth={}", - self.current_page, - self.breadcrumbs.len() - ); - } + )?; let out = self.read_value( - AccessContext::maintenance("tree cursor search read value"), + AccessContext::maintenance("tree cursor read key value"), key, )?; trace!( @@ -105,6 +98,28 @@ impl Cursor { 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) -> io::Result<()> { + for attempt in 0..MAX_HOPS { + if attempt >= MAX_HOPS { + return Err(io::Error::other("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 { @@ -112,7 +127,11 @@ impl Cursor { /// current page and pushing breadcrumb. /// /// This function will return `true` once a `Leaf` page is located. - fn find(&mut self, ctx: AccessContext, key: &Key) -> io::Result { + fn find_inner( + &mut self, + ctx: AccessContext, + key: &Key, + ) -> io::Result { let next_location = self.tree.table_page( ctx, self.current_page, @@ -148,6 +167,11 @@ impl Cursor { }, )??; + trace!( + "cursor find next positon: key={} next={:?}", + key, next_location + ); + if let Some(page) = next_location { self.breadcrumbs .push((self.current_page, page.clone())); @@ -193,6 +217,15 @@ impl Cursor { 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); @@ -287,6 +320,7 @@ impl Cursor { 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) => { @@ -295,6 +329,10 @@ impl Cursor { 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), @@ -327,6 +365,11 @@ impl Cursor { .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?); } @@ -356,6 +399,11 @@ impl Cursor { 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); @@ -383,12 +431,20 @@ impl Cursor { .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(initial_space - KEYCELL_SIZE as u16); + page.set_free_space(new_space); Ok(()) } @@ -416,14 +472,15 @@ impl Cursor { ) { let diff = old.len() - updated.len(); let new_space = page.free_space() as usize + diff; - let free_space_end = page.free_space_end() as usize; let value_bytes: Box<[u8]> = updated.into(); - let mut offset = key.offset as usize; - - if offset == free_space_end && diff > 0 { - offset += diff; - page.set_free_space_end(offset as u16); - } + 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); From 20a924e6add5380d463380b1666cc8fdceafc442 Mon Sep 17 00:00:00 2001 From: Davis Raymond Muro Date: Mon, 6 Jul 2026 14:21:35 -0700 Subject: [PATCH 18/31] storage: move btree struct under storage module --- src/lib.rs | 1 - src/{btree/mod.rs => storage/btree.rs} | 17 ++--- src/{btree => storage}/cursor.rs | 98 ++++++++++++-------------- src/storage/mod.rs | 6 +- 4 files changed, 55 insertions(+), 67 deletions(-) rename src/{btree/mod.rs => storage/btree.rs} (93%) rename src/{btree => storage}/cursor.rs (90%) diff --git a/src/lib.rs b/src/lib.rs index 67da928..a8bf957 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,7 +2,6 @@ use std::io; use crc::Crc; -pub mod btree; pub mod recovery; pub mod storage; pub mod wal; diff --git a/src/btree/mod.rs b/src/storage/btree.rs similarity index 93% rename from src/btree/mod.rs rename to src/storage/btree.rs index dc7bd9f..037cb73 100644 --- a/src/btree/mod.rs +++ b/src/storage/btree.rs @@ -1,22 +1,15 @@ -//! TODO: Move into storage module -pub mod cursor; - use std::{io, path::PathBuf, sync::Arc}; -pub use cursor::Cursor; - -use crate::{ - AccessContext, Page, PageFlags, Pager, - storage::{ - constants::page::META_PAGE_ID, - page::{AnyPage, AnyPageMut, MetaPage, TablePage}, - }, +use super::{ + AccessContext, Cursor, MetaPage, Page, PageFlags, Pager, TablePage, + constants::page::META_PAGE_ID, + page::{AnyPage, AnyPageMut}, }; /// [Tree] is a wrapping structure that signifies a `Blink-Tree` index-organized /// table that can be traversed by [`Cursor`]. pub struct Tree { - inner: Arc, + pub(crate) inner: Arc, } /// Provides standardized access for [`Cursor`] to navigate the [`Tree`] diff --git a/src/btree/cursor.rs b/src/storage/cursor.rs similarity index 90% rename from src/btree/cursor.rs rename to src/storage/cursor.rs index fd728a7..8d0b534 100644 --- a/src/btree/cursor.rs +++ b/src/storage/cursor.rs @@ -5,14 +5,14 @@ use log::{debug, trace, warn}; /// Maximum hops allowed const MAX_HOPS: usize = 64; +use super::{ + AccessContext, Page, PageFlags, PageView, TablePage, btree::TreeInner, + constants::page::HEADER_SIZE, +}; + use crate::{ - AccessContext, KEYCELL_SIZE, Key, KeyCell, Page, PageFlags, - VALUECELL_KEY_SIZE, VALUECELL_VALUE_LEN_SIZE, ValueCell, - btree::TreeInner, - storage::{ - constants::page::HEADER_SIZE, - page::{PageView, TablePage}, - }, + KEYCELL_SIZE, Key, KeyCell, VALUECELL_KEY_SIZE, VALUECELL_VALUE_LEN_SIZE, + ValueCell, }; pub struct Cursor { @@ -493,8 +493,7 @@ mod test { use tempfile::TempDir; use crate::{ - AccessContext, KEYCELL_SIZE, KeyCell, ValueCell, btree::Tree, - storage::page::AnyPageMut, + AccessContext, KEYCELL_SIZE, KeyCell, ValueCell, storage::Tree, }; fn temp_tree() -> (TempDir, Tree) { @@ -514,55 +513,48 @@ mod test { .expect("can retrieve tree root"); assert!(root != 0, "root should be a valid page ID"); tree.inner - .pager - .mut_page( - root, + .mut_table_page( AccessContext::maintenance("test leaf split"), - |p| match p { - AnyPageMut::Table(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, - ) + 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); + initial_start += KEYCELL_SIZE; } - _ => panic!("expected a table page"), + + p.set_free_space_end(initial_start as u16); }, ) .expect("can mutate page"); diff --git a/src/storage/mod.rs b/src/storage/mod.rs index d905744..2f9a0a3 100644 --- a/src/storage/mod.rs +++ b/src/storage/mod.rs @@ -1,6 +1,10 @@ +pub mod btree; pub mod constants; +pub mod cursor; pub mod page; pub mod pager; -pub use page::{Page, PageFlags}; +pub use btree::Tree; +pub use cursor::Cursor; +pub use page::{MetaPage, Page, PageFlags, PageView, PageViewMut, TablePage}; pub use pager::{AccessContext, FlushGuard, Pager}; From 7633639996a3c16cf0dfe041f159b3eeb36099dd Mon Sep 17 00:00:00 2001 From: Davis Raymond Muro Date: Mon, 6 Jul 2026 15:23:43 -0700 Subject: [PATCH 19/31] storage: utilize custom `Result` for storage module --- Cargo.lock | 1 + Cargo.toml | 1 + src/storage/btree.rs | 59 ++++++------ src/storage/cursor.rs | 75 +++++++--------- src/storage/error.rs | 42 +++++++++ src/storage/mod.rs | 2 + src/storage/pager.rs | 203 +++++++++++++++--------------------------- src/wal.rs | 7 +- 8 files changed, 180 insertions(+), 210 deletions(-) create mode 100644 src/storage/error.rs diff --git a/Cargo.lock b/Cargo.lock index 8bb8a88..073dcdf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -182,6 +182,7 @@ dependencies = [ "postcard", "serde", "tempfile", + "thiserror", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 6a14a59..53a1f49 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,7 @@ env_logger = "0.11.11" 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/src/storage/btree.rs b/src/storage/btree.rs index 037cb73..5e50e09 100644 --- a/src/storage/btree.rs +++ b/src/storage/btree.rs @@ -1,8 +1,10 @@ -use std::{io, path::PathBuf, sync::Arc}; +use std::{path::PathBuf, sync::Arc}; use super::{ - AccessContext, Cursor, MetaPage, Page, PageFlags, Pager, TablePage, + AccessContext, Cursor, MetaPage, Page, PageFlags, Pager, StorageError, + TablePage, constants::page::META_PAGE_ID, + error::Result, page::{AnyPage, AnyPageMut}, }; @@ -19,7 +21,7 @@ pub(crate) struct TreeInner { impl TreeInner { /// Returns the root of the [`Tree`] - pub fn root(&self) -> io::Result { + pub fn root(&self) -> Result { self.meta_page( AccessContext::maintenance("tree locate root page id"), |p| p.tree_root() as usize, @@ -27,7 +29,7 @@ impl TreeInner { } /// Set current tree root to `root` - pub fn set_root(&self, ctx: AccessContext, root: u32) -> io::Result<()> { + pub fn set_root(&self, ctx: AccessContext, root: u32) -> Result<()> { self.mut_meta_page(ctx, |mut p| { p.set_tree_root(root); }) @@ -41,7 +43,7 @@ impl TreeInner { &self, ctx: AccessContext, flags: PageFlags, - ) -> io::Result<()> { + ) -> Result<()> { let new_root = self .pager .allocate_page(ctx, flags | PageFlags::IsRoot)?; @@ -55,14 +57,14 @@ impl TreeInner { &self, ctx: AccessContext, f: impl FnOnce(MetaPage<&Page>) -> R, - ) -> io::Result { + ) -> Result { self.pager .page(META_PAGE_ID, ctx, |p| match p { AnyPage::Meta(p) => Ok(f(p)), - _ => Err(io::Error::new( - io::ErrorKind::InvalidData, - "expected metadata page, got table instead", - )), + _ => Err(StorageError::CorruptedData(format!( + "expected metadata page at ID {}", + META_PAGE_ID + ))), })? } @@ -70,14 +72,14 @@ impl TreeInner { &self, ctx: AccessContext, f: impl FnOnce(MetaPage<&mut Page>) -> R, - ) -> io::Result { + ) -> Result { self.pager .mut_page(META_PAGE_ID, ctx, |p| match p { AnyPageMut::Meta(p) => Ok(f(p)), - _ => Err(io::Error::new( - io::ErrorKind::InvalidData, - "expected metadata page, got table instead", - )), + _ => Err(StorageError::CorruptedData(format!( + "expected metadata page at ID {}", + META_PAGE_ID + ))), })? } @@ -86,14 +88,14 @@ impl TreeInner { ctx: AccessContext, page_id: usize, f: impl FnOnce(TablePage<&Page>) -> R, - ) -> io::Result { + ) -> Result { self.pager .page(page_id, ctx, |p| match p { AnyPage::Table(p) => Ok(f(p)), - _ => Err(io::Error::new( - io::ErrorKind::InvalidData, - "expected table page, got metadata instead", - )), + _ => Err(StorageError::CorruptedData(format!( + "expected table page at ID {}", + META_PAGE_ID + ))), })? } @@ -102,14 +104,14 @@ impl TreeInner { ctx: AccessContext, page_id: usize, f: impl FnOnce(TablePage<&mut Page>) -> R, - ) -> io::Result { + ) -> Result { self.pager .mut_page(page_id, ctx, |p| match p { AnyPageMut::Table(p) => Ok(f(p)), - _ => Err(io::Error::new( - io::ErrorKind::InvalidData, - "expected table page, got metadata instead", - )), + _ => Err(StorageError::CorruptedData(format!( + "expected table page at ID {}", + META_PAGE_ID + ))), })? } } @@ -117,10 +119,7 @@ impl TreeInner { 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, - ) -> io::Result { + pub fn load(path: impl Into, cache_size: usize) -> Result { let tree = Self { inner: Arc::new(TreeInner { pager: Pager::open(path, cache_size)?, @@ -140,7 +139,7 @@ impl Tree { /// Creates a new [`Cursor`] that can be used to traverse /// the tree. - pub fn cursor(&self) -> io::Result { + pub fn cursor(&self) -> Result { Cursor::from_root(&self.inner) } } diff --git a/src/storage/cursor.rs b/src/storage/cursor.rs index 8d0b534..da7dc36 100644 --- a/src/storage/cursor.rs +++ b/src/storage/cursor.rs @@ -1,4 +1,4 @@ -use std::{io, sync::Arc}; +use std::sync::Arc; use log::{debug, trace, warn}; @@ -6,8 +6,8 @@ use log::{debug, trace, warn}; const MAX_HOPS: usize = 64; use super::{ - AccessContext, Page, PageFlags, PageView, TablePage, btree::TreeInner, - constants::page::HEADER_SIZE, + AccessContext, Page, PageFlags, PageView, StorageError, TablePage, + btree::TreeInner, constants::page::HEADER_SIZE, error::Result, }; use crate::{ @@ -38,7 +38,7 @@ impl From<(usize, ValueCell)> for CursorValue { impl Cursor { /// Initialize a [`Cursor`] at `root` position of [`super::Tree`] - pub(crate) fn from_root(tree: &Arc) -> io::Result { + pub(crate) fn from_root(tree: &Arc) -> Result { Ok(Self { breadcrumbs: vec![], current_page: tree.root()?, @@ -54,7 +54,7 @@ impl Cursor { ctx: AccessContext, key: &Key, value: Vec, - ) -> io::Result> { + ) -> Result> { self.find( AccessContext::maintenance("tree cursor locate insert location"), key, @@ -78,7 +78,7 @@ impl Cursor { /// 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) -> io::Result> { + pub fn search(&mut self, key: &Key) -> Result> { self.find( AccessContext::maintenance("tree cursor locate key position"), key, @@ -101,10 +101,12 @@ impl Cursor { /// 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) -> io::Result<()> { + pub fn find(&mut self, ctx: AccessContext, key: &Key) -> Result<()> { for attempt in 0..MAX_HOPS { if attempt >= MAX_HOPS { - return Err(io::Error::other("cursor reached maximum hops")); + return Err(StorageError::RecursionDetected( + "cursor reached maximum hops", + )); } trace!( @@ -127,15 +129,11 @@ impl Cursor { /// 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, - ) -> io::Result { + fn find_inner(&mut self, ctx: AccessContext, key: &Key) -> Result { let next_location = self.tree.table_page( ctx, self.current_page, - |page| -> io::Result> { + |page| -> Result> { let flags = page.flags(); if flags.contains(PageFlags::IsLeaf) { return Ok(None); @@ -152,11 +150,10 @@ impl Cursor { KeyCell { key: page.high_key() as u32, offset: page.right_pointer().ok_or( - io::Error::new( - io::ErrorKind::InvalidData, - "key position unknown", - ), - )?, + StorageError::CorruptedData( + format!( + "unable to locate pointer for high key {} in page {}", page.high_key(), self.current_page)) + )? } } else { keys[pos - 1].clone() @@ -182,11 +179,7 @@ impl Cursor { } /// Inserts new value cell in the page cursor is currently in - fn insert_new( - &self, - ctx: AccessContext, - cell: &ValueCell, - ) -> io::Result<()> { + fn insert_new(&self, ctx: AccessContext, cell: &ValueCell) -> Result<()> { self.tree .mut_table_page(ctx, self.current_page, |mut page| { let key_count = page.num_keys() as usize; @@ -198,10 +191,7 @@ impl Cursor { let free_space_end = page.free_space_end() as usize; if cell.len() > free_space { - return Err(io::Error::new( - io::ErrorKind::StorageFull, - "cursor insert to page would overflow", - )); + return Err(StorageError::WouldSplit); } let new_space_end = free_space_end - cell.len(); @@ -209,7 +199,7 @@ impl Cursor { let new_space = free_space - cell.len(); if new_space_end < free_space_start { - return Err(io::Error::other("page is heavily fragmented")); + return Err(StorageError::FragmentedPage); } let key = KeyCell { @@ -252,7 +242,7 @@ impl Cursor { Ok(()) } - Ok(_) => Err(io::Error::other( + Ok(_) => Err(StorageError::NotAllowed( "called insert new on existing key", )), } @@ -265,9 +255,9 @@ impl Cursor { ctx: AccessContext, old: &CursorValue, updated: &ValueCell, - ) -> io::Result<()> { + ) -> Result<()> { let inserted = self.tree - .mut_table_page(ctx, self.current_page, |mut page| -> io::Result { + .mut_table_page(ctx, self.current_page, |mut page| -> Result { let key = self .key_range(old.idx, old.idx + 1, &page)? .pop() @@ -304,17 +294,13 @@ impl Cursor { &self, ctx: AccessContext, key: &Key, - ) -> io::Result> { + ) -> Result> { self.tree .table_page(ctx, self.current_page, |page| { let flags = page.flags(); if !flags.contains(PageFlags::IsLeaf) { - return Err(io::Error::new( - io::ErrorKind::Unsupported, - format!( - "attempt to read non-leaf page {}", - self.current_page - ), + return Err(StorageError::NotAllowed( + "attempt to read value from non-leaf page", )); } @@ -352,7 +338,7 @@ impl Cursor { start_index: usize, end_index: usize, page: &impl PageView, - ) -> io::Result> { + ) -> Result> { let start_offset = HEADER_SIZE + (start_index * KEYCELL_SIZE); let end_offset = HEADER_SIZE + (end_index * KEYCELL_SIZE); @@ -363,7 +349,7 @@ impl Cursor { let mut keys = bytes .chunks(KEYCELL_SIZE) .map(KeyCell::from_bytes) - .collect::>>(); + .collect::>>(); debug!( "cursor key range: start={}:{} end={}:{}", @@ -383,7 +369,7 @@ impl Cursor { &self, page: &mut TablePage<&mut Page>, key: &KeyCell, - ) -> io::Result<()> { + ) -> Result<()> { if !page .flags() .contains(PageFlags::IsLeaf) @@ -419,7 +405,7 @@ impl Cursor { &self, page: &mut TablePage<&mut Page>, key: &KeyCell, - ) -> io::Result<()> { + ) -> Result<()> { let mut keys = self.key_range(0, page.num_keys() as usize, page)?; match keys.binary_search(key) { @@ -448,8 +434,7 @@ impl Cursor { Ok(()) } - Err(_) => Err(io::Error::new( - io::ErrorKind::InvalidInput, + Err(_) => Err(StorageError::NotAllowed( "attempt to remove non-existent key", )), } 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 index 2f9a0a3..0947460 100644 --- a/src/storage/mod.rs +++ b/src/storage/mod.rs @@ -1,10 +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/pager.rs b/src/storage/pager.rs index 02b2c4d..3a798cb 100644 --- a/src/storage/pager.rs +++ b/src/storage/pager.rs @@ -1,11 +1,11 @@ -use crate::storage::page::{AnyPage, AnyPageMut}; - use super::{ - Page, PageFlags, + Page, PageFlags, StorageError, constants::{ O_DIRECT, page::{FORMAT_VERSION, META_PAGE_ID}, }, + error::Result, + page::{AnyPage, AnyPageMut}, }; use log::{debug, info, trace, warn}; use std::{ @@ -34,12 +34,11 @@ fn load_page( page_id: usize, size: usize, reader: &mut (impl Read + Seek), -) -> io::Result { +) -> 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", + return Err(StorageError::InvalidPage( + "page ID zero is not allowed".into(), )); } @@ -51,7 +50,7 @@ fn load_page( let page = Page::build(buf); if let (_, Some(reason)) = page.valid() { - return Err(io::Error::new(io::ErrorKind::InvalidData, reason)); + return Err(StorageError::InvalidPage(reason.into())); } Ok(page) @@ -68,12 +67,11 @@ fn write_page( size: usize, writer: &mut File, page: &mut Page, -) -> io::Result<()> { +) -> 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", + return Err(StorageError::InvalidPage( + "page ID zero is not allowed".into(), )); } @@ -177,7 +175,7 @@ pub struct PageHandle { impl PageHandle { /// Adds this handle to a cached page's diagnostic handle list. - pub fn add(&self, page: &CachedPage) -> io::Result<()> { + pub fn add(&self, page: &CachedPage) -> Result<()> { page.handles .lock() .map_err(|_e| { @@ -191,7 +189,7 @@ impl PageHandle { } /// Removes this handle from a cached page's diagnostic handle list. - pub fn remove(&self, page: &CachedPage) -> io::Result<()> { + pub fn remove(&self, page: &CachedPage) -> Result<()> { let mut handles = page .handles .lock() @@ -384,7 +382,7 @@ impl Pager { /// 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) -> io::Result { + pub fn open(path: impl Into, capacity: usize) -> Result { let inner = OpenOptions::new() .read(true) .write(true) @@ -409,22 +407,16 @@ impl Pager { out.page_size = out.page( META_PAGE_ID, AccessContext::maintenance("startup"), - |p| -> io::Result { - match p { - AnyPage::Meta(p) => { - if p.format_version() != FORMAT_VERSION { - return Err(io::Error::new( - io::ErrorKind::InvalidData, - "unsupported format version", - )); - } - Ok(p.page_size()) + |p| match p { + AnyPage::Meta(p) => { + if p.format_version() != FORMAT_VERSION { + return Err(StorageError::FormatVersion); } - _ => Err(io::Error::new( - io::ErrorKind::InvalidData, - "unable to locate database meta page", - )), + Ok(p.page_size()) } + _ => Err(StorageError::CorruptedData( + "unable to locate database meta page".into(), + )), }, )??; } else { @@ -444,7 +436,7 @@ impl Pager { &self, ctx: AccessContext, flags: PageFlags, - ) -> io::Result { + ) -> Result { let page_id = self.mut_page(META_PAGE_ID, ctx, |p| match p { AnyPageMut::Meta(mut p) => { let page_id = p.next_page(); @@ -452,10 +444,13 @@ impl Pager { if let Some(lsn) = ctx.lsn { p.set_lsn(lsn); } - page_id + + Ok(page_id as usize) } - _ => panic!("meta page id returned a non meta page"), - })? 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; @@ -492,7 +487,7 @@ impl Pager { page_id: usize, ctx: AccessContext, f: impl FnOnce(AnyPage<'_>) -> R, - ) -> io::Result { + ) -> Result { trace!( "page {page_id} access start: mode={:?} txn={:?} lsn={:?} reason={:?}", AccessMode::Read, @@ -545,7 +540,7 @@ impl Pager { page_id: usize, ctx: AccessContext, f: impl FnOnce(AnyPageMut) -> R, - ) -> io::Result { + ) -> Result { if ctx.txn_id.is_none() && ctx.reason.is_none() { warn!( "mutating page {page_id} without transaction or maintenance context." @@ -672,7 +667,7 @@ impl Pager { /// /// 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() @@ -717,7 +712,7 @@ impl Pager { /// - [`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); @@ -734,32 +729,24 @@ impl Pager { &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 @@ -773,31 +760,20 @@ impl Pager { 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)?; @@ -836,7 +812,7 @@ impl Pager { } /// 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}"); @@ -846,12 +822,7 @@ impl Pager { let mut inner = self .inner .lock() - .map_err(|_e| { - io::Error::new( - io::ErrorKind::PermissionDenied, - "failed to acquire lock on pager state", - ) - })?; + .map_err(|_| StorageError::LockPoisoned)?; write_page(page_id, self.page_size as usize, &mut inner, page)?; let flags = page.flags(); info!( @@ -867,16 +838,11 @@ impl Pager { /// /// 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() { @@ -895,12 +861,7 @@ impl Pager { 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 @@ -921,12 +882,7 @@ impl Pager { 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}"); @@ -949,27 +905,17 @@ impl Pager { id: usize, page: Page, dirty: bool, - ) -> io::Result> { + ) -> Result> { trace!("page start 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 @@ -990,19 +936,16 @@ impl Pager { /// 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); @@ -1026,17 +969,13 @@ impl Pager { // 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() { @@ -1060,10 +999,7 @@ impl Pager { } debug!("page evict fail: all pages are currently pinned"); - Err(io::Error::new( - io::ErrorKind::WouldBlock, - "all cached pages are pinned", - )) + Err(StorageError::AllInUse) } } @@ -1134,7 +1070,7 @@ mod tests { (dir, pager, ids) } - fn persisted_page(pager: &Pager, page_id: usize) -> io::Result { + fn persisted_page(pager: &Pager, page_id: usize) -> Result { let mut inner = pager .inner .lock() @@ -1187,7 +1123,7 @@ mod tests { } #[test] - fn accessing_page_not_in_backing_store_returns_unexpected_eof() { + 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; @@ -1195,19 +1131,22 @@ mod tests { .page(missing, AccessContext::anonymous(), |_| ()) .expect_err("missing page should not load"); - assert_eq!(err.kind(), io::ErrorKind::UnexpectedEof); + assert!(matches!(err, StorageError::Io(..))); assert!(pager.info().is_empty()); } #[test] - fn flushing_untracked_page_returns_other() { + fn flushing_untracked_page_returns_cache_miss() { let (_dir, pager, ids) = pager_with_pages([(1, b'a')]); let err = pager .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] @@ -1253,7 +1192,7 @@ mod tests { .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); @@ -1347,7 +1286,7 @@ mod tests { .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); } @@ -1384,7 +1323,7 @@ mod tests { .flush_page(ids[0], false) .expect_err("failing guard should block flush"); - assert_eq!(err.kind(), io::ErrorKind::Other); + assert!(matches!(err, StorageError::Io(..))); assert!(pager.info()[0].dirty); let persisted = @@ -1613,7 +1552,7 @@ mod tests { 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); @@ -1649,6 +1588,6 @@ mod tests { .page(0, AccessContext::anonymous(), |_| ()) .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 bdfe69d..e3b3d77 100644 --- a/src/wal.rs +++ b/src/wal.rs @@ -10,7 +10,7 @@ use std::{ use log::{info, trace, warn}; -use crate::{FlushGuard, read_be}; +use crate::{FlushGuard, read_be, storage}; const MAGIC: &str = "PD"; const MAGIC_SIZE: usize = MAGIC.len(); @@ -917,10 +917,11 @@ impl FlushGuard for WalFlushGuard { &self, _page_id: u64, page: &crate::Page, - ) -> io::Result<()> { + ) -> storage::Result<()> { let lsn = Lsn::from(page.latest_lsn()); self.wal.flush_through(lsn)?; - self.wal.sync_all() + self.wal.sync_all()?; + Ok(()) } } From 1427eae2c542e5e904782690c65033a4959f2ab9 Mon Sep 17 00:00:00 2001 From: Davis Raymond Muro Date: Mon, 6 Jul 2026 15:28:04 -0700 Subject: [PATCH 20/31] cursor: ensure `WouldSplit` error is returned on filled insert --- src/storage/cursor.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/storage/cursor.rs b/src/storage/cursor.rs index da7dc36..23dfb2c 100644 --- a/src/storage/cursor.rs +++ b/src/storage/cursor.rs @@ -641,7 +641,8 @@ mod test { } #[test] - fn cursor_insert_splits_tree_if_needed() { + #[should_panic(expected = "WouldSplit")] + fn cursor_insert_on_split_errors() { let (_dir, tree) = filled_leaf_root(); let record: (u32, &str) = (10, "abc"); @@ -652,6 +653,6 @@ mod test { &record.0, record.1.as_bytes().to_vec(), ) - .expect("can insert into filled leaf"); + .unwrap(); } } From aa722363def1aed17509a37f93509f1ec6ed7a6e Mon Sep 17 00:00:00 2001 From: Davis Raymond Muro Date: Mon, 6 Jul 2026 15:35:32 -0700 Subject: [PATCH 21/31] storage: use libc for `O_DIRECT` and `O_SYNC` --- Cargo.lock | 1 + Cargo.toml | 1 + src/storage/constants.rs | 3 --- src/storage/pager.rs | 8 +++----- 4 files changed, 5 insertions(+), 8 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 073dcdf..5a8900c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -178,6 +178,7 @@ dependencies = [ "clap", "crc", "env_logger", + "libc", "log", "postcard", "serde", diff --git a/Cargo.toml b/Cargo.toml index 53a1f49..e2d0012 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,6 +8,7 @@ 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"] } diff --git a/src/storage/constants.rs b/src/storage/constants.rs index aa145ba..9c491d7 100644 --- a/src/storage/constants.rs +++ b/src/storage/constants.rs @@ -1,6 +1,3 @@ -/// O_DIRECT: https://man7.org/linux/man-pages/man2/open.2.html -pub const O_DIRECT: i32 = 0o40000; - pub mod page { //! Constant values utilized in page layout. //! diff --git a/src/storage/pager.rs b/src/storage/pager.rs index 3a798cb..6998740 100644 --- a/src/storage/pager.rs +++ b/src/storage/pager.rs @@ -1,9 +1,6 @@ use super::{ Page, PageFlags, StorageError, - constants::{ - O_DIRECT, - page::{FORMAT_VERSION, META_PAGE_ID}, - }, + constants::page::{FORMAT_VERSION, META_PAGE_ID}, error::Result, page::{AnyPage, AnyPageMut}, }; @@ -387,7 +384,8 @@ impl Pager { .read(true) .write(true) .create(true) - .custom_flags(O_DIRECT) + // 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(); From 61b6b922d9e210166b941bb1426d8aa8e2b5f086 Mon Sep 17 00:00:00 2001 From: Davis Raymond Muro Date: Mon, 6 Jul 2026 15:44:24 -0700 Subject: [PATCH 22/31] pager: ensure metadata page is validly created --- src/storage/pager.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/storage/pager.rs b/src/storage/pager.rs index 6998740..62ae6f2 100644 --- a/src/storage/pager.rs +++ b/src/storage/pager.rs @@ -423,6 +423,8 @@ impl Pager { 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)?; } Ok(out) @@ -883,7 +885,6 @@ impl Pager { .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) } @@ -904,7 +905,7 @@ impl Pager { page: Page, dirty: bool, ) -> Result> { - trace!("page start tracking: page={id}, dirty={dirty}"); + trace!("page request tracking: page={id}, dirty={dirty}"); let mut clock = self .clock @@ -922,12 +923,14 @@ impl Pager { 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) } From eaae7081da4d84809755deea9f9c14dffbde917c Mon Sep 17 00:00:00 2001 From: Davis Raymond Muro Date: Mon, 6 Jul 2026 16:33:11 -0700 Subject: [PATCH 23/31] wal: ensure page lsn is always outdated at start --- src/wal.rs | 56 ++++++++++++++++++++++++++++++++++++++---------------- 1 file changed, 40 insertions(+), 16 deletions(-) diff --git a/src/wal.rs b/src/wal.rs index e3b3d77..315851e 100644 --- a/src/wal.rs +++ b/src/wal.rs @@ -267,6 +267,9 @@ impl From<(Lsn, Record)> for RecordEntry { /// `[`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 }, @@ -397,7 +400,9 @@ impl Record { | Self::Abort { txn_id, .. } | Self::Compensation { txn_id, .. } | Self::End { txn_id, .. } => Some(*txn_id), - Self::BeginCheckpoint | Self::EndCheckpoint => None, + Self::BeginCheckpoint + | Self::EndCheckpoint + | Self::StartSentinel => None, } } @@ -417,7 +422,9 @@ impl Record { | Self::Abort { prev_lsn, .. } | Self::Compensation { prev_lsn, .. } | Self::End { prev_lsn, .. } => *prev_lsn, - Self::BeginCheckpoint | Self::EndCheckpoint => None, + Self::BeginCheckpoint + | Self::EndCheckpoint + | Self::StartSentinel => None, } } @@ -431,6 +438,7 @@ impl Record { Self::End { .. } => "end", Self::BeginCheckpoint => "begin_checkpoint", Self::EndCheckpoint => "end_checkpoint", + Self::StartSentinel => "log_start", } } @@ -583,11 +591,24 @@ impl Logger { .truncate(false) .open(generation_path(&dir, current_generation))?; - let (next_lsn, records) = + 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. @@ -604,7 +625,7 @@ impl Logger { dir, writer, current_generation, - buffer: VecDeque::new(), + buffer, next_lsn, flushed_lsn, }), @@ -986,7 +1007,8 @@ mod tests { txn_id: 1, prev_lsn: None, }; - let expected_begin_lsn = Lsn::new(0, 0); + let expected_begin_lsn = + Lsn::new(0, Record::StartSentinel.len() as u32); let expected_update_lsn = expected_begin_lsn .advanced_by(begin.len() as u32) .unwrap(); @@ -1006,7 +1028,7 @@ mod tests { .unwrap() .buffer .len(), - 2 + 3 ); logger @@ -1026,9 +1048,9 @@ mod tests { 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"); + assert_eq!(records.len(), 2); + assert_eq!(records[1].lsn, begin_lsn); + assert_eq!(records[1].record.kind(), "begin"); logger .flush_through(update_lsn) @@ -1046,11 +1068,11 @@ mod tests { 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())); + 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] @@ -1156,7 +1178,9 @@ mod tests { .expect("record can be synced"); } - let mut bytes = std::fs::read(&path).expect("wal file can be read"); + 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; @@ -1605,7 +1629,7 @@ mod tests { .iter() .map(|e| e.lsn()) .collect::>(), - vec![begin, update] + vec![Lsn::new(0, 0), begin, update] ); } } From 83fb420bb42d60a29cf83c94cf794c123ed341f8 Mon Sep 17 00:00:00 2001 From: Davis Raymond Muro Date: Mon, 6 Jul 2026 17:50:47 -0700 Subject: [PATCH 24/31] pager: minor reshuffle; rearrange file --- src/storage/pager.rs | 478 ++++++++++++++++++++++--------------------- 1 file changed, 241 insertions(+), 237 deletions(-) diff --git a/src/storage/pager.rs b/src/storage/pager.rs index 62ae6f2..a9b6ed4 100644 --- a/src/storage/pager.rs +++ b/src/storage/pager.rs @@ -22,75 +22,6 @@ use std::{ /// Default size, in bytes, used when creating a new database file. pub const DEFAULT_PAGE_SIZE: u16 = 4096; -/// 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 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 let (_, Some(reason)) = page.valid() { - return Err(StorageError::InvalidPage(reason.into())); - } - - 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(()) -} - /// [`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. @@ -100,12 +31,6 @@ pub trait FlushGuard: Send + Sync { pub struct NoopFlushGuard; -impl FlushGuard for NoopFlushGuard { - fn before_flush(&self, _page_id: u64, _page: &Page) -> io::Result<()> { - Ok(()) - } -} - /// Describes how a thread is currently accessing a cached page. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum AccessMode { @@ -156,78 +81,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) -> 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(()) - } -} - -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: @@ -284,31 +137,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, @@ -342,37 +170,6 @@ 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 latest_lsn: u64, - pub pin_count: usize, - pub handles: Vec, -} - -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 Pager { /// Opens an existing pager file or creates a new one. /// @@ -598,40 +395,6 @@ impl Pager { 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, - 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() - } - /// Attempts to flush every page currently tracked by the cache. /// /// If `evict` is `false`, each dirty page is written to the backing store and @@ -719,7 +482,107 @@ impl Pager { } 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 @@ -1004,6 +867,81 @@ impl Pager { } } +/// 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 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 let (_, Some(reason)) = page.valid() { + return Err(StorageError::InvalidPage(reason.into())); + } + + 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) -> io::Result<()> { + Ok(()) + } +} + impl fmt::Display for Pager { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { writeln!(f, "pager contents:")?; @@ -1014,6 +952,72 @@ impl fmt::Display for Pager { } } +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::*; From f19de3d749935efa030e059245f7a97142ce9dec Mon Sep 17 00:00:00 2001 From: Davis Raymond Muro Date: Tue, 7 Jul 2026 00:38:11 -0700 Subject: [PATCH 25/31] page: include a mutation scope structure The `MutationScope` is for primarily tracking what exactly changed in a page. It provides the same API as a normal page allowing it to be easily substituted in place of a real page, report/inspect the change and the re-apply. --- src/lib.rs | 2 +- src/storage/constants.rs | 3 + src/storage/page.rs | 312 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 316 insertions(+), 1 deletion(-) diff --git a/src/lib.rs b/src/lib.rs index a8bf957..bc6a1cd 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -9,7 +9,7 @@ pub mod wal; pub use storage::{AccessContext, FlushGuard, Page, PageFlags, Pager}; pub use wal::{Logger, Lsn, Record, RecordEntry, RecordFlags, WalFlushGuard}; -/// 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. diff --git a/src/storage/constants.rs b/src/storage/constants.rs index 9c491d7..d4e63b0 100644 --- a/src/storage/constants.rs +++ b/src/storage/constants.rs @@ -7,6 +7,9 @@ pub mod 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; diff --git a/src/storage/page.rs b/src/storage/page.rs index 4416da7..ae944d7 100644 --- a/src/storage/page.rs +++ b/src/storage/page.rs @@ -1,6 +1,7 @@ use std::{fmt, ops}; use bitflags::bitflags; +use log::trace; use crate::CRC32C; @@ -462,6 +463,241 @@ impl TablePage

{ 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)] +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)] +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 @@ -523,6 +759,20 @@ impl ops::DerefMut for TablePage

{ } } +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::*; @@ -537,4 +787,66 @@ mod test { 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); + } } From 27eab09f2ae11aa76a1eb81c81dce752f88b8d66 Mon Sep 17 00:00:00 2001 From: Davis Raymond Muro Date: Tue, 7 Jul 2026 02:34:53 -0700 Subject: [PATCH 26/31] pager: add change guards that audit/track changes --- src/storage/pager.rs | 71 ++++++++++++++++++++++++++++++++++++-------- 1 file changed, 59 insertions(+), 12 deletions(-) diff --git a/src/storage/pager.rs b/src/storage/pager.rs index a9b6ed4..63c1b2a 100644 --- a/src/storage/pager.rs +++ b/src/storage/pager.rs @@ -1,3 +1,8 @@ +use crate::{ + Lsn, + storage::page::{Mutation, MutationScope}, +}; + use super::{ Page, PageFlags, StorageError, constants::page::{FORMAT_VERSION, META_PAGE_ID}, @@ -26,11 +31,24 @@ pub const DEFAULT_PAGE_SIZE: u16 = 4096; /// 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; +/// [`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, + page_id: u64, + changes: Vec, + ) -> Result>; +} + +pub struct NoopChangeGuard; + /// Describes how a thread is currently accessing a cached page. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum AccessMode { @@ -158,6 +176,7 @@ pub struct Pager { inner: sync::Mutex, pub page_size: u16, flush_guard: sync::Arc, + change_guard: sync::Arc, clock: sync::Mutex, pages: sync::RwLock>>, @@ -188,6 +207,7 @@ impl Pager { let mut out = Self { capacity, + change_guard: sync::Arc::new(NoopChangeGuard), clock: sync::Mutex::new(ClockState { hand: 0, ring: vec![], @@ -271,10 +291,17 @@ impl Pager { /// 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 @@ -374,12 +401,18 @@ impl Pager { "failed to acquire write lock on page", ) })?; - let out = f(page.as_variant_mut()); + let mut scope = MutationScope::init(&page); + let out = f(scope.as_variant_mut()); + let lsn = self + .change_guard + .before_change(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 @@ -387,9 +420,11 @@ impl Pager { 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) @@ -937,11 +972,21 @@ fn write_page( } impl FlushGuard for NoopFlushGuard { - fn before_flush(&self, _page_id: u64, _page: &Page) -> io::Result<()> { + fn before_flush(&self, _page_id: u64, _page: &Page) -> Result<()> { Ok(()) } } +impl ChangeGuard for NoopChangeGuard { + fn before_change( + &self, + _page_id: u64, + _changes: Vec, + ) -> Result> { + Ok(None) + } +} + impl fmt::Display for Pager { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { writeln!(f, "pager contents:")?; @@ -1086,8 +1131,8 @@ mod tests { 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")) + fn before_flush(&self, _page_id: u64, _page: &Page) -> Result<()> { + Err(StorageError::NotAllowed("blocked by test guard")) } } @@ -1298,7 +1343,7 @@ mod tests { #[test] fn flush_guard_error_prevents_write_and_keeps_page_dirty() { let (_dir, mut pager, ids) = pager_with_pages([(1, b'a')]); - pager.set_guard(sync::Arc::new(FailingFlushGuard)); + pager.set_flush_guard(sync::Arc::new(FailingFlushGuard)); pager .mut_page( @@ -1328,7 +1373,10 @@ mod tests { .flush_page(ids[0], false) .expect_err("failing guard should block flush"); - assert!(matches!(err, StorageError::Io(..))); + assert!( + matches!(err, StorageError::NotAllowed(..)), + "actual {err:?}" + ); assert!(pager.info()[0].dirty); let persisted = @@ -1455,7 +1503,6 @@ mod tests { #[test] fn flush_all_evicts_dirty_pages_after_flush() { let (_dir, pager, ids) = pager_with_pages([(1, b'a'), (2, b'b')]); - eprintln!("pager: {pager} ids: {ids:?}"); pager .mut_page( From ef43c715df230e0ffe5018dc3762f0c7325ddbfc Mon Sep 17 00:00:00 2001 From: Davis Raymond Muro Date: Tue, 7 Jul 2026 03:44:11 -0700 Subject: [PATCH 27/31] cursor: add debug print of tree representation --- src/main.rs | 36 ++++++++---- src/storage/cursor.rs | 124 +++++++++++++++++++++++++++++++++++++++++- 2 files changed, 147 insertions(+), 13 deletions(-) diff --git a/src/main.rs b/src/main.rs index a39a932..1406a79 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,7 +1,10 @@ use std::sync::Arc; use clap::Parser; -use cryo::{AccessContext, Pager}; +use cryo::{ + AccessContext, + storage::{Tree, cursor::DebugOpt}, +}; use env_logger::Env; #[derive(Parser, Debug, Clone)] @@ -15,33 +18,42 @@ 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 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( + AccessContext::maintenance("main test"), + &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/storage/cursor.rs b/src/storage/cursor.rs index 23dfb2c..66cd957 100644 --- a/src/storage/cursor.rs +++ b/src/storage/cursor.rs @@ -1,4 +1,4 @@ -use std::sync::Arc; +use std::{collections::VecDeque, sync::Arc}; use log::{debug, trace, warn}; @@ -473,6 +473,128 @@ impl Cursor { } } +#[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<()> { + 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( + AccessContext::maintenance("debug print"), + 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; From 2af1606469eb7a6ae4d3122b3a00684a34dd5557 Mon Sep 17 00:00:00 2001 From: Davis Raymond Muro Date: Fri, 10 Jul 2026 18:29:19 -0700 Subject: [PATCH 28/31] note to self --- src/storage/cursor.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/storage/cursor.rs b/src/storage/cursor.rs index 66cd957..b8ce604 100644 --- a/src/storage/cursor.rs +++ b/src/storage/cursor.rs @@ -495,6 +495,7 @@ impl Default for DebugOpt { 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)); @@ -514,6 +515,7 @@ impl Cursor { if depth > opt.max_depth { println!(" <...pruned depth...> "); } + self.tree.table_page( AccessContext::maintenance("debug print"), page, @@ -528,6 +530,7 @@ impl Cursor { }; print!("[{}{}#{page} ", depth_marker(depth, key), root); + let key_count = p.num_keys() as usize; match p @@ -566,6 +569,7 @@ impl Cursor { k.key as usize, )) }); + let pruned = if end_index < key_count { format!( " (+{} children pruned)", From 8f9f2515ba7ae59e0ff2b61a466c61997b25e4b2 Mon Sep 17 00:00:00 2001 From: Davis Raymond Muro Date: Sun, 12 Jul 2026 14:04:59 -0700 Subject: [PATCH 29/31] wal: add doc for read_exact_or_eof --- src/wal.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/wal.rs b/src/wal.rs index 315851e..8adcea9 100644 --- a/src/wal.rs +++ b/src/wal.rs @@ -38,6 +38,12 @@ bitflags! { pub struct RecordFlags: u8 {} } +/// Attempts to read `buf` bytes or an eof +/// +/// ## Errors +/// +/// If the function partially fills `buf` an [`io::ErrorKind::UnexpectedEof`] +/// will be returned. fn read_exact_or_eof( reader: &mut impl Read, buf: &mut [u8], From 26bbda02eaa8dde228406aa0eb77a41d9b299f3e Mon Sep 17 00:00:00 2001 From: Davis Raymond Muro Date: Sun, 12 Jul 2026 15:09:40 -0700 Subject: [PATCH 30/31] recovery: split `wal.rs` into `recovery` mod --- src/lib.rs | 2 - src/recovery.rs | 1 - src/recovery/logger.rs | 1184 +++++++++++++++++++++++++++++ src/recovery/mod.rs | 33 + src/recovery/record.rs | 466 ++++++++++++ src/storage/pager.rs | 2 +- src/wal.rs | 1628 ---------------------------------------- 7 files changed, 1684 insertions(+), 1632 deletions(-) delete mode 100644 src/recovery.rs create mode 100644 src/recovery/logger.rs create mode 100644 src/recovery/mod.rs create mode 100644 src/recovery/record.rs diff --git a/src/lib.rs b/src/lib.rs index bc6a1cd..19391ba 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4,10 +4,8 @@ use crc::Crc; pub mod recovery; pub mod storage; -pub mod wal; pub use storage::{AccessContext, FlushGuard, Page, PageFlags, Pager}; -pub use wal::{Logger, Lsn, Record, RecordEntry, RecordFlags, WalFlushGuard}; /// pub(crate) const CRC32C: Crc = Crc::::new(&crc::CRC_32_ISCSI); 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..418e12d --- /dev/null +++ b/src/recovery/logger.rs @@ -0,0 +1,1184 @@ +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::{FlushGuard, storage}; + +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 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}, + }; + 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 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() + ); + } + + /// 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, + 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 + .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..0d5e449 --- /dev/null +++ b/src/recovery/record.rs @@ -0,0 +1,466 @@ +use super::read_exact_or_eof; +use bitflags::bitflags; +use serde::{Deserialize, Serialize}; +use std::io::{self, Read}; + +use crate::read_be; + +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, + 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 + | 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 { + 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(()) + } +} + +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 super::*; + + 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"); + } +} diff --git a/src/storage/pager.rs b/src/storage/pager.rs index 63c1b2a..43ee2d6 100644 --- a/src/storage/pager.rs +++ b/src/storage/pager.rs @@ -1,5 +1,5 @@ use crate::{ - Lsn, + recovery::Lsn, storage::page::{Mutation, MutationScope}, }; diff --git a/src/wal.rs b/src/wal.rs index 8adcea9..bbc62f1 100644 --- a/src/wal.rs +++ b/src/wal.rs @@ -11,1631 +11,3 @@ use std::{ use log::{info, trace, warn}; use crate::{FlushGuard, read_be, storage}; - -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 {} -} - -/// Attempts to read `buf` bytes or an eof -/// -/// ## Errors -/// -/// If the function partially fills `buf` an [`io::ErrorKind::UnexpectedEof`] -/// will be returned. -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 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, - 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 - | 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 { - 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 (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(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")) - } - - /// 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, - ) -> storage::Result<()> { - let lsn = Lsn::from(page.latest_lsn()); - self.wal.flush_through(lsn)?; - self.wal.sync_all()?; - Ok(()) - } -} - -#[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, Record::StartSentinel.len() as u32); - 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(), - 3 - ); - - 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(), 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.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(), 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 - .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") - [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.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![Lsn::new(0, 0), begin, update] - ); - } -} From 2f2181963acd01816eb2fd4fcef6f20bbd371f5f Mon Sep 17 00:00:00 2001 From: Davis Raymond Muro Date: Mon, 13 Jul 2026 10:06:29 -0700 Subject: [PATCH 31/31] wal: track changes to pages in log --- src/main.rs | 3 +- src/recovery/logger.rs | 124 ++++++++++++++++----- src/recovery/record.rs | 70 ++++++++---- src/storage/btree.rs | 30 ++--- src/storage/cursor.rs | 144 ++++++++++-------------- src/storage/page.rs | 5 +- src/storage/pager.rs | 248 ++++++++++++++++++----------------------- 7 files changed, 338 insertions(+), 286 deletions(-) diff --git a/src/main.rs b/src/main.rs index 1406a79..7527201 100644 --- a/src/main.rs +++ b/src/main.rs @@ -19,6 +19,7 @@ fn main() { let cli = Cli::parse(); 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); @@ -35,7 +36,7 @@ fn main() { tree.cursor() .unwrap() .insert( - AccessContext::maintenance("main test"), + &mut ctx, &k, "so much data" .as_bytes() diff --git a/src/recovery/logger.rs b/src/recovery/logger.rs index 418e12d..756f816 100644 --- a/src/recovery/logger.rs +++ b/src/recovery/logger.rs @@ -8,7 +8,10 @@ use std::{ use log::{info, trace, warn}; -use crate::{FlushGuard, storage}; +use crate::{ + AccessContext, FlushGuard, + storage::{self, pager::ChangeGuard}, +}; use super::{Lsn, Record, RecordEntry}; @@ -41,6 +44,44 @@ impl FlushGuard for WalFlushGuard { } } +/// 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 @@ -488,6 +529,7 @@ mod tests { use crate::{ Page, recovery::record::{MAGIC, RECORD_FORMAT_VERSION}, + storage::page::{Mutation, MutationOffset}, }; use tempfile::TempDir; @@ -503,9 +545,11 @@ mod tests { 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'], + 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, } } @@ -965,6 +1009,52 @@ mod tests { ); } + #[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] @@ -1024,9 +1114,11 @@ mod tests { let bad = Record::Update { txn_id: 1, page_id: 1, - offset: 0, - before: vec![1, 2, 3], - after: vec![9, 9], + 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, }; @@ -1055,24 +1147,6 @@ mod tests { 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(); diff --git a/src/recovery/record.rs b/src/recovery/record.rs index 0d5e449..441f231 100644 --- a/src/recovery/record.rs +++ b/src/recovery/record.rs @@ -3,7 +3,7 @@ use bitflags::bitflags; use serde::{Deserialize, Serialize}; use std::io::{self, Read}; -use crate::read_be; +use crate::{read_be, storage::page::Mutation}; pub const MAGIC: &str = "PO"; pub const MAGIC_SIZE: usize = MAGIC.len(); @@ -100,9 +100,7 @@ pub enum Record { Update { txn_id: u64, page_id: u64, - offset: u16, - before: Vec, - after: Vec, + mutations: Vec, prev_lsn: Option, }, @@ -295,21 +293,24 @@ impl Record { }; match self { - Self::Update { - offset, - before, - after, - .. - } => { - if before.len() != after.len() { - return Err(invalid( - "WAL Update before/after length mismatch", - )); + 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, + )?; } - if after.is_empty() { - return Err(invalid("WAL Update carries no bytes")); - } - Self::check_range(*offset, after.len(), page_size)?; } Self::Compensation { offset, @@ -423,15 +424,19 @@ impl From<(Lsn, Record)> for RecordEntry { #[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, - offset: 42, - before: vec![b'a', b'b', b'c'], - after: vec![b'x', b'y', b'z'], + 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, } } @@ -463,4 +468,27 @@ mod test { 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 index 5e50e09..2026407 100644 --- a/src/storage/btree.rs +++ b/src/storage/btree.rs @@ -1,5 +1,7 @@ use std::{path::PathBuf, sync::Arc}; +use log::trace; + use super::{ AccessContext, Cursor, MetaPage, Page, PageFlags, Pager, StorageError, TablePage, @@ -8,6 +10,9 @@ use super::{ 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 { @@ -22,14 +27,11 @@ pub(crate) struct TreeInner { impl TreeInner { /// Returns the root of the [`Tree`] pub fn root(&self) -> Result { - self.meta_page( - AccessContext::maintenance("tree locate root page id"), - |p| p.tree_root() as usize, - ) + self.meta_page(&TREE_CONTEXT, |p| p.tree_root() as usize) } /// Set current tree root to `root` - pub fn set_root(&self, ctx: AccessContext, root: u32) -> Result<()> { + pub fn set_root(&self, ctx: &mut AccessContext, root: u32) -> Result<()> { self.mut_meta_page(ctx, |mut p| { p.set_tree_root(root); }) @@ -41,7 +43,7 @@ impl TreeInner { /// the caller to do so. pub fn create_root( &self, - ctx: AccessContext, + ctx: &mut AccessContext, flags: PageFlags, ) -> Result<()> { let new_root = self @@ -55,7 +57,7 @@ impl TreeInner { impl TreeInner { pub fn meta_page( &self, - ctx: AccessContext, + ctx: &AccessContext, f: impl FnOnce(MetaPage<&Page>) -> R, ) -> Result { self.pager @@ -70,7 +72,7 @@ impl TreeInner { pub fn mut_meta_page( &self, - ctx: AccessContext, + ctx: &mut AccessContext, f: impl FnOnce(MetaPage<&mut Page>) -> R, ) -> Result { self.pager @@ -85,7 +87,7 @@ impl TreeInner { pub fn table_page( &self, - ctx: AccessContext, + ctx: &AccessContext, page_id: usize, f: impl FnOnce(TablePage<&Page>) -> R, ) -> Result { @@ -101,7 +103,7 @@ impl TreeInner { pub fn mut_table_page( &self, - ctx: AccessContext, + ctx: &mut AccessContext, page_id: usize, f: impl FnOnce(TablePage<&mut Page>) -> R, ) -> Result { @@ -128,10 +130,10 @@ impl Tree { let root = tree.inner.root()?; if root == 0 { - tree.inner.create_root( - AccessContext::maintenance("initialize tree"), - PageFlags::IsLeaf, - )?; + let mut ctx = AccessContext::maintenance("initialize tree"); + tree.inner + .create_root(&mut ctx, PageFlags::IsLeaf)?; + trace!("tree root initialize: context={ctx:?}"); } Ok(tree) diff --git a/src/storage/cursor.rs b/src/storage/cursor.rs index b8ce604..da3fc73 100644 --- a/src/storage/cursor.rs +++ b/src/storage/cursor.rs @@ -15,6 +15,9 @@ use crate::{ ValueCell, }; +const CURSOR_CONTEXT: AccessContext = + AccessContext::maintenance("cursor operation"); + pub struct Cursor { breadcrumbs: Vec<(usize, KeyCell)>, current_page: usize, @@ -51,14 +54,11 @@ impl Cursor { /// TODO: Decide on self-split or user-initiated... pub fn insert( &mut self, - ctx: AccessContext, + ctx: &mut AccessContext, key: &Key, value: Vec, ) -> Result> { - self.find( - AccessContext::maintenance("tree cursor locate insert location"), - key, - )?; + self.find(&CURSOR_CONTEXT, key)?; let new_cell = ValueCell { key: *key, value: value.into(), @@ -79,15 +79,9 @@ impl Cursor { /// 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( - AccessContext::maintenance("tree cursor locate key position"), - key, - )?; + self.find(&CURSOR_CONTEXT, key)?; - let out = self.read_value( - AccessContext::maintenance("tree cursor read key value"), - key, - )?; + let out = self.read_value(&CURSOR_CONTEXT, key)?; trace!( "tree cursor search complete: page={} key={} found={} depth={}", self.current_page, @@ -101,7 +95,7 @@ impl Cursor { /// 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<()> { + pub fn find(&mut self, ctx: &AccessContext, key: &Key) -> Result<()> { for attempt in 0..MAX_HOPS { if attempt >= MAX_HOPS { return Err(StorageError::RecursionDetected( @@ -115,7 +109,7 @@ impl Cursor { self.breadcrumbs.len() ); - if !self.find_inner(ctx, key)? { + if !self.find_inner(&ctx, key)? { break; } } @@ -129,7 +123,7 @@ impl Cursor { /// 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 { + fn find_inner(&mut self, ctx: &AccessContext, key: &Key) -> Result { let next_location = self.tree.table_page( ctx, self.current_page, @@ -179,7 +173,11 @@ impl Cursor { } /// Inserts new value cell in the page cursor is currently in - fn insert_new(&self, ctx: AccessContext, cell: &ValueCell) -> Result<()> { + 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; @@ -252,7 +250,7 @@ impl Cursor { /// Update `old` in the page [`Cursor`] is in with `updated`. fn insert_existing( &self, - ctx: AccessContext, + ctx: &mut AccessContext, old: &CursorValue, updated: &ValueCell, ) -> Result<()> { @@ -292,7 +290,7 @@ impl Cursor { /// [`Page`]. Utilize `Self::key_range` instead. fn read_value( &self, - ctx: AccessContext, + ctx: &AccessContext, key: &Key, ) -> Result> { self.tree @@ -517,7 +515,7 @@ impl Cursor { } self.tree.table_page( - AccessContext::maintenance("debug print"), + &CURSOR_CONTEXT, page, |p| -> Result<()> { let root = if p @@ -622,52 +620,45 @@ mod test { .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( - AccessContext::maintenance("test leaf split"), - 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; - } + .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_end(initial_start as u16); - }, - ) + 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) @@ -690,6 +681,7 @@ mod test { #[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 { @@ -697,11 +689,7 @@ mod test { .cursor() .expect("can initialize cursor"); cursor - .insert( - AccessContext::maintenance("cursor insert test"), - &r.0, - r.1.as_bytes().to_vec(), - ) + .insert(&mut ctx, &r.0, r.1.as_bytes().to_vec()) .expect("can insert record"); } @@ -723,6 +711,7 @@ mod test { #[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"); @@ -730,11 +719,7 @@ mod test { let out = tree .cursor() .expect("can init cursor") - .insert( - AccessContext::maintenance("cursor insert overwrite"), - &record_1.0, - record_1.1.as_bytes().to_vec(), - ) + .insert(&mut ctx, &record_1.0, record_1.1.as_bytes().to_vec()) .expect("can insert"); assert!(out.is_none()); @@ -742,11 +727,7 @@ mod test { let replaced = tree .cursor() .expect("can init cursor") - .insert( - AccessContext::maintenance("cursor insert overwrite"), - &record_2.0, - record_2.1.as_bytes().to_vec(), - ) + .insert(&mut ctx, &record_2.0, record_2.1.as_bytes().to_vec()) .expect("can insert"); assert!(replaced.is_some()); @@ -770,15 +751,12 @@ mod 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( - AccessContext::maintenance("cursor insert test"), - &record.0, - record.1.as_bytes().to_vec(), - ) + .insert(&mut ctx, &record.0, record.1.as_bytes().to_vec()) .unwrap(); } } diff --git a/src/storage/page.rs b/src/storage/page.rs index ae944d7..f4b7faf 100644 --- a/src/storage/page.rs +++ b/src/storage/page.rs @@ -2,6 +2,7 @@ use std::{fmt, ops}; use bitflags::bitflags; use log::trace; +use serde::{Deserialize, Serialize}; use crate::CRC32C; @@ -475,7 +476,7 @@ pub struct MutationScope { /// 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)] +#[derive(Debug, Clone, Deserialize, Serialize)] pub struct Mutation { /// Start and end offset range of the mutation pub offset: MutationOffset, @@ -485,7 +486,7 @@ pub struct Mutation { pub after: Box<[u8]>, } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Deserialize, Serialize)] pub struct MutationOffset { pub start: usize, pub end: usize, diff --git a/src/storage/pager.rs b/src/storage/pager.rs index 43ee2d6..bfa4e26 100644 --- a/src/storage/pager.rs +++ b/src/storage/pager.rs @@ -27,6 +27,8 @@ use std::{ /// Default size, in bytes, used when creating a new database file. pub const DEFAULT_PAGE_SIZE: u16 = 4096; +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. @@ -42,8 +44,9 @@ pub struct NoopFlushGuard; pub trait ChangeGuard: Send + Sync { fn before_change( &self, + ctx: &mut AccessContext, page_id: u64, - changes: Vec, + mutations: Vec, ) -> Result>; } @@ -219,10 +222,8 @@ impl Pager { }; if len >= DEFAULT_PAGE_SIZE as u64 { - out.page_size = out.page( - META_PAGE_ID, - AccessContext::maintenance("startup"), - |p| match p { + 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); @@ -232,8 +233,7 @@ impl Pager { _ => 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 { @@ -251,16 +251,13 @@ impl Pager { /// new page_id. pub fn allocate_page( &self, - ctx: AccessContext, + 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); - if let Some(lsn) = ctx.lsn { - p.set_lsn(lsn); - } Ok(page_id as usize) } @@ -309,7 +306,7 @@ impl Pager { pub fn page( &self, page_id: usize, - ctx: AccessContext, + ctx: &AccessContext, f: impl FnOnce(AnyPage<'_>) -> R, ) -> Result { trace!( @@ -362,7 +359,7 @@ impl Pager { pub fn mut_page( &self, page_id: usize, - ctx: AccessContext, + ctx: &mut AccessContext, f: impl FnOnce(AnyPageMut) -> R, ) -> Result { if ctx.txn_id.is_none() && ctx.reason.is_none() { @@ -405,7 +402,7 @@ impl Pager { let out = f(scope.as_variant_mut()); let lsn = self .change_guard - .before_change(page_id as u64, scope.diff(None))?; + .before_change(ctx, page_id as u64, scope.diff(None))?; scope.replay(&mut page); cached @@ -980,6 +977,7 @@ impl FlushGuard for NoopFlushGuard { impl ChangeGuard for NoopChangeGuard { fn before_change( &self, + _ctx: &mut AccessContext, _page_id: u64, _changes: Vec, ) -> Result> { @@ -1068,6 +1066,8 @@ mod tests { use super::*; use tempfile::TempDir; + 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"); @@ -1080,27 +1080,21 @@ mod tests { ) -> (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( - AccessContext::maintenance("pager with pages"), - PageFlags::IsLeaf, - ) + .allocate_page(&mut ctx, PageFlags::IsLeaf) .expect("test page can be allocated"); pager - .mut_page( - page_id, - AccessContext::maintenance("test setup"), - |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"), - }, - ) + .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) @@ -1145,7 +1139,7 @@ mod tests { [(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| match page { + .page(id, &TEST_CONTEXT, |page| match page { AnyPage::Meta(_) => { panic!("values should not be in meta page") } @@ -1178,7 +1172,7 @@ mod tests { let missing = ids[0] + 1; let err = pager - .page(missing, AccessContext::anonymous(), |_| ()) + .page(missing, &TEST_CONTEXT, |_| ()) .expect_err("missing page should not load"); assert!(matches!(err, StorageError::Io(..))); @@ -1204,7 +1198,7 @@ mod tests { let (_dir, pager, ids) = pager_with_pages([(1, b'a')]); pager - .page(ids[0], AccessContext::anonymous(), |_| ()) + .page(ids[0], &TEST_CONTEXT, |_| ()) .expect("page can be loaded into cache"); assert_eq!(pager.info().len(), 1); @@ -1219,22 +1213,19 @@ mod tests { #[test] fn dirty_page_gets_second_chance_before_flush() { let (_dir, pager, ids) = pager_with_pages([(1, b'a')]); + let mut ctx = AccessContext::maintenance("test mutation"); pager - .mut_page( - ids[0], - AccessContext::maintenance("test mutation"), - |page| match page { - AnyPageMut::Table(mut page) => { - let start = page.free_space_start() as usize; - let end = start + 1; + .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"), - }, - ) + 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 @@ -1275,22 +1266,19 @@ mod tests { #[test] fn dirty_page_can_be_flushed_and_evicted() { let (_dir, pager, ids) = pager_with_pages([(1, b'a')]); + let mut ctx = AccessContext::maintenance("test mutation"); pager - .mut_page( - ids[0], - AccessContext::maintenance("test mutation"), - |page| match page { - AnyPageMut::Table(mut page) => { - let start = page.free_space_start() as usize; - let end = start + 1; + .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"), - }, - ) + 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 @@ -1343,23 +1331,20 @@ mod tests { #[test] fn flush_guard_error_prevents_write_and_keeps_page_dirty() { 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( - ids[0], - AccessContext::maintenance("test mutation"), - |page| match page { - AnyPageMut::Table(mut page) => { - let start = page.free_space_start() as usize; - let end = start + 1; + .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"), - }, - ) + 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 @@ -1397,38 +1382,31 @@ mod tests { #[test] fn flush_all_flushes_dirty_pages_and_keeps_them_cached() { let (_dir, pager, ids) = pager_with_pages([(1, b'a'), (2, b'b')]); + let mut ctx = AccessContext::maintenance("test mutation"); pager - .mut_page( - ids[0], - AccessContext::maintenance("test mutation"), - |page| match page { - AnyPageMut::Table(mut page) => { - let start = page.free_space_start() as usize; - let end = start + 1; + .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"), - }, - ) + 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( - ids[1], - AccessContext::maintenance("test mutation"), - |page| match page { - AnyPageMut::Table(mut page) => { - let start = page.free_space_start() as usize; - let end = start + 1; + .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"), - }, - ) + 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 &ids { @@ -1485,10 +1463,10 @@ mod tests { let (_dir, pager, ids) = pager_with_pages([(1, b'a'), (2, b'b')]); pager - .page(ids[0], AccessContext::anonymous(), |_| ()) + .page(ids[0], &TEST_CONTEXT, |_| ()) .expect("page 1 can be loaded"); pager - .page(ids[1], AccessContext::anonymous(), |_| ()) + .page(ids[1], &TEST_CONTEXT, |_| ()) .expect("page 2 can be loaded"); assert_eq!(pager.info().len(), 2); @@ -1503,38 +1481,31 @@ mod tests { #[test] fn flush_all_evicts_dirty_pages_after_flush() { let (_dir, pager, ids) = pager_with_pages([(1, b'a'), (2, b'b')]); + let mut ctx = AccessContext::maintenance("test mutation"); pager - .mut_page( - ids[0], - AccessContext::maintenance("test mutation"), - |page| match page { - AnyPageMut::Table(mut page) => { - let start = page.free_space_start() as usize; - let end = start + 1; + .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"), - }, - ) + 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( - ids[1], - AccessContext::maintenance("test mutation"), - |page| match page { - AnyPageMut::Table(mut page) => { - let start = page.free_space_start() as usize; - let end = start + 1; + .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"), - }, - ) + 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 &ids { @@ -1583,22 +1554,19 @@ mod tests { #[test] fn flush_all_returns_resource_busy_for_recently_accessed_dirty_page() { let (_dir, pager, ids) = pager_with_pages([(1, b'a')]); + let mut ctx = AccessContext::maintenance("test mutation"); pager - .mut_page( - ids[0], - AccessContext::maintenance("test mutation"), - |page| match page { - AnyPageMut::Table(mut page) => { - let start = page.free_space_start() as usize; - let end = start + 1; + .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"), - }, - ) + 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 @@ -1637,7 +1605,7 @@ mod tests { 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!(matches!(err, StorageError::InvalidPage(..)));