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