Skip to main content

reddb_engine/storage/
pager.rs

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