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