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    /// A checkpoint host can preserve the first image of an existing page
183    /// before the cache accepts an overwrite. Newly reserved pages need no undo.
184    fn needs_page_image(&self, _page_id: u32) -> std::io::Result<bool> {
185        Ok(false)
186    }
187
188    /// Must make the image durable before returning. The pager may evict the
189    /// replacement immediately; recording only at flush time is too late.
190    fn capture_page_image(&self, _page: &Page) -> std::io::Result<()> {
191        Ok(())
192    }
193
194    /// Called before pages are removed from allocator ownership.
195    fn reserve_pages(&self, _start: u32, _count: u32) -> std::io::Result<()> {
196        Ok(())
197    }
198
199    /// Called before a persisted free-list trunk is detached from its head.
200    fn detach_free_pages(&self, _pages: &[u32]) -> std::io::Result<()> {
201        Ok(())
202    }
203
204    /// True only while detached free IDs have durable recovery ownership.
205    /// No-op callbacks must not authorize moving a whole trunk into RAM.
206    fn has_page_ownership_journal(&self) -> std::io::Result<bool> {
207        Ok(false)
208    }
209
210    /// Called before retired pages enter the free list.
211    fn retire_pages(&self, _pages: &[u32]) -> std::io::Result<()> {
212        Ok(())
213    }
214}
215
216/// A pager without a WAL may only publish pages whose LSN is zero.
217pub struct NoWal;
218impl WalBarrier for NoWal {
219    fn flush_until(&self, _lsn: u64) -> std::io::Result<()> {
220        Err(std::io::Error::other(
221            "nonzero page LSN requires a WAL durability barrier",
222        ))
223    }
224}
225pub struct Pager<F: VfsFile, W: WalBarrier> {
226    /// Database file path
227    path: PathBuf,
228    /// File handle
229    file: Mutex<F>,
230    /// Whether mutable pages are protected by the in-file DWB zone.
231    dwb_enabled: bool,
232    /// A failed writeback may leave the only complete image in the DWB.
233    /// Refuse further writes until reopen has recovered that image.
234    writeback_failed: std::sync::atomic::AtomicBool,
235    /// Page cache
236    cache: PageCache,
237    /// Free page list
238    freelist: RwLock<FreeList>,
239    /// Store-managed frees stay unavailable until their removal from the
240    /// durable catalog has crossed a publication boundary.
241    deferred_reclamation: Mutex<DeferredPageReclamation>,
242    /// Excludes page ownership changes while a host publishes an epoch.
243    /// Reentrant so the publishing thread can use ordinary pager methods.
244    ownership_mutations: parking_lot::ReentrantMutex<()>,
245    /// Reused page ownership must reach both superblock copies before a
246    /// manifest can refer to those pages, including single-slot recovery.
247    allocation_reused: std::sync::atomic::AtomicBool,
248    /// A consumed trunk cannot be reused until both header fences succeed.
249    allocation_fence_pending: std::sync::atomic::AtomicBool,
250    /// Database header
251    header: RwLock<DatabaseHeader>,
252    /// Serialize read-select-write sequences for the superblock pair.
253    superblock_publication: Mutex<()>,
254    /// Configuration
255    config: PagerConfig,
256    /// Dirty flag for header
257    header_dirty: Mutex<bool>,
258    wal: W,
259}
260
261#[derive(Default)]
262struct DeferredPageReclamation {
263    enabled: bool,
264    pending: std::collections::BTreeSet<u32>,
265    published: std::collections::BTreeSet<u32>,
266}
267
268/// The superblock zone occupies the head of page 0 (ADR 0038 §2 phase 1).
269const SUPERBLOCK_SLOT_SIZE: usize = reddb_file::PAGED_SUPERBLOCK_SLOT_SIZE;
270const SUPERBLOCK_ZONE_SIZE: usize = reddb_file::PAGED_SUPERBLOCK_ZONE_SIZE;
271// The phase-3 layout was introduced in 1.0.1. Later file format versions
272// must not make existing phase-3 stores look like legacy sidecar stores.
273const PHASE3_DWB_ZONE_FILE_VERSION: u32 = 0x0001_0001;
274
275/// The newest valid superblock copy together with its slot image.
276pub type NewestSuperblock = (
277    reddb_file::PagedSuperblockSelection,
278    [u8; SUPERBLOCK_SLOT_SIZE],
279);
280
281/// Take the first slot's worth of a freshly-built page-0 image. The database
282/// header and the encryption header both live well inside this prefix.
283fn superblock_slot_image(page: &[u8; PAGE_SIZE]) -> [u8; SUPERBLOCK_SLOT_SIZE] {
284    let mut image = [0u8; SUPERBLOCK_SLOT_SIZE];
285    image.copy_from_slice(&page[..SUPERBLOCK_SLOT_SIZE]);
286    image
287}
288
289impl<F: VfsFile, W: WalBarrier> Pager<F, W> {
290    /// Use an already-owned file. The host retains exclusive ownership until
291    /// this pager is dropped, and supplies the native WAL/encryption policy.
292    /// `create` is explicit: a corrupt or empty existing file is never recreated.
293    pub fn open_owned(
294        file: F,
295        path: PathBuf,
296        config: PagerConfig,
297        wal: W,
298        create: bool,
299    ) -> Result<Self, PagerError> {
300        if create && file.file_len()? != 0 {
301            return Err(PagerError::InvalidDatabase(
302                "new pager file is not empty".into(),
303            ));
304        }
305        let pager = Self {
306            path,
307            file: Mutex::new(file),
308            dwb_enabled: config.double_write && !config.read_only,
309            writeback_failed: std::sync::atomic::AtomicBool::new(false),
310            cache: PageCache::new(config.cache_size),
311            freelist: RwLock::new(FreeList::new()),
312            deferred_reclamation: Mutex::new(DeferredPageReclamation::default()),
313            ownership_mutations: parking_lot::ReentrantMutex::new(()),
314            allocation_reused: std::sync::atomic::AtomicBool::new(false),
315            allocation_fence_pending: std::sync::atomic::AtomicBool::new(false),
316            header: RwLock::new(DatabaseHeader::default()),
317            superblock_publication: Mutex::new(()),
318            config,
319            header_dirty: Mutex::new(false),
320            wal,
321        };
322        if create {
323            pager.initialize()?;
324        } else {
325            pager.ensure_phase3_dwb_zone_layout()?;
326            pager.recover_from_dwb()?;
327            pager.load_header()?;
328        }
329        Ok(pager)
330    }
331
332    pub fn wal_barrier(&self) -> &W {
333        &self.wal
334    }
335
336    /// Hold across an entire ownership publication, not just individual
337    /// allocation calls. Readers may continue accessing already-published pages.
338    pub fn lock_ownership_mutations(&self) -> parking_lot::ReentrantMutexGuard<'_, ()> {
339        self.ownership_mutations.lock()
340    }
341
342    /// An interrupted publication must be recovered before any more writes.
343    pub fn fence_interrupted_ownership(&self) {
344        self.writeback_failed
345            .store(true, std::sync::atomic::Ordering::Release);
346    }
347
348    /// Run a host operation while holding the same lock as protected page I/O.
349    /// This is used only for native prefetch and fault-injection tests.
350    #[doc(hidden)]
351    pub fn with_file<T>(&self, operation: impl FnOnce(&mut F) -> T) -> Result<T, PagerError> {
352        Ok(operation(&mut *self.file_lock()?))
353    }
354
355    /// Initialize a new database
356    fn initialize(&self) -> Result<(), PagerError> {
357        if self.config.read_only {
358            return Err(PagerError::ReadOnly);
359        }
360
361        // Create header page. Page ids 1 and 2 are reserved for fixed
362        // metadata/vault pages. ADR 0038 phase 3 reserves the following DWB
363        // zone in-file so normal B-tree allocation starts after it.
364        let initial_page_count = FIRST_ALLOCATABLE_PAGE;
365        let header_page = Page::new_header_page(initial_page_count);
366        self.header_write()?.page_count = initial_page_count;
367
368        // Reserve page 0 (the superblock zone) plus the reserved pages so any
369        // scan over 0..page_count can read every allocated page in a brand-new
370        // database. The zone's own bytes are published below, one slot at a
371        // time; this first whole-page write only sizes the file.
372        self.write_page_raw(0, &header_page)?;
373        let mut metadata_page = Page::new(PageType::Header, 1);
374        metadata_page.update_checksum();
375        self.write_page_raw(1, &metadata_page)?;
376        let mut vault_page = Page::new(PageType::Vault, 2);
377        vault_page.update_checksum();
378        self.write_page_raw(2, &vault_page)?;
379        self.clear_dwb_zone()?;
380
381        // Publish both superblock copies so the ping-pong invariant — one
382        // valid copy always survives a crash — holds from the very first
383        // update onward, not just from the second.
384        let mut slot = superblock_slot_image(header_page.as_bytes());
385        self.write_superblock_slot(&mut slot, 0, 1)?;
386        self.write_superblock_slot(&mut slot, 1, 2)?;
387
388        // Sync to disk
389        self.sync()?;
390
391        Ok(())
392    }
393
394    /// Acquire a write lock on the header RwLock, mapping poison errors.
395    fn header_write(&self) -> Result<std::sync::RwLockWriteGuard<'_, DatabaseHeader>, PagerError> {
396        self.header.write().map_err(|_| PagerError::LockPoisoned)
397    }
398
399    /// Acquire a read lock on the header RwLock, mapping poison errors.
400    fn header_read(&self) -> Result<std::sync::RwLockReadGuard<'_, DatabaseHeader>, PagerError> {
401        self.header.read().map_err(|_| PagerError::LockPoisoned)
402    }
403
404    /// Acquire a write lock on the freelist RwLock, mapping poison errors.
405    fn freelist_write(&self) -> Result<std::sync::RwLockWriteGuard<'_, FreeList>, PagerError> {
406        self.freelist.write().map_err(|_| PagerError::LockPoisoned)
407    }
408
409    /// Acquire a lock on the file Mutex, mapping poison errors.
410    fn file_lock(&self) -> Result<std::sync::MutexGuard<'_, F>, PagerError> {
411        self.file.lock().map_err(|_| PagerError::LockPoisoned)
412    }
413
414    /// Acquire a lock on the header_dirty Mutex, mapping poison errors.
415    fn header_dirty_lock(&self) -> Result<std::sync::MutexGuard<'_, bool>, PagerError> {
416        self.header_dirty
417            .lock()
418            .map_err(|_| PagerError::LockPoisoned)
419    }
420
421    /// Read the whole superblock zone (both ping-pong copies) from the head of
422    /// the file. A file shorter than the zone yields zero-filled tail bytes,
423    /// which decode as "slot never written".
424    fn read_superblock_zone(&self) -> Result<[u8; SUPERBLOCK_ZONE_SIZE], PagerError> {
425        let mut file = self.file_lock()?;
426        Ok(pager_io::read_superblock_zone(&mut *file)?)
427    }
428
429    /// The newest valid superblock copy, with its slot image.
430    ///
431    /// Both copies invalid is the one unrecoverable superblock state (ADR 0074
432    /// §2): the store does not open, and the error names the zone and points
433    /// at salvage rather than panicking or silently re-initialising.
434    #[doc(hidden)]
435    pub fn newest_superblock(&self) -> Result<NewestSuperblock, PagerError> {
436        let zone = self.read_superblock_zone()?;
437        let selection = reddb_file::select_paged_superblock(&zone)
438            .ok_or_else(|| PagerError::SuperblockZoneUnrecoverable(self.path.clone()))?;
439
440        let start = selection.copy_index * SUPERBLOCK_SLOT_SIZE;
441        let mut image = [0u8; SUPERBLOCK_SLOT_SIZE];
442        image.copy_from_slice(&zone[start..start + SUPERBLOCK_SLOT_SIZE]);
443        Ok((selection, image))
444    }
445
446    fn ensure_phase3_dwb_zone_layout(&self) -> Result<(), PagerError> {
447        let (_, image) = self.newest_superblock()?;
448        let header = reddb_file::decode_database_header(&image)
449            .map_err(|err| PagerError::InvalidDatabase(err.to_string()))?;
450        if header.version < PHASE3_DWB_ZONE_FILE_VERSION {
451            return Err(PagerError::LegacyPagedStore {
452                path: self.path.clone(),
453            });
454        }
455        Ok(())
456    }
457
458    /// Seal `slot` for `copy_index`/`generation` and write exactly that slot,
459    /// then fsync. The caller guarantees `copy_index` is the stale copy.
460    fn write_superblock_slot(
461        &self,
462        slot: &mut [u8; SUPERBLOCK_SLOT_SIZE],
463        copy_index: usize,
464        generation: u64,
465    ) -> Result<(), PagerError> {
466        self.ensure_writeback_available()?;
467        if self.config.read_only {
468            return Err(PagerError::ReadOnly);
469        }
470        let mut file = self.file_lock()?;
471        self.ensure_writeback_available()?;
472        Ok(pager_io::write_superblock_slot(
473            &mut *file, slot, copy_index, generation,
474        )?)
475    }
476
477    /// Publish `mutate`'s edit of the newest slot image into the *stale* copy.
478    ///
479    /// Reading the newest image first means every byte the pager does not
480    /// explicitly rewrite — most importantly the encryption marker and header —
481    /// carries forward untouched.
482    #[doc(hidden)]
483    pub fn commit_superblock(
484        &self,
485        mutate: impl FnOnce(&mut [u8; SUPERBLOCK_SLOT_SIZE]) -> Result<(), PagerError>,
486    ) -> Result<(), PagerError> {
487        let _ownership = self.lock_ownership_mutations();
488        let _publication = self
489            .superblock_publication
490            .lock()
491            .map_err(|_| PagerError::LockPoisoned)?;
492        self.commit_superblock_locked(mutate)
493    }
494
495    fn commit_superblock_locked(
496        &self,
497        mutate: impl FnOnce(&mut [u8; SUPERBLOCK_SLOT_SIZE]) -> Result<(), PagerError>,
498    ) -> Result<(), PagerError> {
499        if self.config.read_only {
500            return Err(PagerError::ReadOnly);
501        }
502        let (newest, mut image) = self.newest_superblock()?;
503        mutate(&mut image)?;
504        let next_copy = reddb_file::paged_superblock_next_copy(Some(newest));
505        self.write_superblock_slot(&mut image, next_copy, newest.generation.saturating_add(1))
506    }
507
508    /// Fence a file-format upgrade in both recoverable superblock copies.
509    ///
510    /// The caller must exclude other mutators while the fence is in progress
511    /// and may publish a new mandatory format only after this succeeds. A
512    /// failed write fences subsequent mutation until reopen and recovery.
513    pub fn fence_file_format_version(&self, version: u32) -> Result<(), PagerError> {
514        let _ownership = self.lock_ownership_mutations();
515        self.ensure_writeback_available()?;
516        if self.config.read_only {
517            return Err(PagerError::ReadOnly);
518        }
519        if !(PHASE3_DWB_ZONE_FILE_VERSION..=reddb_file::PAGE_FILE_VERSION).contains(&version) {
520            return Err(PagerError::InvalidDatabase(
521                "unsupported file format fence".into(),
522            ));
523        }
524        let _publication = self
525            .superblock_publication
526            .lock()
527            .map_err(|_| PagerError::LockPoisoned)?;
528        if version < self.header_read()?.version {
529            return Err(PagerError::InvalidDatabase(
530                "file format downgrade is not supported".into(),
531            ));
532        }
533        let result = (|| {
534            for _ in 0..reddb_file::PAGED_SUPERBLOCK_SLOT_COUNT {
535                self.commit_superblock_locked(|image| {
536                    reddb_file::set_database_header_version(image, version)
537                        .map_err(|error| PagerError::InvalidDatabase(error.to_string()))
538                })?;
539            }
540            self.header_write()?.version = version;
541            Ok(())
542        })();
543        if result.is_err() {
544            self.writeback_failed
545                .store(true, std::sync::atomic::Ordering::Release);
546        }
547        result
548    }
549
550    /// Load the database header from the newest valid superblock copy.
551    fn load_header(&self) -> Result<(), PagerError> {
552        let (_, image) = self.newest_superblock()?;
553
554        let mut decoded_header = reddb_file::decode_database_header(&image)
555            .map_err(|err| PagerError::InvalidDatabase(err.to_string()))?;
556        // Cache eviction (or DWB recovery) can persist pages before the next
557        // superblock publication. After a crash the allocation counter may
558        // therefore lag behind live pages already in the file. Reserve the
559        // entire physical extent before allocating; never overwrite that tail.
560        // A partial last page is reserved too, without treating it as verified.
561        let physical_pages = self.file_lock()?.file_len()?.div_ceil(PAGE_SIZE as u64);
562        let physical_pages = u32::try_from(physical_pages).map_err(|_| {
563            PagerError::InvalidDatabase("data file exceeds the page address space".into())
564        })?;
565        if decoded_header.page_count < physical_pages {
566            decoded_header.page_count = physical_pages;
567            if !self.config.read_only {
568                *self.header_dirty_lock()? = true;
569            }
570        }
571        let freelist_head = decoded_header.freelist_head;
572
573        {
574            let mut header = self.header_write()?;
575            *header = decoded_header;
576        }
577
578        // Initialize freelist
579        {
580            let mut freelist = self.freelist_write()?;
581            *freelist = FreeList::from_header(freelist_head, 0);
582        }
583
584        Ok(())
585    }
586
587    /// Publish the in-memory database header as a new superblock generation.
588    fn write_header(&self) -> Result<(), PagerError> {
589        if self.config.read_only {
590            return Err(PagerError::ReadOnly);
591        }
592
593        let header = self.header_read()?.clone();
594        self.commit_superblock(|image| {
595            reddb_file::encode_database_header(image, &header)
596                .map_err(|err| PagerError::InvalidDatabase(err.to_string()))
597        })?;
598        *self.header_dirty_lock()? = false;
599
600        Ok(())
601    }
602
603    /// Read a page from disk (bypassing cache)
604    fn read_page_raw(&self, page_id: u32) -> Result<Page, PagerError> {
605        let mut file = self.file_lock()?;
606        Ok(pager_io::read_page(
607            &mut *file,
608            page_id,
609            self.config.verify_checksums,
610        )?)
611    }
612
613    fn ensure_writeback_available(&self) -> Result<(), PagerError> {
614        if self
615            .writeback_failed
616            .load(std::sync::atomic::Ordering::Acquire)
617        {
618            return Err(PagerError::Io(std::io::Error::other(
619                "page writeback failed; reopen the database to recover before writing",
620            )));
621        }
622        Ok(())
623    }
624
625    fn record_ownership(
626        &self,
627        operation: impl FnOnce(&W) -> std::io::Result<()>,
628    ) -> Result<(), PagerError> {
629        if let Err(error) = operation(&self.wal) {
630            self.writeback_failed
631                .store(true, std::sync::atomic::Ordering::Release);
632            return Err(PagerError::Io(error));
633        }
634        Ok(())
635    }
636
637    /// Amortize protected eviction over one bounded DWB frame. Keeping the
638    /// remaining cache dirty would otherwise pay three fsyncs per victim.
639    fn write_evicted_dirty_page(&self, page: Page) -> Result<(), PagerError> {
640        let mut ids = self
641            .cache
642            .dirty_page_ids(pager_io::DWB_MAX_PAGES_PER_FRAME.saturating_sub(1));
643        ids.sort_unstable();
644        let mut pages = self.cache.flush_dirty_pages(&ids);
645        pages.push((page.page_id(), page));
646        pages.sort_unstable_by_key(|(id, _)| *id);
647        self.write_dirty_page_batch(&pages)
648    }
649
650    /// Write a page to disk (bypassing cache)
651    fn write_page_raw(&self, page_id: u32, page: &Page) -> Result<(), PagerError> {
652        self.ensure_writeback_available()?;
653        if self.config.read_only {
654            return Err(PagerError::ReadOnly);
655        }
656        let mut file = self.file_lock()?;
657        self.ensure_writeback_available()?;
658        Ok(pager_io::write_page(&mut *file, page_id, page)?)
659    }
660
661    /// Read a page (cache-aware)
662    pub fn read_page(&self, page_id: u32) -> Result<Page, PagerError> {
663        // Check cache first
664        if let Some(page) = self.cache.get(page_id) {
665            return Ok(page);
666        }
667
668        // Cache miss - read from disk
669        let page = self.read_page_raw(page_id)?;
670
671        // Add to cache
672        if let Some(dirty_page) = self.cache.insert(page_id, page.clone()) {
673            // Evicted page was dirty, need to write it back
674            self.write_evicted_dirty_page(dirty_page)?;
675        }
676
677        Ok(page)
678    }
679
680    /// Read a page without verifying checksum (for encrypted pages)
681    ///
682    /// Use this when the page content has its own integrity protection
683    /// (e.g., AES-GCM authentication tag for encrypted pages).
684    pub fn read_page_no_checksum(&self, page_id: u32) -> Result<Page, PagerError> {
685        // Check cache first
686        if let Some(page) = self.cache.get(page_id) {
687            return Ok(page);
688        }
689
690        // Cache miss - use the same byte I/O without a page CRC check.
691        let mut file = self.file_lock()?;
692        let page = pager_io::read_page(&mut *file, page_id, false)?;
693        drop(file);
694
695        // Add to cache (no checksum verification)
696        if let Some(dirty_page) = self.cache.insert(page_id, page.clone()) {
697            // Evicted page was dirty, need to write it back
698            self.write_evicted_dirty_page(dirty_page)?;
699        }
700
701        Ok(page)
702    }
703
704    /// Write a page (cache-aware)
705    pub fn write_page(&self, page_id: u32, mut page: Page) -> Result<(), PagerError> {
706        let _ownership = self.lock_ownership_mutations();
707        self.ensure_writeback_available()?;
708        if self.config.read_only {
709            return Err(PagerError::ReadOnly);
710        }
711
712        self.capture_page_before_write(page_id)?;
713
714        // Update checksum
715        page.update_checksum();
716
717        // Add to cache and mark dirty. If the cache had to evict a
718        // dirty entry, write it through immediately — the evicted
719        // page will never be flushed otherwise (same bug fixed in
720        // `allocate_page`).
721        if let Some(dirty_page) = self.cache.insert(page_id, page) {
722            self.write_evicted_dirty_page(dirty_page)?;
723        }
724        self.cache.mark_dirty(page_id);
725
726        Ok(())
727    }
728
729    /// Write a page without updating checksum (for encrypted pages)
730    ///
731    /// Use this when the page content has its own integrity protection
732    /// (e.g., AES-GCM authentication tag for encrypted pages).
733    pub fn write_page_no_checksum(&self, page_id: u32, page: Page) -> Result<(), PagerError> {
734        let _ownership = self.lock_ownership_mutations();
735        self.ensure_writeback_available()?;
736        if self.config.read_only {
737            return Err(PagerError::ReadOnly);
738        }
739
740        self.capture_page_before_write(page_id)?;
741
742        // Add to cache and mark dirty (no checksum update). Same
743        // eviction-write-through guard as `write_page`.
744        if let Some(dirty_page) = self.cache.insert(page_id, page) {
745            self.write_evicted_dirty_page(dirty_page)?;
746        }
747        self.cache.mark_dirty(page_id);
748
749        Ok(())
750    }
751
752    fn capture_page_before_write(&self, page_id: u32) -> Result<(), PagerError> {
753        let mut required = false;
754        self.record_ownership(|wal| {
755            required = wal.needs_page_image(page_id)?;
756            Ok(())
757        })?;
758        if required {
759            let previous = self.read_page(page_id)?;
760            self.record_ownership(|wal| wal.capture_page_image(&previous))?;
761        }
762        Ok(())
763    }
764
765    /// Allocate a new page
766    pub fn allocate_page(&self, page_type: PageType) -> Result<Page, PagerError> {
767        let _ownership = self.lock_ownership_mutations();
768        self.ensure_writeback_available()?;
769        if self.config.read_only {
770            return Err(PagerError::ReadOnly);
771        }
772
773        // Try to get from freelist first
774        let page_id = {
775            let mut freelist = self.freelist_write()?;
776            // A previous fence can fail after loading the trunk into memory.
777            // Retry it before the fast path can consume any of those pages.
778            self.complete_allocation_fence()?;
779            if let Some(id) = freelist.next_in_memory_page() {
780                self.record_ownership(|wal| wal.reserve_pages(id, 1))?;
781                let id = freelist
782                    .allocate()
783                    .expect("free page checked while allocator is locked");
784                self.allocation_reused
785                    .store(true, std::sync::atomic::Ordering::Release);
786                id
787            } else if freelist.trunk_head() != 0 {
788                let trunk_id = freelist.trunk_head();
789
790                let trunk = self.read_page(trunk_id).map_err(|e| match e {
791                    PagerError::PageNotFound(_) => {
792                        PagerError::InvalidDatabase("Freelist trunk missing".to_string())
793                    }
794                    other => other,
795                })?;
796                if trunk.page_id() != trunk_id {
797                    return Err(PagerError::InvalidDatabase(
798                        "misdirected freelist trunk".into(),
799                    ));
800                }
801
802                // Persist ownership of every page detached from the trunk
803                // before either superblock stops naming that trunk.
804                let mut detached = FreeList::new();
805                detached
806                    .load_from_trunk(&trunk)
807                    .map_err(|e| PagerError::InvalidDatabase(format!("Freelist: {e}")))?;
808                let mut pages = Vec::new();
809                let mut seen = std::collections::HashSet::new();
810                let page_count = self.page_count()?;
811                while let Some(id) = detached.allocate() {
812                    if id < FIRST_ALLOCATABLE_PAGE || id >= page_count || !seen.insert(id) {
813                        return Err(PagerError::InvalidDatabase(
814                            "invalid freelist trunk page".into(),
815                        ));
816                    }
817                    pages.push(id);
818                }
819                let mut journaled = false;
820                self.record_ownership(|wal| {
821                    journaled = wal.has_page_ownership_journal()?;
822                    Ok(())
823                })?;
824                if !journaled {
825                    self.allocate_unjournaled_trunk(&mut freelist, trunk, &pages)?
826                } else {
827                    self.record_ownership(|wal| wal.detach_free_pages(&pages))?;
828
829                    freelist
830                        .load_from_trunk(&trunk)
831                        .map_err(|e| PagerError::InvalidDatabase(format!("Freelist: {}", e)))?;
832                    let mut header = self.header_write()?;
833                    header.freelist_head = freelist.trunk_head();
834                    *self.header_dirty_lock()? = true;
835                    drop(header);
836                    // The consumed trunk itself becomes reusable. Forget its old
837                    // allocator role durably before cache eviction can overwrite it.
838                    // Keep the allocator locked so another allocator cannot race
839                    // ahead of this fence. A failed fence leaves allocation stopped.
840                    self.allocation_fence_pending
841                        .store(true, std::sync::atomic::Ordering::Release);
842                    self.complete_allocation_fence()?;
843                    let next = freelist.next_in_memory_page().ok_or_else(|| {
844                        PagerError::InvalidDatabase("Freelist empty after trunk load".to_string())
845                    })?;
846                    self.record_ownership(|wal| wal.reserve_pages(next, 1))?;
847                    let id = freelist.allocate().ok_or_else(|| {
848                        PagerError::InvalidDatabase("Freelist empty after trunk load".to_string())
849                    })?;
850                    self.allocation_reused
851                        .store(true, std::sync::atomic::Ordering::Release);
852                    id
853                }
854            } else {
855                // No free pages, extend file
856                let mut header = self.header_write()?;
857                let id = header.page_count;
858                let next = id
859                    .checked_add(1)
860                    .ok_or_else(|| PagerError::InvalidDatabase("page count overflow".into()))?;
861                self.record_ownership(|wal| wal.reserve_pages(id, 1))?;
862                header.page_count = next;
863                *self.header_dirty_lock()? = true;
864                id
865            }
866        };
867
868        let page = Page::new(page_type, page_id);
869
870        // Write to cache. The evicted page (if any) is dirty by
871        // definition — `cache.insert` only returns `Some` when it
872        // had to evict a dirty entry to make room. The previous
873        // version dropped that return value, which silently lost
874        // writes whenever a freshly-allocated page caused an LRU
875        // eviction. This shows up under heavy ingest as
876        // "B-tree insert error: Pager error: I/O error: failed to
877        // fill whole buffer" later, when something tries to read
878        // back the never-flushed page. Mirror `read_page`'s
879        // handling: write the evicted page through immediately so
880        // the on-disk image stays consistent.
881        if let Some(dirty_page) = self.cache.insert(page_id, page.clone()) {
882            self.write_evicted_dirty_page(dirty_page)?;
883        }
884        self.cache.mark_dirty(page_id);
885
886        Ok(page)
887    }
888
889    /// Keep untouched free IDs reachable on disk when no ownership epoch can
890    /// recover a detached trunk. Removing one ID costs one protected page write;
891    /// checkpoint/replay epochs retain their batched, journaled allocation path.
892    fn allocate_unjournaled_trunk(
893        &self,
894        freelist: &mut FreeList,
895        mut trunk: Page,
896        pages: &[u32],
897    ) -> Result<u32, PagerError> {
898        let id = *pages
899            .first()
900            .expect("validated trunk includes its own page");
901        self.record_ownership(|wal| wal.reserve_pages(id, 1))?;
902        if pages.len() > 1 {
903            let remaining =
904                u32::try_from(pages.len() - 2).expect("a validated trunk fits in one page");
905            trunk.content_mut()[4..8].copy_from_slice(&remaining.to_le_bytes());
906            trunk.update_checksum();
907            self.cache.remove(trunk.page_id());
908            self.write_pages_through_dwb(&[(trunk.page_id(), trunk)])?;
909            freelist.note_durable_trunk_allocation(remaining);
910        } else {
911            // The empty trunk itself is the last available page. Fence both
912            // superblocks before it can be overwritten as user data.
913            freelist
914                .load_from_trunk(&trunk)
915                .map_err(|error| PagerError::InvalidDatabase(error.to_string()))?;
916            self.header_write()?.freelist_head = freelist.trunk_head();
917            *self.header_dirty_lock()? = true;
918            self.allocation_fence_pending
919                .store(true, std::sync::atomic::Ordering::Release);
920            self.complete_allocation_fence()?;
921            assert_eq!(freelist.allocate(), Some(id));
922        }
923        self.allocation_reused
924            .store(true, std::sync::atomic::Ordering::Release);
925        Ok(id)
926    }
927
928    /// Reserve a contiguous extent of vector pages.
929    pub fn reserve_contig_extent(&self, n_pages: u32) -> Result<ExtentId, PagerError> {
930        let _ownership = self.lock_ownership_mutations();
931        self.ensure_writeback_available()?;
932        if self.config.read_only {
933            return Err(PagerError::ReadOnly);
934        }
935        if n_pages == 0 {
936            return Err(PagerError::InvalidDatabase(
937                "contiguous extent must reserve at least one page".to_string(),
938            ));
939        }
940
941        let start_page = {
942            let mut header = self.header_write()?;
943            let start = header.page_count;
944            let end = header.page_count.checked_add(n_pages).ok_or_else(|| {
945                PagerError::InvalidDatabase("contiguous extent page count overflow".to_string())
946            })?;
947            self.record_ownership(|wal| wal.reserve_pages(start, n_pages))?;
948            header.page_count = end;
949            *self.header_dirty_lock()? = true;
950            start
951        };
952
953        for page_id in start_page..start_page + n_pages {
954            let mut page = Page::new(PageType::Vector, page_id);
955            page.update_checksum();
956            if let Some(dirty_page) = self.cache.insert(page_id, page) {
957                self.write_evicted_dirty_page(dirty_page)?;
958            }
959            self.cache.mark_dirty(page_id);
960        }
961
962        Ok(ExtentId {
963            start_page,
964            n_pages,
965        })
966    }
967
968    /// Free a page (return to freelist)
969    pub fn free_page(&self, page_id: u32) -> Result<(), PagerError> {
970        let _ownership = self.lock_ownership_mutations();
971        self.ensure_writeback_available()?;
972        if self.config.read_only {
973            return Err(PagerError::ReadOnly);
974        }
975
976        let mut deferred = self
977            .deferred_reclamation
978            .lock()
979            .map_err(|_| PagerError::LockPoisoned)?;
980        if deferred.enabled {
981            if page_id < FIRST_ALLOCATABLE_PAGE || page_id >= self.page_count()? {
982                return Err(PagerError::InvalidDatabase(
983                    "invalid deferred free page".into(),
984                ));
985            }
986            self.record_ownership(|wal| wal.retire_pages(&[page_id]))?;
987            // Keep dirty forwarding/empty nodes until publication. An
988            // incidental flush must leave the previous root traversable.
989            deferred.pending.insert(page_id);
990            return Ok(());
991        }
992        drop(deferred);
993
994        self.record_ownership(|wal| wal.retire_pages(&[page_id]))?;
995
996        // Remove from cache
997        self.cache.remove(page_id);
998
999        // Add to freelist
1000        let mut freelist = self.freelist_write()?;
1001        freelist.free(page_id);
1002
1003        *self.header_dirty_lock()? = true;
1004
1005        Ok(())
1006    }
1007
1008    pub fn enable_deferred_reclamation(&self) -> Result<(), PagerError> {
1009        let _ownership = self.lock_ownership_mutations();
1010        self.deferred_reclamation
1011            .lock()
1012            .map_err(|_| PagerError::LockPoisoned)?
1013            .enabled = true;
1014        Ok(())
1015    }
1016
1017    pub fn deferred_reclamation_pages(&self) -> Result<(Vec<u32>, Vec<u32>), PagerError> {
1018        let deferred = self
1019            .deferred_reclamation
1020            .lock()
1021            .map_err(|_| PagerError::LockPoisoned)?;
1022        Ok((
1023            deferred.pending.iter().copied().collect(),
1024            deferred.published.iter().copied().collect(),
1025        ))
1026    }
1027
1028    pub fn restore_deferred_reclamation(&self, pages: &[u32]) -> Result<(), PagerError> {
1029        let _ownership = self.lock_ownership_mutations();
1030        let count = self.page_count()?;
1031        if pages
1032            .iter()
1033            .any(|id| *id < FIRST_ALLOCATABLE_PAGE || *id >= count)
1034        {
1035            return Err(PagerError::InvalidDatabase(
1036                "invalid persisted deferred free page".into(),
1037            ));
1038        }
1039        let mut deferred = self
1040            .deferred_reclamation
1041            .lock()
1042            .map_err(|_| PagerError::LockPoisoned)?;
1043        deferred.pending.extend(pages);
1044        deferred.published.extend(pages);
1045        Ok(())
1046    }
1047
1048    /// The caller has durably published a catalog that retains `retained`
1049    /// and no longer names `released`, after waiting for old tree readers.
1050    pub fn complete_deferred_publication(
1051        &self,
1052        retained: &[u32],
1053        released: &[u32],
1054    ) -> Result<(), PagerError> {
1055        let _ownership = self.lock_ownership_mutations();
1056        let mut deferred = self
1057            .deferred_reclamation
1058            .lock()
1059            .map_err(|_| PagerError::LockPoisoned)?;
1060        if !released.is_empty() {
1061            self.free_retired_pages(released)?;
1062        }
1063        for id in released {
1064            deferred.pending.remove(id);
1065        }
1066        deferred.published = retained.iter().copied().collect();
1067        Ok(())
1068    }
1069
1070    /// Record the pages whose catalog ownership is about to be retired.
1071    pub fn record_retirement_intent(&self, pages: &[u32]) -> Result<(), PagerError> {
1072        let _ownership = self.lock_ownership_mutations();
1073        self.ensure_writeback_available()?;
1074        if self.config.read_only {
1075            return Err(PagerError::ReadOnly);
1076        }
1077        let page_count = self.page_count()?;
1078        let mut seen = std::collections::HashSet::with_capacity(pages.len());
1079        if pages
1080            .iter()
1081            .any(|id| *id < FIRST_ALLOCATABLE_PAGE || *id >= page_count || !seen.insert(*id))
1082        {
1083            return Err(PagerError::InvalidDatabase(
1084                "invalid retirement intent".into(),
1085            ));
1086        }
1087        self.record_ownership(|wal| wal.retire_pages(pages))
1088    }
1089
1090    /// Return a fully validated, unpublished tree generation in one allocator
1091    /// mutation. Call only after its removal from the manifest is durable.
1092    pub fn free_retired_pages(&self, pages: &[u32]) -> Result<(), PagerError> {
1093        let _ownership = self.lock_ownership_mutations();
1094        self.ensure_writeback_available()?;
1095        if self.config.read_only {
1096            return Err(PagerError::ReadOnly);
1097        }
1098        let page_count = self.page_count()?;
1099        if pages
1100            .iter()
1101            .any(|id| *id < FIRST_ALLOCATABLE_PAGE || *id >= page_count)
1102        {
1103            return Err(PagerError::InvalidDatabase(
1104                "invalid retired page range".into(),
1105            ));
1106        }
1107        let mut seen = std::collections::HashSet::with_capacity(pages.len());
1108        if pages.iter().any(|id| !seen.insert(*id)) {
1109            return Err(PagerError::InvalidDatabase("duplicate retired page".into()));
1110        }
1111        let mut freelist = self.freelist_write()?;
1112        if freelist.contains_any(&seen) {
1113            return Err(PagerError::InvalidDatabase(
1114                "retired page is already free".into(),
1115            ));
1116        }
1117        // Some free pages remain in on-disk trunks until allocation reaches
1118        // them. Check those too before changing the cache or the allocator.
1119        // The allocator lock prevents concurrent consumption or publication.
1120        let mut trunk_id = freelist.trunk_head();
1121        let mut visited = std::collections::HashSet::new();
1122        while trunk_id != 0 && !seen.is_empty() {
1123            if trunk_id < FIRST_ALLOCATABLE_PAGE
1124                || trunk_id >= page_count
1125                || !visited.insert(trunk_id)
1126            {
1127                return Err(PagerError::InvalidDatabase(
1128                    "invalid freelist trunk chain".into(),
1129                ));
1130            }
1131            let trunk = self.read_page(trunk_id)?;
1132            if trunk.page_id() != trunk_id {
1133                return Err(PagerError::InvalidDatabase(
1134                    "misdirected freelist trunk".into(),
1135                ));
1136            }
1137            let mut loaded = FreeList::new();
1138            let next = loaded
1139                .load_from_trunk(&trunk)
1140                .map_err(|error| PagerError::InvalidDatabase(format!("Freelist: {error}")))?;
1141            while let Some(id) = loaded.allocate() {
1142                if id < FIRST_ALLOCATABLE_PAGE || id >= page_count {
1143                    return Err(PagerError::InvalidDatabase(
1144                        "invalid free page range".into(),
1145                    ));
1146                }
1147                if seen.contains(&id) {
1148                    return Err(PagerError::InvalidDatabase(
1149                        "retired page is already free".into(),
1150                    ));
1151                }
1152            }
1153            trunk_id = next.unwrap_or(0);
1154        }
1155        self.record_ownership(|wal| wal.retire_pages(pages))?;
1156        for id in pages {
1157            self.cache.remove(*id);
1158        }
1159        freelist.free_batch(pages);
1160        *self.header_dirty_lock()? = true;
1161        Ok(())
1162    }
1163
1164    fn pages_not_in_freelist(
1165        &self,
1166        pages: &[u32],
1167        freelist: &FreeList,
1168        page_count: u32,
1169    ) -> Result<std::collections::HashSet<u32>, PagerError> {
1170        let mut remaining = std::collections::HashSet::with_capacity(pages.len());
1171        if pages
1172            .iter()
1173            .any(|id| *id < FIRST_ALLOCATABLE_PAGE || *id >= page_count || !remaining.insert(*id))
1174        {
1175            return Err(PagerError::InvalidDatabase(
1176                "invalid recovery page set".into(),
1177            ));
1178        }
1179        let requested = remaining.clone();
1180        freelist
1181            .remove_existing_from(&requested, &mut remaining)
1182            .map_err(|error| PagerError::InvalidDatabase(format!("Freelist: {error}")))?;
1183        let mut trunk_id = freelist.trunk_head();
1184        let mut visited = std::collections::HashSet::new();
1185        while trunk_id != 0 {
1186            if trunk_id < FIRST_ALLOCATABLE_PAGE
1187                || trunk_id >= page_count
1188                || !visited.insert(trunk_id)
1189            {
1190                return Err(PagerError::InvalidDatabase(
1191                    "invalid freelist trunk chain".into(),
1192                ));
1193            }
1194            let trunk = self.read_page(trunk_id)?;
1195            if trunk.page_id() != trunk_id {
1196                return Err(PagerError::InvalidDatabase(
1197                    "misdirected freelist trunk".into(),
1198                ));
1199            }
1200            let mut loaded = FreeList::new();
1201            let next = loaded
1202                .load_from_trunk(&trunk)
1203                .map_err(|error| PagerError::InvalidDatabase(format!("Freelist: {error}")))?;
1204            while let Some(id) = loaded.allocate() {
1205                if id < FIRST_ALLOCATABLE_PAGE || id >= page_count {
1206                    return Err(PagerError::InvalidDatabase(
1207                        "invalid free page range".into(),
1208                    ));
1209                }
1210                if requested.contains(&id) && !remaining.remove(&id) {
1211                    return Err(PagerError::InvalidDatabase(
1212                        "duplicate recovery page".into(),
1213                    ));
1214                }
1215            }
1216            trunk_id = next.unwrap_or(0);
1217        }
1218        Ok(remaining)
1219    }
1220
1221    /// Add proven-unowned pages to the allocator exactly once during recovery.
1222    /// The caller must exclude all allocation and publication until this state
1223    /// has been synced to both superblocks and the journal is completed.
1224    pub fn recover_unowned_pages(&self, pages: &[u32]) -> Result<(), PagerError> {
1225        let _ownership = self.lock_ownership_mutations();
1226        self.ensure_writeback_available()?;
1227        if self.config.read_only {
1228            return Err(PagerError::ReadOnly);
1229        }
1230        let page_count = self.page_count()?;
1231        let mut freelist = self.freelist_write()?;
1232        let remaining = self.pages_not_in_freelist(pages, &freelist, page_count)?;
1233        if !remaining.is_empty() {
1234            let mut missing: Vec<_> = remaining.into_iter().collect();
1235            missing.sort_unstable();
1236            for id in &missing {
1237                self.cache.remove(*id);
1238            }
1239            freelist.free_batch(&missing);
1240            *self.header_dirty_lock()? = true;
1241        }
1242        Ok(())
1243    }
1244
1245    /// Reject a recovery plan if any page it must preserve is already free.
1246    /// The caller must exclude allocation and freelist mutation during recovery.
1247    pub fn validate_owned_pages(&self, pages: &[u32]) -> Result<(), PagerError> {
1248        let page_count = self.page_count()?;
1249        let freelist = self.freelist_write()?;
1250        let absent = self.pages_not_in_freelist(pages, &freelist, page_count)?;
1251        if absent.len() != pages.len() {
1252            return Err(PagerError::InvalidDatabase(
1253                "owned recovery page is free".into(),
1254            ));
1255        }
1256        Ok(())
1257    }
1258
1259    /// Persist the current allocator in both fallback copies during recovery.
1260    /// The caller must exclude allocation until journal completion is durable.
1261    pub fn sync_recovered_allocator(&self) -> Result<(), PagerError> {
1262        let _ownership = self.lock_ownership_mutations();
1263        let result = (|| {
1264            self.sync()?;
1265            let _publication = self
1266                .superblock_publication
1267                .lock()
1268                .map_err(|_| PagerError::LockPoisoned)?;
1269            let header = self.header_read()?.clone();
1270            for _ in 0..reddb_file::PAGED_SUPERBLOCK_SLOT_COUNT {
1271                self.commit_superblock_locked(|image| {
1272                    reddb_file::encode_database_header(image, &header)
1273                        .map_err(|error| PagerError::InvalidDatabase(error.to_string()))
1274                })?;
1275            }
1276            Ok(())
1277        })();
1278        if result.is_err() {
1279            self.writeback_failed
1280                .store(true, std::sync::atomic::Ordering::Release);
1281        }
1282        result
1283    }
1284
1285    /// Get database header
1286    /// Pages available to the allocator, including pages on unloaded trunks.
1287    pub fn free_page_count(&self) -> Result<u32, PagerError> {
1288        Ok(self.freelist_write()?.total_free())
1289    }
1290
1291    pub fn header(&self) -> Result<DatabaseHeader, PagerError> {
1292        Ok(self.header_read()?.clone())
1293    }
1294
1295    pub fn physical_header(&self) -> Result<PhysicalFileHeader, PagerError> {
1296        Ok(self.header_read()?.physical)
1297    }
1298
1299    pub fn update_physical_header(&self, physical: PhysicalFileHeader) -> Result<(), PagerError> {
1300        let _ownership = self.lock_ownership_mutations();
1301        self.ensure_writeback_available()?;
1302        if self.config.read_only {
1303            return Err(PagerError::ReadOnly);
1304        }
1305
1306        let mut header = self.header_write()?;
1307        header.physical = physical;
1308        *self.header_dirty_lock()? = true;
1309        Ok(())
1310    }
1311
1312    /// Get page count
1313    pub fn page_count(&self) -> Result<u32, PagerError> {
1314        Ok(self.header_read()?.page_count)
1315    }
1316
1317    /// Flush all dirty pages to disk
1318    pub fn flush(&self) -> Result<(), PagerError> {
1319        let _ownership = self.lock_ownership_mutations();
1320        self.ensure_writeback_available()?;
1321        if self.config.read_only {
1322            return Ok(());
1323        }
1324
1325        // Persist freelist to trunk pages when dirty
1326        let trunks = {
1327            let mut freelist = self.freelist_write()?;
1328            if freelist.is_dirty() {
1329                let mut header = self.header_write()?;
1330                let trunks = freelist.flush_to_trunks(0, || {
1331                    let id = header.page_count;
1332                    header.page_count += 1;
1333                    id
1334                });
1335                header.freelist_head = freelist.trunk_head();
1336                *self.header_dirty_lock()? = true;
1337                freelist.mark_clean();
1338                trunks
1339            } else {
1340                Vec::new()
1341            }
1342        };
1343
1344        let mut dirty_pages = Vec::new();
1345        for trunk in trunks {
1346            let page_id = trunk.page_id();
1347            if let Some(evicted) = self.cache.insert(page_id, trunk) {
1348                // Trunk publication can evict dirty data. Keep it in the same
1349                // WAL-first, double-write-protected batch as resident pages.
1350                dirty_pages.push((evicted.page_id(), evicted));
1351            }
1352            self.cache.mark_dirty(page_id);
1353        }
1354
1355        // IDs are compact; page images are not. At 16 KiB per page, staging
1356        // 256 images plus one DWB frame uses about 5 MiB, independently of
1357        // the dirty cache size. Publish the header only after every batch.
1358        const FLUSH_BATCH_PAGES: usize = 256;
1359        for batch in dirty_pages.chunks(FLUSH_BATCH_PAGES) {
1360            self.write_dirty_page_batch(batch)?;
1361        }
1362        drop(dirty_pages);
1363        // Batch dirty IDs, not every resident ID: otherwise a few edits in
1364        // a mostly clean cache pay a protected write per cache-sized slice.
1365        // Physical order also avoids arbitrary seeks among each batch's homes.
1366        let mut page_ids = self.cache.dirty_page_ids(usize::MAX);
1367        page_ids.sort_unstable();
1368        for ids in page_ids.chunks(FLUSH_BATCH_PAGES) {
1369            let pages = self.cache.flush_dirty_pages(ids);
1370            self.write_dirty_page_batch(&pages)?;
1371        }
1372
1373        // Write header if dirty
1374        if *self.header_dirty_lock()? {
1375            self.write_header()?;
1376        }
1377
1378        Ok(())
1379    }
1380
1381    fn write_dirty_page_batch(&self, pages: &[(u32, Page)]) -> Result<(), PagerError> {
1382        if pages.is_empty() {
1383            return Ok(());
1384        }
1385        // Every image's WAL must be durable before its home write, including
1386        // dirty data evicted while publishing freelist trunks. LSN zero is
1387        // reserved for pages not produced by a WAL append.
1388        let result = (|| {
1389            let max_lsn = pages
1390                .iter()
1391                .filter_map(|(_, page)| page.header().ok().map(|header| header.lsn))
1392                .max()
1393                .unwrap_or(0);
1394            if max_lsn > 0 {
1395                self.wal.flush_until(max_lsn)?;
1396            }
1397            self.write_pages_through_dwb(pages)
1398        })();
1399        if result.is_err() {
1400            self.writeback_failed
1401                .store(true, std::sync::atomic::Ordering::Release);
1402            for (page_id, _) in pages {
1403                self.cache.mark_dirty(*page_id);
1404            }
1405        }
1406        result
1407    }
1408
1409    /// Sync file to disk (fsync)
1410    pub fn sync(&self) -> Result<(), PagerError> {
1411        let _ownership = self.lock_ownership_mutations();
1412        self.flush()?;
1413
1414        if self
1415            .allocation_reused
1416            .swap(false, std::sync::atomic::Ordering::AcqRel)
1417        {
1418            // flush published the current allocator in one slot. Mirror that
1419            // ownership before callers publish roots that use reclaimed pages;
1420            // recovery through the sibling slot must never allocate them again.
1421            if let Err(error) = self.write_header_and_sync() {
1422                self.allocation_reused
1423                    .store(true, std::sync::atomic::Ordering::Release);
1424                return Err(error);
1425            }
1426        }
1427
1428        let file = self.file_lock()?;
1429        file.sync_all()?;
1430
1431        Ok(())
1432    }
1433
1434    /// Get cache statistics
1435    pub fn cache_stats(&self) -> super::page_cache::CacheStats {
1436        self.cache.stats()
1437    }
1438
1439    /// Pages currently resident in the page cache. Slots are fixed size, so
1440    /// this times the page size is the cache's memory footprint (ADR 0073 §2).
1441    pub fn cache_len(&self) -> usize {
1442        self.cache.len()
1443    }
1444
1445    /// Preallocated page-cache slots — the sizing this process derived from
1446    /// its page-cache budget share.
1447    pub fn cache_capacity(&self) -> usize {
1448        self.cache.capacity()
1449    }
1450
1451    /// Count dirty pages currently in the page cache.
1452    pub fn dirty_page_count(&self) -> usize {
1453        self.cache.dirty_count()
1454    }
1455
1456    /// Estimated fraction of the page cache holding dirty pages.
1457    /// Returned in `[0, 1]`. Used by the background writer to
1458    /// decide when to kick in aggressive flushing.
1459    pub fn dirty_fraction(&self) -> f64 {
1460        let capacity = self.cache.capacity().max(1) as f64;
1461        self.cache.dirty_count() as f64 / capacity
1462    }
1463
1464    /// Flush up to `max` dirty pages from the cache. Returns the
1465    /// number actually written. Background-writer entry point —
1466    /// reuses the same persistence path as `flush()` but bounded.
1467    pub fn flush_some_dirty(&self, max: usize) -> Result<usize, PagerError> {
1468        let _ownership = self.lock_ownership_mutations();
1469        self.ensure_writeback_available()?;
1470        if self.config.read_only || max == 0 {
1471            return Ok(0);
1472        }
1473        let dirty_pages = self.cache.flush_some_dirty(max);
1474        if dirty_pages.is_empty() {
1475            return Ok(0);
1476        }
1477        let count = dirty_pages.len();
1478        // WAL-first: every cached dirty page carries an LSN that the
1479        // WAL must have already persisted. The full `flush()` path
1480        // enforces this with `wal.flush(max_lsn)`; here we simply
1481        // write through the pager — safe because callers only reach
1482        // this path via the bgwriter, which runs asynchronously
1483        // alongside normal commits that already respect WAL-first.
1484        for (page_id, page) in dirty_pages {
1485            self.write_page(page_id, page)?;
1486        }
1487        Ok(count)
1488    }
1489
1490    /// Get database file path
1491    pub fn path(&self) -> &Path {
1492        &self.path
1493    }
1494
1495    /// Check if database is read-only
1496    pub fn is_read_only(&self) -> bool {
1497        self.config.read_only
1498    }
1499
1500    /// Get file size in bytes
1501    pub fn file_size(&self) -> Result<u64, PagerError> {
1502        let file = self.file_lock()?;
1503        Ok(file.file_len()?)
1504    }
1505
1506    /// Clear the in-file DWB zone with valid reserved pages.
1507    fn clear_dwb_zone(&self) -> Result<(), PagerError> {
1508        let mut file = self.file_lock()?;
1509        Ok(pager_io::clear_dwb_zone(&mut *file)?)
1510    }
1511
1512    /// Read the internal manifest page (page 1), the zone the superblock roots.
1513    ///
1514    /// ADR 0074 §2: manifest corruption fails the open didactically, naming the
1515    /// zone — it never falls back to a sidecar and never returns garbage.
1516    pub fn read_manifest_page(&self) -> Result<Page, PagerError> {
1517        self.read_page(1)
1518            .map_err(|_| PagerError::ManifestZoneCorrupt(self.path.clone()))
1519    }
1520
1521    /// Write pages through the double-write buffer for torn page protection.
1522    ///
1523    /// 1. Write pages to the in-file DWB zone with a header (magic + count + checksum)
1524    /// 2. fsync the DWB zone
1525    /// 3. Write all pages to their final locations in the .rdb file
1526    /// 4. Clear the DWB zone (marks as consumed)
1527    fn write_pages_through_dwb(&self, pages: &[(u32, Page)]) -> Result<(), PagerError> {
1528        if self.config.read_only {
1529            return Err(PagerError::ReadOnly);
1530        }
1531        // Keep the file locked through staging, home writes and clearing so
1532        // another page writer cannot interleave with a protected batch.
1533        let mut file = self.file_lock()?;
1534        self.ensure_writeback_available()?;
1535        let result = pager_io::write_pages(&mut *file, pages, self.dwb_enabled);
1536        if result.is_err() {
1537            // Set this while holding the file lock: a waiting writer must not
1538            // replace the recovery frame after an interrupted home write.
1539            self.writeback_failed
1540                .store(true, std::sync::atomic::Ordering::Release);
1541        }
1542        Ok(result?)
1543    }
1544
1545    /// Recover from double-write buffer after a crash.
1546    ///
1547    /// If the DWB zone contains valid pages, they were written before the crash
1548    /// interrupted writing to their home locations. Re-apply them.
1549    fn recover_from_dwb(&self) -> Result<(), PagerError> {
1550        let mut file = self.file_lock()?;
1551        Ok(pager_io::recover_from_dwb(&mut *file)?)
1552    }
1553
1554    /// Finish a consumed trunk's durable ownership change, retaining errors
1555    /// across allocation attempts until both copies have been synchronized.
1556    /// Called with the freelist locked when consuming or retrying a trunk.
1557    fn complete_allocation_fence(&self) -> Result<(), PagerError> {
1558        if self
1559            .allocation_fence_pending
1560            .load(std::sync::atomic::Ordering::Acquire)
1561        {
1562            self.write_header_and_sync()?;
1563            self.write_header_and_sync()?;
1564            self.allocation_fence_pending
1565                .store(false, std::sync::atomic::Ordering::Release);
1566        }
1567        Ok(())
1568    }
1569
1570    /// Write header and sync to disk (public for checkpointer).
1571    pub fn write_header_and_sync(&self) -> Result<(), PagerError> {
1572        let _ownership = self.lock_ownership_mutations();
1573        self.write_header()?;
1574        let file = self.file_lock()?;
1575        file.sync_all()?;
1576        Ok(())
1577    }
1578
1579    /// Set the checkpoint_in_progress flag in the header.
1580    pub fn set_checkpoint_in_progress(
1581        &self,
1582        in_progress: bool,
1583        target_lsn: u64,
1584    ) -> Result<(), PagerError> {
1585        let _ownership = self.lock_ownership_mutations();
1586        let mut header = self.header_write()?;
1587        header.checkpoint_in_progress = in_progress;
1588        header.checkpoint_target_lsn = target_lsn;
1589        *self.header_dirty_lock()? = true;
1590        drop(header);
1591        self.write_header_and_sync()
1592    }
1593
1594    /// Update the checkpoint LSN and clear the in-progress flag.
1595    pub fn complete_checkpoint(&self, lsn: u64) -> Result<(), PagerError> {
1596        let _ownership = self.lock_ownership_mutations();
1597        let mut header = self.header_write()?;
1598        header.checkpoint_lsn = lsn;
1599        header.checkpoint_in_progress = false;
1600        header.checkpoint_target_lsn = 0;
1601        *self.header_dirty_lock()? = true;
1602        drop(header);
1603        self.write_header_and_sync()
1604    }
1605}