Skip to main content

mentedb_storage/
page.rs

1//! Page Manager: file-backed page storage with free list management.
2//!
3//! Pages are 64KB fixed-size blocks used as the fundamental I/O unit.
4//! The page file layout:
5//! - Page 0: file header (magic, version, page count, free list head)
6//! - Page 1..N: data pages
7
8use std::fs::{File, OpenOptions};
9use std::io::{Read, Seek, SeekFrom, Write};
10use std::path::Path;
11
12use mentedb_core::error::{MenteError, MenteResult};
13use tracing::{debug, info, trace};
14
15/// Page size: 64KB to accommodate large embedding vectors and long content.
16pub const PAGE_SIZE: usize = 64 * 1024;
17
18/// Magic number identifying a MenteDB page file ("MENTEDB1").
19const MAGIC: u64 = 0x4D454E_5445444231;
20
21/// File format version.
22const VERSION: u32 = 1;
23
24/// A page identifier.
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
26pub struct PageId(pub u64);
27
28/// Page type classification.
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30#[repr(u8)]
31pub enum PageType {
32    Free = 0,
33    Data = 1,
34    Index = 2,
35    Overflow = 3,
36}
37
38impl From<u8> for PageType {
39    fn from(v: u8) -> Self {
40        match v {
41            1 => PageType::Data,
42            2 => PageType::Index,
43            3 => PageType::Overflow,
44            _ => PageType::Free,
45        }
46    }
47}
48
49/// Fixed-layout page header stored at the start of every page.
50#[repr(C)]
51#[derive(Debug, Clone, Copy)]
52pub struct PageHeader {
53    /// Which page this is.
54    pub page_id: u64,
55    /// Log Sequence Number: links to the WAL.
56    pub lsn: u64,
57    /// CRC-32 checksum of the page content.
58    pub checksum: u32,
59    /// Remaining free bytes in the data portion.
60    pub free_space: u16,
61    /// Number of occupied slots.
62    pub num_slots: u16,
63    /// Page type tag.
64    pub page_type: u8,
65    /// Padding for stable layout.
66    pub _padding: [u8; 7],
67}
68
69/// Size of the page header in bytes.
70pub const HEADER_SIZE: usize = std::mem::size_of::<PageHeader>();
71
72/// Usable data bytes per page (total page size minus header).
73pub const PAGE_DATA_SIZE: usize = PAGE_SIZE - HEADER_SIZE;
74
75/// A fixed-size, 4KB-aligned page.
76#[repr(C, align(4096))]
77pub struct Page {
78    /// The page header containing metadata (page ID, type, checksum, etc.).
79    pub header: PageHeader,
80    /// Raw page data payload.
81    pub data: [u8; PAGE_DATA_SIZE],
82}
83
84impl std::fmt::Debug for Page {
85    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
86        f.debug_struct("Page")
87            .field("header", &self.header)
88            .field("data_len", &self.data.len())
89            .finish()
90    }
91}
92
93impl Clone for Page {
94    fn clone(&self) -> Self {
95        let mut new_page = Page::zeroed();
96        new_page.header = self.header;
97        new_page.data.copy_from_slice(&self.data);
98        new_page
99    }
100}
101
102impl Page {
103    /// Create a zero-initialized page.
104    pub fn zeroed() -> Self {
105        // Safety: Page is #[repr(C)] with only primitive types; all-zeros is valid.
106        unsafe { std::mem::zeroed() }
107    }
108
109    /// View the raw bytes of this page.
110    fn as_bytes(&self) -> &[u8; PAGE_SIZE] {
111        // Safety: Page is #[repr(C)] with size == PAGE_SIZE.
112        unsafe { &*(self as *const Page as *const [u8; PAGE_SIZE]) }
113    }
114
115    /// Reconstruct a Page from a raw byte buffer.
116    fn from_bytes(bytes: &[u8; PAGE_SIZE]) -> Self {
117        // Safety: Page is #[repr(C)]; any bit pattern in data is valid.
118        unsafe { std::ptr::read(bytes.as_ptr() as *const Page) }
119    }
120
121    /// Compute CRC-32 checksum over header fields (excluding `checksum`) and data.
122    pub fn compute_checksum(&self) -> u32 {
123        let mut h = crc32fast::Hasher::new();
124        h.update(&self.header.page_id.to_le_bytes());
125        h.update(&self.header.lsn.to_le_bytes());
126        h.update(&self.header.free_space.to_le_bytes());
127        h.update(&self.header.num_slots.to_le_bytes());
128        h.update(&[self.header.page_type]);
129        h.update(&self.data);
130        h.finalize()
131    }
132}
133
134/// On-disk file header occupying the first bytes of page 0.
135#[repr(C)]
136struct FileHeader {
137    magic: u64,
138    version: u32,
139    _pad: u32,
140    page_count: u64,
141    free_list_head: u64,
142}
143
144/// Manages a file-backed page store with free-list allocation.
145pub struct PageManager {
146    file: File,
147    page_count: u64,
148    free_list_head: u64,
149    /// Pages read from disk since open (metrics and tests).
150    reads: u64,
151}
152
153impl PageManager {
154    /// Open (or create) a page file at `dir_path/pages.db`.
155    pub fn open(dir_path: &Path) -> MenteResult<Self> {
156        let file_path = dir_path.join("pages.db");
157        let exists = file_path.exists()
158            && std::fs::metadata(&file_path)
159                .map(|m| m.len() > 0)
160                .unwrap_or(false);
161
162        let mut file = OpenOptions::new()
163            .read(true)
164            .write(true)
165            .create(true)
166            .truncate(false)
167            .open(&file_path)?;
168
169        if exists {
170            let mut buf = [0u8; std::mem::size_of::<FileHeader>()];
171            file.seek(SeekFrom::Start(0))?;
172            file.read_exact(&mut buf)?;
173            let hdr: FileHeader = unsafe { std::ptr::read(buf.as_ptr() as *const FileHeader) };
174
175            if hdr.magic != MAGIC {
176                return Err(MenteError::Storage("invalid page file magic number".into()));
177            }
178            if hdr.version != VERSION {
179                return Err(MenteError::Storage(format!(
180                    "unsupported page file version: {}",
181                    hdr.version
182                )));
183            }
184
185            info!(page_count = hdr.page_count, "opened existing page file");
186            Ok(Self {
187                file,
188                page_count: hdr.page_count,
189                free_list_head: hdr.free_list_head,
190                reads: 0,
191            })
192        } else {
193            let mut pm = Self {
194                file,
195                page_count: 1,
196                free_list_head: 0,
197                reads: 0,
198            };
199            // Write header page (page 0) — reserves first PAGE_SIZE bytes.
200            let mut header_page = Page::zeroed();
201            header_page.header.page_id = 0;
202            pm.write_page_raw(PageId(0), &header_page)?;
203            pm.write_file_header()?;
204            info!("created new page file");
205            Ok(pm)
206        }
207    }
208
209    /// Re-read the file header from disk to pick up changes made by another process.
210    pub fn reload_header(&mut self) -> MenteResult<()> {
211        let mut buf = [0u8; std::mem::size_of::<FileHeader>()];
212        self.file.seek(SeekFrom::Start(0))?;
213        self.file.read_exact(&mut buf)?;
214        let hdr: FileHeader = unsafe { std::ptr::read(buf.as_ptr() as *const FileHeader) };
215        if hdr.magic != MAGIC {
216            return Err(MenteError::Storage(
217                "invalid page file magic on reload".into(),
218            ));
219        }
220        self.page_count = hdr.page_count;
221        self.free_list_head = hdr.free_list_head;
222        debug!(page_count = self.page_count, "reloaded page file header");
223        Ok(())
224    }
225
226    /// Persist the file header into the beginning of page 0.
227    fn write_file_header(&mut self) -> MenteResult<()> {
228        let hdr = FileHeader {
229            magic: MAGIC,
230            version: VERSION,
231            _pad: 0,
232            page_count: self.page_count,
233            free_list_head: self.free_list_head,
234        };
235        let bytes = unsafe {
236            std::slice::from_raw_parts(
237                &hdr as *const FileHeader as *const u8,
238                std::mem::size_of::<FileHeader>(),
239            )
240        };
241        self.file.seek(SeekFrom::Start(0))?;
242        self.file.write_all(bytes)?;
243        self.file.flush()?;
244        Ok(())
245    }
246
247    /// Allocate a new page, reusing from the free list when possible.
248    pub fn allocate_page(&mut self) -> MenteResult<PageId> {
249        if self.free_list_head != 0 {
250            let page_id = PageId(self.free_list_head);
251            let page = self.read_page(page_id)?;
252            let next_free = u64::from_le_bytes(page.data[..8].try_into().unwrap());
253            self.free_list_head = next_free;
254            self.write_file_header()?;
255            debug!(page_id = page_id.0, "allocated page from free list");
256            return Ok(page_id);
257        }
258
259        let page_id = PageId(self.page_count);
260        self.page_count += 1;
261
262        let mut page = Page::zeroed();
263        page.header.page_id = page_id.0;
264        page.header.page_type = PageType::Data as u8;
265        page.header.free_space = PAGE_DATA_SIZE as u16;
266        self.write_page_raw(page_id, &page)?;
267        self.write_file_header()?;
268
269        debug!(page_id = page_id.0, "allocated new page");
270        Ok(page_id)
271    }
272
273    /// Read a page from disk.
274    pub fn read_page(&mut self, page_id: PageId) -> MenteResult<Box<Page>> {
275        if page_id.0 >= self.page_count {
276            return Err(MenteError::Storage(format!(
277                "page {} out of range (count={})",
278                page_id.0, self.page_count
279            )));
280        }
281
282        let offset = page_id.0 * PAGE_SIZE as u64;
283        let mut buf = [0u8; PAGE_SIZE];
284        self.file.seek(SeekFrom::Start(offset))?;
285        self.file.read_exact(&mut buf)?;
286        self.reads += 1;
287
288        let page = Page::from_bytes(&buf);
289        if page.header.checksum != 0 {
290            let expected = page.compute_checksum();
291            if page.header.checksum != expected {
292                return Err(MenteError::Storage(format!(
293                    "page {} checksum mismatch (stored={:#x}, computed={:#x})",
294                    page_id.0, page.header.checksum, expected
295                )));
296            }
297        }
298
299        trace!(page_id = page_id.0, "read page from disk");
300        Ok(Box::new(page))
301    }
302
303    /// Write a page to disk.
304    pub fn write_page(&mut self, page_id: PageId, page: &Page) -> MenteResult<()> {
305        self.write_page_raw(page_id, page)
306    }
307
308    fn write_page_raw(&mut self, page_id: PageId, page: &Page) -> MenteResult<()> {
309        let offset = page_id.0 * PAGE_SIZE as u64;
310        self.file.seek(SeekFrom::Start(offset))?;
311        self.file.write_all(page.as_bytes())?;
312        trace!(page_id = page_id.0, "wrote page to disk");
313        Ok(())
314    }
315
316    /// Return a page to the free list.
317    pub fn free_page(&mut self, page_id: PageId) -> MenteResult<()> {
318        let mut page = Page::zeroed();
319        page.header.page_id = page_id.0;
320        page.header.page_type = PageType::Free as u8;
321        // Store the current free list head as a forward pointer.
322        page.data[..8].copy_from_slice(&self.free_list_head.to_le_bytes());
323
324        self.write_page(page_id, &page)?;
325        self.free_list_head = page_id.0;
326        self.write_file_header()?;
327
328        debug!(page_id = page_id.0, "freed page");
329        Ok(())
330    }
331
332    /// Rebuild the free list by scanning page types.
333    ///
334    /// Used after WAL recovery: header updates are not fsynced per write, so
335    /// the persisted free list can disagree with WAL-replayed page states.
336    /// Page types are WAL-authoritative; the chain is derived from them.
337    pub fn rebuild_free_list(&mut self) -> MenteResult<()> {
338        let mut head = 0u64;
339        let mut freed = 0usize;
340        for i in (1..self.page_count).rev() {
341            let page = self.read_page(PageId(i))?;
342            if PageType::from(page.header.page_type) == PageType::Free {
343                let mut fresh = Page::zeroed();
344                fresh.header.page_id = i;
345                fresh.header.page_type = PageType::Free as u8;
346                fresh.data[..8].copy_from_slice(&head.to_le_bytes());
347                self.write_page_raw(PageId(i), &fresh)?;
348                head = i;
349                freed += 1;
350            }
351        }
352        self.free_list_head = head;
353        self.write_file_header()?;
354        if freed > 0 {
355            debug!(freed, head, "rebuilt free list from page types");
356        }
357        Ok(())
358    }
359
360    /// Rebuild the free list from a known set of free pages instead of
361    /// scanning every page. Every listed page is read first and must be typed
362    /// Free; if any is not, nothing is written and false is returned so the
363    /// caller falls back to [`Self::rebuild_free_list`]. A Data page must never
364    /// be put on the free list, since the next allocation would overwrite it.
365    pub fn rebuild_free_list_from(
366        &mut self,
367        pages: &std::collections::BTreeSet<u64>,
368    ) -> MenteResult<bool> {
369        for &i in pages {
370            if i == 0 || i >= self.page_count {
371                return Ok(false);
372            }
373            let page = self.read_page(PageId(i))?;
374            if PageType::from(page.header.page_type) != PageType::Free {
375                return Ok(false);
376            }
377        }
378        let mut head = 0u64;
379        for &i in pages.iter().rev() {
380            let mut fresh = Page::zeroed();
381            fresh.header.page_id = i;
382            fresh.header.page_type = PageType::Free as u8;
383            fresh.data[..8].copy_from_slice(&head.to_le_bytes());
384            self.write_page_raw(PageId(i), &fresh)?;
385            head = i;
386        }
387        self.free_list_head = head;
388        self.write_file_header()?;
389        Ok(true)
390    }
391
392    /// Pages read from disk since this manager was opened.
393    pub fn reads(&self) -> u64 {
394        self.reads
395    }
396
397    /// Total number of pages (including the header page).
398    pub fn page_count(&self) -> u64 {
399        self.page_count
400    }
401
402    /// Sync page file data to disk.
403    pub fn sync(&mut self) -> MenteResult<()> {
404        self.file.sync_data()?;
405        Ok(())
406    }
407}
408
409#[cfg(test)]
410mod tests {
411    use super::*;
412
413    fn setup() -> (tempfile::TempDir, PageManager) {
414        let dir = tempfile::tempdir().unwrap();
415        let pm = PageManager::open(dir.path()).unwrap();
416        (dir, pm)
417    }
418
419    #[test]
420    fn test_allocate_and_read_write() {
421        let (_dir, mut pm) = setup();
422
423        let pid = pm.allocate_page().unwrap();
424        assert_eq!(pid.0, 1); // page 0 is file header
425
426        let mut page = Page::zeroed();
427        page.header.page_id = pid.0;
428        page.header.page_type = PageType::Data as u8;
429        page.data[0..5].copy_from_slice(b"hello");
430        pm.write_page(pid, &page).unwrap();
431
432        let loaded = pm.read_page(pid).unwrap();
433        assert_eq!(&loaded.data[0..5], b"hello");
434    }
435
436    #[test]
437    fn test_free_and_reuse() {
438        let (_dir, mut pm) = setup();
439
440        let p1 = pm.allocate_page().unwrap();
441        let p2 = pm.allocate_page().unwrap();
442        assert_eq!(p1.0, 1);
443        assert_eq!(p2.0, 2);
444
445        // Free p1, then allocate — should get p1 back.
446        pm.free_page(p1).unwrap();
447        let p3 = pm.allocate_page().unwrap();
448        assert_eq!(p3.0, p1.0);
449
450        // Next allocation should be brand new.
451        let p4 = pm.allocate_page().unwrap();
452        assert_eq!(p4.0, 3);
453    }
454
455    #[test]
456    fn test_multiple_free_reuse() {
457        let (_dir, mut pm) = setup();
458
459        let p1 = pm.allocate_page().unwrap();
460        let p2 = pm.allocate_page().unwrap();
461        let _p3 = pm.allocate_page().unwrap();
462
463        // Free in order p1, p2 — free list becomes p2 -> p1.
464        pm.free_page(p1).unwrap();
465        pm.free_page(p2).unwrap();
466
467        // Allocations should come back in LIFO order: p2, p1.
468        let a1 = pm.allocate_page().unwrap();
469        let a2 = pm.allocate_page().unwrap();
470        assert_eq!(a1.0, p2.0);
471        assert_eq!(a2.0, p1.0);
472
473        // p3 is still allocated; next new page is 4.
474        let a3 = pm.allocate_page().unwrap();
475        assert_eq!(a3.0, 4);
476    }
477
478    #[test]
479    fn test_reopen() {
480        let dir = tempfile::tempdir().unwrap();
481        let pid;
482        {
483            let mut pm = PageManager::open(dir.path()).unwrap();
484            pid = pm.allocate_page().unwrap();
485            let mut page = Page::zeroed();
486            page.header.page_id = pid.0;
487            page.data[0..4].copy_from_slice(b"test");
488            pm.write_page(pid, &page).unwrap();
489            pm.sync().unwrap();
490        }
491        {
492            let mut pm = PageManager::open(dir.path()).unwrap();
493            let page = pm.read_page(pid).unwrap();
494            assert_eq!(&page.data[0..4], b"test");
495        }
496    }
497
498    #[test]
499    fn test_out_of_range() {
500        let (_dir, mut pm) = setup();
501        assert!(pm.read_page(PageId(999)).is_err());
502    }
503
504    #[test]
505    fn test_checksum() {
506        let mut page = Page::zeroed();
507        page.header.page_id = 42;
508        page.data[0] = 0xFF;
509        let c1 = page.compute_checksum();
510        page.data[0] = 0x00;
511        let c2 = page.compute_checksum();
512        assert_ne!(c1, c2);
513    }
514
515    #[test]
516    fn test_checksum_verified_on_read() {
517        let dir = tempfile::tempdir().unwrap();
518        let pid;
519        {
520            let mut pm = PageManager::open(dir.path()).unwrap();
521            pid = pm.allocate_page().unwrap();
522            let mut page = Page::zeroed();
523            page.header.page_id = pid.0;
524            page.header.page_type = PageType::Data as u8;
525            page.data[0..5].copy_from_slice(b"valid");
526            page.header.checksum = page.compute_checksum();
527            pm.write_page(pid, &page).unwrap();
528            pm.sync().unwrap();
529        }
530        {
531            // Valid checksum — should read fine
532            let mut pm = PageManager::open(dir.path()).unwrap();
533            let page = pm.read_page(pid).unwrap();
534            assert_eq!(&page.data[0..5], b"valid");
535        }
536        {
537            // Corrupt the data on disk
538            let data_path = dir.path().join("pages.db");
539            let mut raw = std::fs::read(&data_path).unwrap();
540            let offset = pid.0 as usize * PAGE_SIZE;
541            // Flip a byte in the data section (after header)
542            raw[offset + std::mem::size_of::<PageHeader>()] ^= 0xFF;
543            std::fs::write(&data_path, &raw).unwrap();
544
545            let mut pm = PageManager::open(dir.path()).unwrap();
546            let result = pm.read_page(pid);
547            assert!(result.is_err(), "corrupted page should fail checksum");
548        }
549    }
550}