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