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