Skip to main content

reddb_engine/storage/
pager.rs

1//! Shared pager orchestration over an exclusively owned VFS file.
2//!
3//! Native and browser use the same cache, allocation fences, freelist, DWB,
4//! superblock publication and checkpoint algorithms. Opening/locking files,
5//! scheduling, WAL implementation and encryption remain host policy.
6//! The owner explicitly calls `sync` before teardown; this low-level pager
7//! does not hide I/O failures in a destructor. It is not a transaction API.
8use super::freelist::FreeList;
9use super::page::{Page, PageError, PageType, PAGE_SIZE};
10use super::page_cache::PageCache;
11use super::pager_io::{self, FIRST_ALLOCATABLE_PAGE};
12use reddb_file::VfsFile;
13pub use reddb_file::{DatabaseHeader, PhysicalFileHeader};
14use std::path::{Path, PathBuf};
15use std::sync::{Mutex, RwLock};
16
17/// Pager error types
18#[derive(Debug)]
19pub enum PagerError {
20    /// I/O error
21    Io(std::io::Error),
22    /// Page error
23    Page(PageError),
24    /// Invalid database file
25    InvalidDatabase(String),
26    /// Database is read-only
27    ReadOnly,
28    /// Page not found
29    PageNotFound(u32),
30    /// Database is locked
31    Locked,
32    /// A Mutex or RwLock was poisoned (another thread panicked while holding it)
33    LockPoisoned,
34    /// Database is encrypted but no key was supplied.
35    EncryptionRequired,
36    /// Plain (unencrypted) database opened with an encryption key.
37    PlainDatabaseRefusesKey,
38    /// Encryption key validation failed for an encrypted database.
39    InvalidKey,
40    /// Both superblock copies failed validation (ADR 0074 §2).
41    SuperblockZoneUnrecoverable(PathBuf),
42    /// The internal manifest zone failed its checksum (ADR 0074 §2).
43    ManifestZoneCorrupt(PathBuf),
44    /// The in-file double-write zone failed validation (ADR 0074 §2).
45    DwbZoneCorrupt(PathBuf),
46    /// The store still carries a retired phase-1 pager sidecar (ADR 0038 §4).
47    LegacySidecarStore { sidecar: PathBuf },
48    /// The store predates the phase-3 in-file DWB layout marker (ADR 0038 §4).
49    LegacyPagedStore { path: PathBuf },
50}
51
52/// A contiguous run of database pages reserved for vector-turbo payloads.
53#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54pub struct ExtentId {
55    pub start_page: u32,
56    pub n_pages: u32,
57}
58
59impl std::fmt::Display for PagerError {
60    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
61        match self {
62            Self::Io(e) => write!(f, "I/O error: {}", e),
63            Self::Page(e) => write!(f, "Page error: {}", e),
64            Self::InvalidDatabase(msg) => write!(f, "Invalid database: {}", msg),
65            Self::ReadOnly => write!(f, "Database is read-only"),
66            Self::PageNotFound(id) => write!(f, "Page {} not found", id),
67            Self::Locked => write!(f, "Database is locked"),
68            Self::LockPoisoned => write!(f, "Internal lock poisoned (concurrent thread panicked)"),
69            Self::EncryptionRequired => write!(
70                f,
71                "Database is encrypted but no key was supplied (set PagerConfig::encryption)"
72            ),
73            Self::PlainDatabaseRefusesKey => write!(
74                f,
75                "Plain (unencrypted) database opened with an encryption key — refusing"
76            ),
77            Self::InvalidKey => write!(f, "Encryption key validation failed for this database"),
78            Self::SuperblockZoneUnrecoverable(path) => write!(
79                f,
80                "superblock zone of {} is unrecoverable: both ping-pong copies failed \
81                 validation, so no generation can be trusted to root the store. The store \
82                 will not be opened. Recover what survives with red salvage \
83                 (ADR 0074 §4); it reads the damaged file without writing to it and \
84                 reports what it could not verify.",
85                path.display()
86            ),
87            Self::ManifestZoneCorrupt(path) => write!(
88                f,
89                "internal manifest zone of {} failed its checksum: the zone that names \
90                 collections, indexes and the checkpoint boundary cannot be trusted, so no \
91                 rows are returned rather than garbage ones. Run the `SCRUB` statement to classify the fault \
92                 and `red salvage` to extract what survives (ADR 0074 §2/§4).",
93                path.display()
94            ),
95            Self::DwbZoneCorrupt(path) => write!(
96                f,
97                "double-write zone of {} failed validation: page recovery cannot prove \
98                 whether the protected in-place write completed. The store will not be opened \
99                 silently; run the `SCRUB` statement to classify the fault and `red salvage` to extract trusted \
100                 rows (ADR 0074 §2/§4).",
101                path.display()
102            ),
103            Self::LegacySidecarStore { sidecar } => write!(
104                f,
105                "refusing to open a legacy sidecar-backed store: found {}. The superblock \
106                 pair, internal manifest, and double-write recovery state now live inside the \
107                 .rdb file (ADR 0038 §2/§4), \
108                 and the engine never reads the retired sidecars silently. Convert the store \
109                 first with the offline migration tool \
110                 (`reddb_server::pager_zone_migration::migrate_to_zoned`); it is reversible.",
111                sidecar.display()
112            ),
113            Self::LegacyPagedStore { path } => write!(
114                f,
115                "refusing to open a pre-phase-3 paged store: {} does not carry the layout marker \
116                 for the in-file double-write zone. Convert the store first with the offline \
117                 migration tool (`reddb_server::pager_zone_migration::migrate_to_zoned`); it is \
118                 reversible.",
119                path.display()
120            ),
121        }
122    }
123}
124
125impl std::error::Error for PagerError {
126    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
127        match self {
128            Self::Io(e) => Some(e),
129            Self::Page(e) => Some(e),
130            _ => None,
131        }
132    }
133}
134
135impl From<std::io::Error> for PagerError {
136    fn from(e: std::io::Error) -> Self {
137        Self::Io(e)
138    }
139}
140
141impl From<PageError> for PagerError {
142    fn from(e: PageError) -> Self {
143        Self::Page(e)
144    }
145}
146
147impl From<pager_io::PagerIoError> for PagerError {
148    fn from(error: pager_io::PagerIoError) -> Self {
149        match error {
150            pager_io::PagerIoError::Io(error) => Self::Io(error),
151            pager_io::PagerIoError::Page(error) => Self::Page(error),
152            pager_io::PagerIoError::InvalidDatabase(message) => Self::InvalidDatabase(message),
153        }
154    }
155}
156
157/// Host-independent pager configuration. File creation and ownership are host policy.
158#[derive(Debug, Clone)]
159pub struct PagerConfig {
160    pub cache_size: usize,
161    pub read_only: bool,
162    pub verify_checksums: bool,
163    pub double_write: bool,
164}
165
166impl Default for PagerConfig {
167    fn default() -> Self {
168        Self {
169            cache_size: 10_000,
170            read_only: false,
171            verify_checksums: true,
172            double_write: true,
173        }
174    }
175}
176
177/// The host's WAL durability barrier, called before publishing dirty page batches.
178/// Implementations must make all records through `lsn` durable or return an error.
179pub trait WalBarrier {
180    fn flush_until(&self, lsn: u64) -> std::io::Result<()>;
181}
182
183/// A pager without a WAL may only publish pages whose LSN is zero.
184pub struct NoWal;
185impl WalBarrier for NoWal {
186    fn flush_until(&self, _lsn: u64) -> std::io::Result<()> {
187        Err(std::io::Error::other(
188            "nonzero page LSN requires a WAL durability barrier",
189        ))
190    }
191}
192pub struct Pager<F: VfsFile, W: WalBarrier> {
193    /// Database file path
194    path: PathBuf,
195    /// File handle
196    file: Mutex<F>,
197    /// Whether mutable pages are protected by the in-file DWB zone.
198    dwb_enabled: bool,
199    /// Page cache
200    cache: PageCache,
201    /// Free page list
202    freelist: RwLock<FreeList>,
203    /// Store-managed frees stay unavailable until their removal from the
204    /// durable catalog has crossed a publication boundary.
205    deferred_reclamation: Mutex<DeferredPageReclamation>,
206    /// Reused page ownership must reach both superblock copies before a
207    /// manifest can refer to those pages, including single-slot recovery.
208    allocation_reused: std::sync::atomic::AtomicBool,
209    /// A consumed trunk cannot be reused until both header fences succeed.
210    allocation_fence_pending: std::sync::atomic::AtomicBool,
211    /// Database header
212    header: RwLock<DatabaseHeader>,
213    /// Configuration
214    config: PagerConfig,
215    /// Dirty flag for header
216    header_dirty: Mutex<bool>,
217    wal: W,
218}
219
220#[derive(Default)]
221struct DeferredPageReclamation {
222    enabled: bool,
223    pending: std::collections::BTreeSet<u32>,
224    published: std::collections::BTreeSet<u32>,
225}
226
227/// The superblock zone occupies the head of page 0 (ADR 0038 §2 phase 1).
228const SUPERBLOCK_SLOT_SIZE: usize = reddb_file::PAGED_SUPERBLOCK_SLOT_SIZE;
229const SUPERBLOCK_ZONE_SIZE: usize = reddb_file::PAGED_SUPERBLOCK_ZONE_SIZE;
230const PHASE3_DWB_ZONE_FILE_VERSION: u32 = reddb_file::PAGE_FILE_VERSION;
231
232/// The newest valid superblock copy together with its slot image.
233pub type NewestSuperblock = (
234    reddb_file::PagedSuperblockSelection,
235    [u8; SUPERBLOCK_SLOT_SIZE],
236);
237
238/// Take the first slot's worth of a freshly-built page-0 image. The database
239/// header and the encryption header both live well inside this prefix.
240fn superblock_slot_image(page: &[u8; PAGE_SIZE]) -> [u8; SUPERBLOCK_SLOT_SIZE] {
241    let mut image = [0u8; SUPERBLOCK_SLOT_SIZE];
242    image.copy_from_slice(&page[..SUPERBLOCK_SLOT_SIZE]);
243    image
244}
245
246impl<F: VfsFile, W: WalBarrier> Pager<F, W> {
247    /// Use an already-owned file. The host retains exclusive ownership until
248    /// this pager is dropped, and supplies the native WAL/encryption policy.
249    /// `create` is explicit: a corrupt or empty existing file is never recreated.
250    pub fn open_owned(
251        file: F,
252        path: PathBuf,
253        config: PagerConfig,
254        wal: W,
255        create: bool,
256    ) -> Result<Self, PagerError> {
257        if create && file.file_len()? != 0 {
258            return Err(PagerError::InvalidDatabase(
259                "new pager file is not empty".into(),
260            ));
261        }
262        let pager = Self {
263            path,
264            file: Mutex::new(file),
265            dwb_enabled: config.double_write && !config.read_only,
266            cache: PageCache::new(config.cache_size),
267            freelist: RwLock::new(FreeList::new()),
268            deferred_reclamation: Mutex::new(DeferredPageReclamation::default()),
269            allocation_reused: std::sync::atomic::AtomicBool::new(false),
270            allocation_fence_pending: std::sync::atomic::AtomicBool::new(false),
271            header: RwLock::new(DatabaseHeader::default()),
272            config,
273            header_dirty: Mutex::new(false),
274            wal,
275        };
276        if create {
277            pager.initialize()?;
278        } else {
279            pager.ensure_phase3_dwb_zone_layout()?;
280            pager.recover_from_dwb()?;
281            pager.load_header()?;
282        }
283        Ok(pager)
284    }
285
286    pub fn wal_barrier(&self) -> &W {
287        &self.wal
288    }
289
290    /// Run a host operation while holding the same lock as protected page I/O.
291    /// This is used only for native prefetch and fault-injection tests.
292    #[doc(hidden)]
293    pub fn with_file<T>(&self, operation: impl FnOnce(&mut F) -> T) -> Result<T, PagerError> {
294        Ok(operation(&mut *self.file_lock()?))
295    }
296
297    /// Initialize a new database
298    fn initialize(&self) -> Result<(), PagerError> {
299        if self.config.read_only {
300            return Err(PagerError::ReadOnly);
301        }
302
303        // Create header page. Page ids 1 and 2 are reserved for fixed
304        // metadata/vault pages. ADR 0038 phase 3 reserves the following DWB
305        // zone in-file so normal B-tree allocation starts after it.
306        let initial_page_count = FIRST_ALLOCATABLE_PAGE;
307        let header_page = Page::new_header_page(initial_page_count);
308        self.header_write()?.page_count = initial_page_count;
309
310        // Reserve page 0 (the superblock zone) plus the reserved pages so any
311        // scan over 0..page_count can read every allocated page in a brand-new
312        // database. The zone's own bytes are published below, one slot at a
313        // time; this first whole-page write only sizes the file.
314        self.write_page_raw(0, &header_page)?;
315        let mut metadata_page = Page::new(PageType::Header, 1);
316        metadata_page.update_checksum();
317        self.write_page_raw(1, &metadata_page)?;
318        let mut vault_page = Page::new(PageType::Vault, 2);
319        vault_page.update_checksum();
320        self.write_page_raw(2, &vault_page)?;
321        self.clear_dwb_zone()?;
322
323        // Publish both superblock copies so the ping-pong invariant — one
324        // valid copy always survives a crash — holds from the very first
325        // update onward, not just from the second.
326        let mut slot = superblock_slot_image(header_page.as_bytes());
327        self.write_superblock_slot(&mut slot, 0, 1)?;
328        self.write_superblock_slot(&mut slot, 1, 2)?;
329
330        // Sync to disk
331        self.sync()?;
332
333        Ok(())
334    }
335
336    /// Acquire a write lock on the header RwLock, mapping poison errors.
337    fn header_write(&self) -> Result<std::sync::RwLockWriteGuard<'_, DatabaseHeader>, PagerError> {
338        self.header.write().map_err(|_| PagerError::LockPoisoned)
339    }
340
341    /// Acquire a read lock on the header RwLock, mapping poison errors.
342    fn header_read(&self) -> Result<std::sync::RwLockReadGuard<'_, DatabaseHeader>, PagerError> {
343        self.header.read().map_err(|_| PagerError::LockPoisoned)
344    }
345
346    /// Acquire a write lock on the freelist RwLock, mapping poison errors.
347    fn freelist_write(&self) -> Result<std::sync::RwLockWriteGuard<'_, FreeList>, PagerError> {
348        self.freelist.write().map_err(|_| PagerError::LockPoisoned)
349    }
350
351    /// Acquire a lock on the file Mutex, mapping poison errors.
352    fn file_lock(&self) -> Result<std::sync::MutexGuard<'_, F>, PagerError> {
353        self.file.lock().map_err(|_| PagerError::LockPoisoned)
354    }
355
356    /// Acquire a lock on the header_dirty Mutex, mapping poison errors.
357    fn header_dirty_lock(&self) -> Result<std::sync::MutexGuard<'_, bool>, PagerError> {
358        self.header_dirty
359            .lock()
360            .map_err(|_| PagerError::LockPoisoned)
361    }
362
363    /// Read the whole superblock zone (both ping-pong copies) from the head of
364    /// the file. A file shorter than the zone yields zero-filled tail bytes,
365    /// which decode as "slot never written".
366    fn read_superblock_zone(&self) -> Result<[u8; SUPERBLOCK_ZONE_SIZE], PagerError> {
367        let mut file = self.file_lock()?;
368        Ok(pager_io::read_superblock_zone(&mut *file)?)
369    }
370
371    /// The newest valid superblock copy, with its slot image.
372    ///
373    /// Both copies invalid is the one unrecoverable superblock state (ADR 0074
374    /// §2): the store does not open, and the error names the zone and points
375    /// at salvage rather than panicking or silently re-initialising.
376    #[doc(hidden)]
377    pub fn newest_superblock(&self) -> Result<NewestSuperblock, PagerError> {
378        let zone = self.read_superblock_zone()?;
379        let selection = reddb_file::select_paged_superblock(&zone)
380            .ok_or_else(|| PagerError::SuperblockZoneUnrecoverable(self.path.clone()))?;
381
382        let start = selection.copy_index * SUPERBLOCK_SLOT_SIZE;
383        let mut image = [0u8; SUPERBLOCK_SLOT_SIZE];
384        image.copy_from_slice(&zone[start..start + SUPERBLOCK_SLOT_SIZE]);
385        Ok((selection, image))
386    }
387
388    fn ensure_phase3_dwb_zone_layout(&self) -> Result<(), PagerError> {
389        let (_, image) = self.newest_superblock()?;
390        let header = reddb_file::decode_database_header(&image)
391            .map_err(|err| PagerError::InvalidDatabase(err.to_string()))?;
392        if header.version < PHASE3_DWB_ZONE_FILE_VERSION {
393            return Err(PagerError::LegacyPagedStore {
394                path: self.path.clone(),
395            });
396        }
397        Ok(())
398    }
399
400    /// Seal `slot` for `copy_index`/`generation` and write exactly that slot,
401    /// then fsync. The caller guarantees `copy_index` is the stale copy.
402    fn write_superblock_slot(
403        &self,
404        slot: &mut [u8; SUPERBLOCK_SLOT_SIZE],
405        copy_index: usize,
406        generation: u64,
407    ) -> Result<(), PagerError> {
408        if self.config.read_only {
409            return Err(PagerError::ReadOnly);
410        }
411        let mut file = self.file_lock()?;
412        Ok(pager_io::write_superblock_slot(
413            &mut *file, slot, copy_index, generation,
414        )?)
415    }
416
417    /// Publish `mutate`'s edit of the newest slot image into the *stale* copy.
418    ///
419    /// Reading the newest image first means every byte the pager does not
420    /// explicitly rewrite — most importantly the encryption marker and header —
421    /// carries forward untouched.
422    #[doc(hidden)]
423    pub fn commit_superblock(
424        &self,
425        mutate: impl FnOnce(&mut [u8; SUPERBLOCK_SLOT_SIZE]) -> Result<(), PagerError>,
426    ) -> Result<(), PagerError> {
427        if self.config.read_only {
428            return Err(PagerError::ReadOnly);
429        }
430        let (newest, mut image) = self.newest_superblock()?;
431        mutate(&mut image)?;
432        let next_copy = reddb_file::paged_superblock_next_copy(Some(newest));
433        self.write_superblock_slot(&mut image, next_copy, newest.generation.saturating_add(1))
434    }
435
436    /// Load the database header from the newest valid superblock copy.
437    fn load_header(&self) -> Result<(), PagerError> {
438        let (_, image) = self.newest_superblock()?;
439
440        let mut decoded_header = reddb_file::decode_database_header(&image)
441            .map_err(|err| PagerError::InvalidDatabase(err.to_string()))?;
442        // Cache eviction (or DWB recovery) can persist pages before the next
443        // superblock publication. After a crash the allocation counter may
444        // therefore lag behind live pages already in the file. Reserve the
445        // entire physical extent before allocating; never overwrite that tail.
446        // A partial last page is reserved too, without treating it as verified.
447        let physical_pages = self.file_lock()?.file_len()?.div_ceil(PAGE_SIZE as u64);
448        let physical_pages = u32::try_from(physical_pages).map_err(|_| {
449            PagerError::InvalidDatabase("data file exceeds the page address space".into())
450        })?;
451        if decoded_header.page_count < physical_pages {
452            decoded_header.page_count = physical_pages;
453            if !self.config.read_only {
454                *self.header_dirty_lock()? = true;
455            }
456        }
457        let freelist_head = decoded_header.freelist_head;
458
459        {
460            let mut header = self.header_write()?;
461            *header = decoded_header;
462        }
463
464        // Initialize freelist
465        {
466            let mut freelist = self.freelist_write()?;
467            *freelist = FreeList::from_header(freelist_head, 0);
468        }
469
470        Ok(())
471    }
472
473    /// Publish the in-memory database header as a new superblock generation.
474    fn write_header(&self) -> Result<(), PagerError> {
475        if self.config.read_only {
476            return Err(PagerError::ReadOnly);
477        }
478
479        let header = self.header_read()?.clone();
480        self.commit_superblock(|image| {
481            reddb_file::encode_database_header(image, &header)
482                .map_err(|err| PagerError::InvalidDatabase(err.to_string()))
483        })?;
484        *self.header_dirty_lock()? = false;
485
486        Ok(())
487    }
488
489    /// Read a page from disk (bypassing cache)
490    fn read_page_raw(&self, page_id: u32) -> Result<Page, PagerError> {
491        let mut file = self.file_lock()?;
492        Ok(pager_io::read_page(
493            &mut *file,
494            page_id,
495            self.config.verify_checksums,
496        )?)
497    }
498
499    /// Write a page to disk (bypassing cache)
500    fn write_page_raw(&self, page_id: u32, page: &Page) -> Result<(), PagerError> {
501        if self.config.read_only {
502            return Err(PagerError::ReadOnly);
503        }
504        let mut file = self.file_lock()?;
505        Ok(pager_io::write_page(&mut *file, page_id, page)?)
506    }
507
508    /// Read a page (cache-aware)
509    pub fn read_page(&self, page_id: u32) -> Result<Page, PagerError> {
510        // Check cache first
511        if let Some(page) = self.cache.get(page_id) {
512            return Ok(page);
513        }
514
515        // Cache miss - read from disk
516        let page = self.read_page_raw(page_id)?;
517
518        // Add to cache
519        if let Some(dirty_page) = self.cache.insert(page_id, page.clone()) {
520            // Evicted page was dirty, need to write it back
521            let evicted_id = dirty_page.page_id();
522            self.write_page_raw(evicted_id, &dirty_page)?;
523        }
524
525        Ok(page)
526    }
527
528    /// Read a page without verifying checksum (for encrypted pages)
529    ///
530    /// Use this when the page content has its own integrity protection
531    /// (e.g., AES-GCM authentication tag for encrypted pages).
532    pub fn read_page_no_checksum(&self, page_id: u32) -> Result<Page, PagerError> {
533        // Check cache first
534        if let Some(page) = self.cache.get(page_id) {
535            return Ok(page);
536        }
537
538        // Cache miss - use the same byte I/O without a page CRC check.
539        let mut file = self.file_lock()?;
540        let page = pager_io::read_page(&mut *file, page_id, false)?;
541        drop(file);
542
543        // Add to cache (no checksum verification)
544        if let Some(dirty_page) = self.cache.insert(page_id, page.clone()) {
545            // Evicted page was dirty, need to write it back
546            let evicted_id = dirty_page.page_id();
547            self.write_page_raw(evicted_id, &dirty_page)?;
548        }
549
550        Ok(page)
551    }
552
553    /// Write a page (cache-aware)
554    pub fn write_page(&self, page_id: u32, mut page: Page) -> Result<(), PagerError> {
555        if self.config.read_only {
556            return Err(PagerError::ReadOnly);
557        }
558
559        // Update checksum
560        page.update_checksum();
561
562        // Add to cache and mark dirty. If the cache had to evict a
563        // dirty entry, write it through immediately — the evicted
564        // page will never be flushed otherwise (same bug fixed in
565        // `allocate_page`).
566        if let Some(dirty_page) = self.cache.insert(page_id, page) {
567            let evicted_id = dirty_page.page_id();
568            self.write_page_raw(evicted_id, &dirty_page)?;
569        }
570        self.cache.mark_dirty(page_id);
571
572        Ok(())
573    }
574
575    /// Write a page without updating checksum (for encrypted pages)
576    ///
577    /// Use this when the page content has its own integrity protection
578    /// (e.g., AES-GCM authentication tag for encrypted pages).
579    pub fn write_page_no_checksum(&self, page_id: u32, page: Page) -> Result<(), PagerError> {
580        if self.config.read_only {
581            return Err(PagerError::ReadOnly);
582        }
583
584        // Add to cache and mark dirty (no checksum update). Same
585        // eviction-write-through guard as `write_page`.
586        if let Some(dirty_page) = self.cache.insert(page_id, page) {
587            let evicted_id = dirty_page.page_id();
588            self.write_page_raw(evicted_id, &dirty_page)?;
589        }
590        self.cache.mark_dirty(page_id);
591
592        Ok(())
593    }
594
595    /// Allocate a new page
596    pub fn allocate_page(&self, page_type: PageType) -> Result<Page, PagerError> {
597        if self.config.read_only {
598            return Err(PagerError::ReadOnly);
599        }
600
601        // Try to get from freelist first
602        let page_id = {
603            let mut freelist = self.freelist_write()?;
604            // A previous fence can fail after loading the trunk into memory.
605            // Retry it before the fast path can consume any of those pages.
606            self.complete_allocation_fence()?;
607            if let Some(id) = freelist.allocate() {
608                self.allocation_reused
609                    .store(true, std::sync::atomic::Ordering::Release);
610                id
611            } else if freelist.trunk_head() != 0 {
612                let trunk_id = freelist.trunk_head();
613
614                let trunk = self.read_page(trunk_id).map_err(|e| match e {
615                    PagerError::PageNotFound(_) => {
616                        PagerError::InvalidDatabase("Freelist trunk missing".to_string())
617                    }
618                    other => other,
619                })?;
620
621                freelist
622                    .load_from_trunk(&trunk)
623                    .map_err(|e| PagerError::InvalidDatabase(format!("Freelist: {}", e)))?;
624                let mut header = self.header_write()?;
625                header.freelist_head = freelist.trunk_head();
626                *self.header_dirty_lock()? = true;
627                drop(header);
628                // The consumed trunk itself becomes reusable. Forget its old
629                // allocator role durably before cache eviction can overwrite it.
630                // Keep the allocator locked so another allocator cannot race
631                // ahead of this fence. A failed fence leaves allocation stopped.
632                self.allocation_fence_pending
633                    .store(true, std::sync::atomic::Ordering::Release);
634                self.complete_allocation_fence()?;
635                let id = freelist.allocate().ok_or_else(|| {
636                    PagerError::InvalidDatabase("Freelist empty after trunk load".to_string())
637                })?;
638                self.allocation_reused
639                    .store(true, std::sync::atomic::Ordering::Release);
640                id
641            } else {
642                // No free pages, extend file
643                let mut header = self.header_write()?;
644                let id = header.page_count;
645                header.page_count += 1;
646                *self.header_dirty_lock()? = true;
647                id
648            }
649        };
650
651        let page = Page::new(page_type, page_id);
652
653        // Write to cache. The evicted page (if any) is dirty by
654        // definition — `cache.insert` only returns `Some` when it
655        // had to evict a dirty entry to make room. The previous
656        // version dropped that return value, which silently lost
657        // writes whenever a freshly-allocated page caused an LRU
658        // eviction. This shows up under heavy ingest as
659        // "B-tree insert error: Pager error: I/O error: failed to
660        // fill whole buffer" later, when something tries to read
661        // back the never-flushed page. Mirror `read_page`'s
662        // handling: write the evicted page through immediately so
663        // the on-disk image stays consistent.
664        if let Some(dirty_page) = self.cache.insert(page_id, page.clone()) {
665            let evicted_id = dirty_page.page_id();
666            self.write_page_raw(evicted_id, &dirty_page)?;
667        }
668        self.cache.mark_dirty(page_id);
669
670        Ok(page)
671    }
672
673    /// Reserve a contiguous extent of vector pages.
674    pub fn reserve_contig_extent(&self, n_pages: u32) -> Result<ExtentId, PagerError> {
675        if self.config.read_only {
676            return Err(PagerError::ReadOnly);
677        }
678        if n_pages == 0 {
679            return Err(PagerError::InvalidDatabase(
680                "contiguous extent must reserve at least one page".to_string(),
681            ));
682        }
683
684        let start_page = {
685            let mut header = self.header_write()?;
686            let start = header.page_count;
687            header.page_count = header.page_count.checked_add(n_pages).ok_or_else(|| {
688                PagerError::InvalidDatabase("contiguous extent page count overflow".to_string())
689            })?;
690            *self.header_dirty_lock()? = true;
691            start
692        };
693
694        for page_id in start_page..start_page + n_pages {
695            let mut page = Page::new(PageType::Vector, page_id);
696            page.update_checksum();
697            if let Some(dirty_page) = self.cache.insert(page_id, page) {
698                let evicted_id = dirty_page.page_id();
699                self.write_page_raw(evicted_id, &dirty_page)?;
700            }
701            self.cache.mark_dirty(page_id);
702        }
703
704        Ok(ExtentId {
705            start_page,
706            n_pages,
707        })
708    }
709
710    /// Free a page (return to freelist)
711    pub fn free_page(&self, page_id: u32) -> Result<(), PagerError> {
712        if self.config.read_only {
713            return Err(PagerError::ReadOnly);
714        }
715
716        let mut deferred = self
717            .deferred_reclamation
718            .lock()
719            .map_err(|_| PagerError::LockPoisoned)?;
720        if deferred.enabled {
721            if page_id < FIRST_ALLOCATABLE_PAGE || page_id >= self.page_count()? {
722                return Err(PagerError::InvalidDatabase(
723                    "invalid deferred free page".into(),
724                ));
725            }
726            // Keep dirty forwarding/empty nodes until publication. An
727            // incidental flush must leave the previous root traversable.
728            deferred.pending.insert(page_id);
729            return Ok(());
730        }
731        drop(deferred);
732
733        // Remove from cache
734        self.cache.remove(page_id);
735
736        // Add to freelist
737        let mut freelist = self.freelist_write()?;
738        freelist.free(page_id);
739
740        *self.header_dirty_lock()? = true;
741
742        Ok(())
743    }
744
745    pub fn enable_deferred_reclamation(&self) -> Result<(), PagerError> {
746        self.deferred_reclamation
747            .lock()
748            .map_err(|_| PagerError::LockPoisoned)?
749            .enabled = true;
750        Ok(())
751    }
752
753    pub fn deferred_reclamation_pages(&self) -> Result<(Vec<u32>, Vec<u32>), PagerError> {
754        let deferred = self
755            .deferred_reclamation
756            .lock()
757            .map_err(|_| PagerError::LockPoisoned)?;
758        Ok((
759            deferred.pending.iter().copied().collect(),
760            deferred.published.iter().copied().collect(),
761        ))
762    }
763
764    pub fn restore_deferred_reclamation(&self, pages: &[u32]) -> Result<(), PagerError> {
765        let count = self.page_count()?;
766        if pages
767            .iter()
768            .any(|id| *id < FIRST_ALLOCATABLE_PAGE || *id >= count)
769        {
770            return Err(PagerError::InvalidDatabase(
771                "invalid persisted deferred free page".into(),
772            ));
773        }
774        let mut deferred = self
775            .deferred_reclamation
776            .lock()
777            .map_err(|_| PagerError::LockPoisoned)?;
778        deferred.pending.extend(pages);
779        deferred.published.extend(pages);
780        Ok(())
781    }
782
783    /// The caller has durably published a catalog that retains `retained`
784    /// and no longer names `released`, after waiting for old tree readers.
785    pub fn complete_deferred_publication(
786        &self,
787        retained: &[u32],
788        released: &[u32],
789    ) -> Result<(), PagerError> {
790        let mut deferred = self
791            .deferred_reclamation
792            .lock()
793            .map_err(|_| PagerError::LockPoisoned)?;
794        if !released.is_empty() {
795            self.free_retired_pages(released)?;
796        }
797        for id in released {
798            deferred.pending.remove(id);
799        }
800        deferred.published = retained.iter().copied().collect();
801        Ok(())
802    }
803
804    /// Return a fully validated, unpublished tree generation in one allocator
805    /// mutation. Call only after its removal from the manifest is durable.
806    pub fn free_retired_pages(&self, pages: &[u32]) -> Result<(), PagerError> {
807        if self.config.read_only {
808            return Err(PagerError::ReadOnly);
809        }
810        let page_count = self.page_count()?;
811        if pages
812            .iter()
813            .any(|id| *id < FIRST_ALLOCATABLE_PAGE || *id >= page_count)
814        {
815            return Err(PagerError::InvalidDatabase(
816                "invalid retired page range".into(),
817            ));
818        }
819        let mut freelist = self.freelist_write()?;
820        for id in pages {
821            self.cache.remove(*id);
822        }
823        freelist.free_batch(pages);
824        *self.header_dirty_lock()? = true;
825        Ok(())
826    }
827
828    /// Get database header
829    /// Pages available to the allocator, including pages on unloaded trunks.
830    pub fn free_page_count(&self) -> Result<u32, PagerError> {
831        Ok(self.freelist_write()?.total_free())
832    }
833
834    pub fn header(&self) -> Result<DatabaseHeader, PagerError> {
835        Ok(self.header_read()?.clone())
836    }
837
838    pub fn physical_header(&self) -> Result<PhysicalFileHeader, PagerError> {
839        Ok(self.header_read()?.physical)
840    }
841
842    pub fn update_physical_header(&self, physical: PhysicalFileHeader) -> Result<(), PagerError> {
843        if self.config.read_only {
844            return Err(PagerError::ReadOnly);
845        }
846
847        let mut header = self.header_write()?;
848        header.physical = physical;
849        *self.header_dirty_lock()? = true;
850        Ok(())
851    }
852
853    /// Get page count
854    pub fn page_count(&self) -> Result<u32, PagerError> {
855        Ok(self.header_read()?.page_count)
856    }
857
858    /// Flush all dirty pages to disk
859    pub fn flush(&self) -> Result<(), PagerError> {
860        if self.config.read_only {
861            return Ok(());
862        }
863
864        // Persist freelist to trunk pages when dirty
865        let trunks = {
866            let mut freelist = self.freelist_write()?;
867            if freelist.is_dirty() {
868                let mut header = self.header_write()?;
869                let trunks = freelist.flush_to_trunks(0, || {
870                    let id = header.page_count;
871                    header.page_count += 1;
872                    id
873                });
874                header.freelist_head = freelist.trunk_head();
875                *self.header_dirty_lock()? = true;
876                freelist.mark_clean();
877                trunks
878            } else {
879                Vec::new()
880            }
881        };
882
883        let mut dirty_pages = Vec::new();
884        for trunk in trunks {
885            let page_id = trunk.page_id();
886            if let Some(evicted) = self.cache.insert(page_id, trunk) {
887                // Trunk publication can evict dirty data. Keep it in the same
888                // WAL-first, double-write-protected batch as resident pages.
889                dirty_pages.push((evicted.page_id(), evicted));
890            }
891            self.cache.mark_dirty(page_id);
892        }
893
894        // Flush dirty pages from cache (through DWB if enabled)
895        dirty_pages.extend(self.cache.flush_dirty());
896        if !dirty_pages.is_empty() {
897            // WAL-FIRST: ensure every WAL record describing a dirty
898            // page is durable BEFORE the page itself reaches disk.
899            // Pages with `lsn == 0` are exempt (freelist trunks, header
900            // shadow pages, anything not produced by a WAL append).
901            let max_lsn = dirty_pages
902                .iter()
903                .filter_map(|(_, page)| page.header().ok().map(|h| h.lsn))
904                .max()
905                .unwrap_or(0);
906            if max_lsn > 0 {
907                self.wal.flush_until(max_lsn)?;
908            }
909            self.write_pages_through_dwb(&dirty_pages)?;
910        }
911
912        // Write header if dirty
913        if *self.header_dirty_lock()? {
914            self.write_header()?;
915        }
916
917        Ok(())
918    }
919
920    /// Sync file to disk (fsync)
921    pub fn sync(&self) -> Result<(), PagerError> {
922        self.flush()?;
923
924        if self
925            .allocation_reused
926            .swap(false, std::sync::atomic::Ordering::AcqRel)
927        {
928            // flush published the current allocator in one slot. Mirror that
929            // ownership before callers publish roots that use reclaimed pages;
930            // recovery through the sibling slot must never allocate them again.
931            if let Err(error) = self.write_header_and_sync() {
932                self.allocation_reused
933                    .store(true, std::sync::atomic::Ordering::Release);
934                return Err(error);
935            }
936        }
937
938        let file = self.file_lock()?;
939        file.sync_all()?;
940
941        Ok(())
942    }
943
944    /// Get cache statistics
945    pub fn cache_stats(&self) -> super::page_cache::CacheStats {
946        self.cache.stats()
947    }
948
949    /// Pages currently resident in the page cache. Slots are fixed size, so
950    /// this times the page size is the cache's memory footprint (ADR 0073 §2).
951    pub fn cache_len(&self) -> usize {
952        self.cache.len()
953    }
954
955    /// Preallocated page-cache slots — the sizing this process derived from
956    /// its page-cache budget share.
957    pub fn cache_capacity(&self) -> usize {
958        self.cache.capacity()
959    }
960
961    /// Count dirty pages currently in the page cache.
962    pub fn dirty_page_count(&self) -> usize {
963        self.cache.dirty_count()
964    }
965
966    /// Estimated fraction of the page cache holding dirty pages.
967    /// Returned in `[0, 1]`. Used by the background writer to
968    /// decide when to kick in aggressive flushing.
969    pub fn dirty_fraction(&self) -> f64 {
970        let capacity = self.cache.capacity().max(1) as f64;
971        self.cache.dirty_count() as f64 / capacity
972    }
973
974    /// Flush up to `max` dirty pages from the cache. Returns the
975    /// number actually written. Background-writer entry point —
976    /// reuses the same persistence path as `flush()` but bounded.
977    pub fn flush_some_dirty(&self, max: usize) -> Result<usize, PagerError> {
978        if self.config.read_only || max == 0 {
979            return Ok(0);
980        }
981        let dirty_pages = self.cache.flush_some_dirty(max);
982        if dirty_pages.is_empty() {
983            return Ok(0);
984        }
985        let count = dirty_pages.len();
986        // WAL-first: every cached dirty page carries an LSN that the
987        // WAL must have already persisted. The full `flush()` path
988        // enforces this with `wal.flush(max_lsn)`; here we simply
989        // write through the pager — safe because callers only reach
990        // this path via the bgwriter, which runs asynchronously
991        // alongside normal commits that already respect WAL-first.
992        for (page_id, page) in dirty_pages {
993            self.write_page(page_id, page)?;
994        }
995        Ok(count)
996    }
997
998    /// Get database file path
999    pub fn path(&self) -> &Path {
1000        &self.path
1001    }
1002
1003    /// Check if database is read-only
1004    pub fn is_read_only(&self) -> bool {
1005        self.config.read_only
1006    }
1007
1008    /// Get file size in bytes
1009    pub fn file_size(&self) -> Result<u64, PagerError> {
1010        let file = self.file_lock()?;
1011        Ok(file.file_len()?)
1012    }
1013
1014    /// Clear the in-file DWB zone with valid reserved pages.
1015    fn clear_dwb_zone(&self) -> Result<(), PagerError> {
1016        let mut file = self.file_lock()?;
1017        Ok(pager_io::clear_dwb_zone(&mut *file)?)
1018    }
1019
1020    /// Read the internal manifest page (page 1), the zone the superblock roots.
1021    ///
1022    /// ADR 0074 §2: manifest corruption fails the open didactically, naming the
1023    /// zone — it never falls back to a sidecar and never returns garbage.
1024    pub fn read_manifest_page(&self) -> Result<Page, PagerError> {
1025        self.read_page(1)
1026            .map_err(|_| PagerError::ManifestZoneCorrupt(self.path.clone()))
1027    }
1028
1029    /// Write pages through the double-write buffer for torn page protection.
1030    ///
1031    /// 1. Write pages to the in-file DWB zone with a header (magic + count + checksum)
1032    /// 2. fsync the DWB zone
1033    /// 3. Write all pages to their final locations in the .rdb file
1034    /// 4. Clear the DWB zone (marks as consumed)
1035    fn write_pages_through_dwb(&self, pages: &[(u32, Page)]) -> Result<(), PagerError> {
1036        if self.config.read_only {
1037            return Err(PagerError::ReadOnly);
1038        }
1039        // Keep the file locked through staging, home writes and clearing so
1040        // another page writer cannot interleave with a protected batch.
1041        let mut file = self.file_lock()?;
1042        Ok(pager_io::write_pages(&mut *file, pages, self.dwb_enabled)?)
1043    }
1044
1045    /// Recover from double-write buffer after a crash.
1046    ///
1047    /// If the DWB zone contains valid pages, they were written before the crash
1048    /// interrupted writing to their home locations. Re-apply them.
1049    fn recover_from_dwb(&self) -> Result<(), PagerError> {
1050        let mut file = self.file_lock()?;
1051        Ok(pager_io::recover_from_dwb(&mut *file)?)
1052    }
1053
1054    /// Finish a consumed trunk's durable ownership change, retaining errors
1055    /// across allocation attempts until both copies have been synchronized.
1056    /// Called with the freelist locked when consuming or retrying a trunk.
1057    fn complete_allocation_fence(&self) -> Result<(), PagerError> {
1058        if self
1059            .allocation_fence_pending
1060            .load(std::sync::atomic::Ordering::Acquire)
1061        {
1062            self.write_header_and_sync()?;
1063            self.write_header_and_sync()?;
1064            self.allocation_fence_pending
1065                .store(false, std::sync::atomic::Ordering::Release);
1066        }
1067        Ok(())
1068    }
1069
1070    /// Write header and sync to disk (public for checkpointer).
1071    pub fn write_header_and_sync(&self) -> Result<(), PagerError> {
1072        self.write_header()?;
1073        let file = self.file_lock()?;
1074        file.sync_all()?;
1075        Ok(())
1076    }
1077
1078    /// Set the checkpoint_in_progress flag in the header.
1079    pub fn set_checkpoint_in_progress(
1080        &self,
1081        in_progress: bool,
1082        target_lsn: u64,
1083    ) -> Result<(), PagerError> {
1084        let mut header = self.header_write()?;
1085        header.checkpoint_in_progress = in_progress;
1086        header.checkpoint_target_lsn = target_lsn;
1087        *self.header_dirty_lock()? = true;
1088        drop(header);
1089        self.write_header_and_sync()
1090    }
1091
1092    /// Update the checkpoint LSN and clear the in-progress flag.
1093    pub fn complete_checkpoint(&self, lsn: u64) -> Result<(), PagerError> {
1094        let mut header = self.header_write()?;
1095        header.checkpoint_lsn = lsn;
1096        header.checkpoint_in_progress = false;
1097        header.checkpoint_target_lsn = 0;
1098        *self.header_dirty_lock()? = true;
1099        drop(header);
1100        self.write_header_and_sync()
1101    }
1102}