Skip to main content

reddb_engine/storage/
pager.rs

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