Skip to main content

reddb_server/storage/engine/pager/
impl.rs

1use super::*;
2
3pub(super) const BTRFS_SUPER_MAGIC: i64 = 0x9123_683e;
4pub(super) const ZFS_SUPER_MAGIC: i64 = 0x2fc1_2fc1;
5pub(super) const FS_NOCOW_FL: u64 = 0x0080_0000;
6
7/// The superblock zone occupies the head of page 0 (ADR 0038 §2 phase 1).
8pub(super) const SUPERBLOCK_SLOT_SIZE: usize = reddb_file::PAGED_SUPERBLOCK_SLOT_SIZE;
9pub(super) const SUPERBLOCK_ZONE_SIZE: usize = reddb_file::PAGED_SUPERBLOCK_ZONE_SIZE;
10pub(super) const PHASE3_DWB_ZONE_FILE_VERSION: u32 = reddb_file::PAGE_FILE_VERSION;
11
12/// The newest valid superblock copy together with its slot image.
13type NewestSuperblock = (
14    reddb_file::PagedSuperblockSelection,
15    [u8; SUPERBLOCK_SLOT_SIZE],
16);
17
18/// Take the first slot's worth of a freshly-built page-0 image. The database
19/// header and the encryption header both live well inside this prefix.
20fn superblock_slot_image(page: &[u8; PAGE_SIZE]) -> [u8; SUPERBLOCK_SLOT_SIZE] {
21    let mut image = [0u8; SUPERBLOCK_SLOT_SIZE];
22    image.copy_from_slice(&page[..SUPERBLOCK_SLOT_SIZE]);
23    image
24}
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub(super) enum CowFilesystemKind {
28    Zfs,
29    BtrfsDataCow,
30    TestOverride,
31}
32
33pub(super) fn classify_cow_filesystem(
34    fs_type: i64,
35    mount_options: Option<&str>,
36    inode_flags: Option<u64>,
37) -> Option<CowFilesystemKind> {
38    match fs_type {
39        ZFS_SUPER_MAGIC => Some(CowFilesystemKind::Zfs),
40        BTRFS_SUPER_MAGIC => {
41            let mount_options = mount_options?;
42            if mount_options.split(',').any(|option| option == "nodatacow") {
43                return None;
44            }
45
46            let inode_flags = inode_flags?;
47            if inode_flags & FS_NOCOW_FL != 0 {
48                return None;
49            }
50
51            Some(CowFilesystemKind::BtrfsDataCow)
52        }
53        _ => None,
54    }
55}
56
57#[cfg(target_os = "linux")]
58fn linux_fstatfs_type(file: &File) -> Option<i64> {
59    use std::mem::MaybeUninit;
60    use std::os::fd::AsRawFd;
61
62    let mut stat = MaybeUninit::<libc::statfs>::uninit();
63    let rc = unsafe { libc::fstatfs(file.as_raw_fd(), stat.as_mut_ptr()) };
64    if rc != 0 {
65        return None;
66    }
67    let stat = unsafe { stat.assume_init() };
68    // `statfs.f_type` is i32 on 32-bit glibc targets (armv7) and i64 on
69    // 64-bit ones — widen explicitly so both compile.
70    #[allow(clippy::unnecessary_cast)]
71    Some(stat.f_type as i64)
72}
73
74#[cfg(target_os = "linux")]
75fn linux_inode_flags(file: &File) -> Option<u64> {
76    use std::os::fd::AsRawFd;
77
78    let mut flags: libc::c_long = 0;
79    let rc = unsafe { libc::ioctl(file.as_raw_fd(), libc::FS_IOC_GETFLAGS, &mut flags) };
80    if rc != 0 {
81        return None;
82    }
83    Some(flags as u64)
84}
85
86#[cfg(target_os = "linux")]
87fn linux_mount_options_for_path(path: &Path) -> Option<String> {
88    let path = path.canonicalize().ok()?;
89    let mountinfo = std::fs::read_to_string("/proc/self/mountinfo").ok()?;
90    parse_mountinfo_options_for_path(&mountinfo, &path)
91}
92
93#[cfg(target_os = "linux")]
94pub(super) fn parse_mountinfo_options_for_path(mountinfo: &str, path: &Path) -> Option<String> {
95    let mut best: Option<(usize, String)> = None;
96
97    for line in mountinfo.lines() {
98        let fields: Vec<&str> = line.split(' ').collect();
99        if fields.len() < 10 {
100            continue;
101        }
102
103        let Some(separator) = fields.iter().position(|field| *field == "-") else {
104            continue;
105        };
106        if separator + 3 >= fields.len() || separator < 6 {
107            continue;
108        }
109
110        let mount_point = mountinfo_unescape_path(fields[4]);
111        if !path.starts_with(&mount_point) {
112            continue;
113        }
114
115        let fs_type = fields[separator + 1];
116        if fs_type != "btrfs" && fs_type != "zfs" {
117            continue;
118        }
119
120        let mount_options = fields[5];
121        let super_options = fields[separator + 3];
122        let options = format!("{mount_options},{super_options}");
123        let depth = mount_point.components().count();
124        if best
125            .as_ref()
126            .map(|(best_depth, _)| depth > *best_depth)
127            .unwrap_or(true)
128        {
129            best = Some((depth, options));
130        }
131    }
132
133    best.map(|(_, options)| options)
134}
135
136#[cfg(target_os = "linux")]
137fn mountinfo_unescape_path(value: &str) -> PathBuf {
138    let bytes = value.as_bytes();
139    let mut out = Vec::with_capacity(bytes.len());
140    let mut i = 0;
141
142    while i < bytes.len() {
143        if bytes[i] == b'\\' && i + 3 < bytes.len() {
144            let octal = &value[i + 1..i + 4];
145            if let Ok(byte) = u8::from_str_radix(octal, 8) {
146                out.push(byte);
147                i += 4;
148                continue;
149            }
150        }
151        out.push(bytes[i]);
152        i += 1;
153    }
154
155    PathBuf::from(String::from_utf8_lossy(&out).into_owned())
156}
157
158impl Pager {
159    /// Open or create a database file
160    pub fn open<P: AsRef<Path>>(path: P, mut config: PagerConfig) -> Result<Self, PagerError> {
161        let path = path.as_ref().to_path_buf();
162        let exists = path.exists();
163
164        if !exists && !config.create {
165            return Err(PagerError::InvalidDatabase(
166                "Database does not exist".into(),
167            ));
168        }
169
170        // ADR 0038 §4 phase 1 is a clean break: a store still carrying the
171        // retired `rdb-hdr`/`rdb-meta` sidecars is never read by the engine.
172        // The offline migration tool is the only path forward.
173        if exists {
174            if let Some(sidecar) = reddb_file::layout::retired::first_present_phase1_sidecar(&path)
175            {
176                return Err(PagerError::LegacySidecarStore { sidecar });
177            }
178        }
179
180        if !exists && config.read_only {
181            return Err(PagerError::InvalidDatabase(
182                "Cannot create read-only database".into(),
183            ));
184        }
185
186        // Open file
187        // Note: create requires write access, so disable it for read-only mode
188        let file = OpenOptions::new()
189            .read(true)
190            .write(!config.read_only)
191            .create(config.create && !config.read_only)
192            .open(&path)?;
193
194        // gh-892: diagnostic probe of the filesystem block size. If the
195        // compile-time 16 KiB PAGE_SIZE is not a multiple of the FS block
196        // size, page writes straddle FS blocks and incur read-modify-write
197        // amplification. Pure diagnostic — emitted once per open, never
198        // mutates the page size. `blksize()` is the fstat `st_blksize`.
199        #[cfg(unix)]
200        {
201            use std::os::unix::fs::MetadataExt;
202            if let Ok(meta) = file.metadata() {
203                let fs_block_size = meta.blksize();
204                if Self::page_size_misaligned_with_block(PAGE_SIZE, fs_block_size) {
205                    tracing::warn!(
206                        page_size = PAGE_SIZE,
207                        fs_block_size,
208                        path = %path.display(),
209                        "database page size is not a multiple of the filesystem \
210                         block size; page writes will straddle FS blocks \
211                         (read-modify-write amplification). Diagnostic only — \
212                         the page size is unchanged."
213                    );
214                }
215            }
216        }
217
218        // Acquire file lock (exclusive for writes, shared for read-only)
219        let lock_file = if !config.read_only {
220            let lf = OpenOptions::new().read(true).write(true).open(&path)?;
221            lf.try_lock_exclusive().map_err(|_| PagerError::Locked)?;
222            Some(lf)
223        } else {
224            let lf = OpenOptions::new().read(true).open(&path)?;
225            match lf.try_lock_shared() {
226                Ok(_) => Some(lf),
227                Err(_) => None,
228            }
229        };
230
231        // ADR 0038 phase 3: the DWB sidecar is retired. When DWB is active,
232        // the recovery frame is written to a reserved zone inside the data
233        // file and fsynced before in-place page writes. A pre-existing sidecar
234        // marks a legacy store that must go through the offline migration tool.
235        //
236        // gh-895: an explicit `double_write = false` request is honored only
237        // when the already-open data file is proven to live on a filesystem
238        // with atomic CoW page writes. Unknown and non-CoW filesystems fail
239        // closed by keeping the in-file DWB zone enabled.
240        let fold_dwb = crate::physical::fold_dwb_into_wal_enabled();
241        if exists {
242            if let Some(sidecar) =
243                reddb_file::layout::retired::first_present_phase3_dwb_sidecar(&path)
244            {
245                return Err(PagerError::LegacySidecarStore { sidecar });
246            }
247        }
248        if !config.double_write && !config.read_only && !fold_dwb {
249            let skip_dwb_on_cow =
250                Self::cow_filesystem_has_atomic_page_writes(&path, &file).is_some();
251            if !skip_dwb_on_cow {
252                tracing::warn!(
253                    path = %path.display(),
254                    "double_write=false requested, but the data file is not proven to be on \
255                     ZFS or btrfs datacow; keeping the in-file double-write buffer enabled"
256                );
257                config.double_write = true;
258            }
259        }
260        let dwb_enabled = config.double_write && !config.read_only && !fold_dwb;
261
262        let mut pager = Self {
263            path,
264            file: Mutex::new(file),
265            _lock_file: lock_file,
266            dwb_enabled,
267            cache: PageCache::new(config.cache_size),
268            freelist: RwLock::new(FreeList::new()),
269            deferred_reclamation: Mutex::new(DeferredPageReclamation::default()),
270            allocation_reused: std::sync::atomic::AtomicBool::new(false),
271            allocation_fence_pending: std::sync::atomic::AtomicBool::new(false),
272            header: RwLock::new(DatabaseHeader::default()),
273            config,
274            header_dirty: Mutex::new(false),
275            wal: RwLock::new(None),
276            encryption: None,
277        };
278
279        if exists {
280            // Recover from the in-file double-write zone before loading the
281            // header or allowing WAL replay to read pages.
282            pager.ensure_phase3_dwb_zone_layout()?;
283            pager.recover_from_dwb()?;
284            // Load existing database (with header shadow fallback)
285            pager.load_header()?;
286            pager.bind_encryption_for_existing()?;
287        } else {
288            // Initialize new database
289            pager.initialize()?;
290            pager.bind_encryption_for_new()?;
291        }
292
293        Ok(pager)
294    }
295
296    /// gh-892 diagnostic predicate: returns `true` when the database
297    /// `page_size` is **not** an integer multiple of the filesystem's
298    /// reported block size (`st_blksize`), i.e. `page_size % fs_block_size
299    /// != 0`. A `fs_block_size` of `0` (probe unavailable / unknown) is
300    /// treated as aligned so a missing probe never produces a warning.
301    /// Pure function with no I/O so the warn decision is unit-testable.
302    pub(crate) fn page_size_misaligned_with_block(page_size: usize, fs_block_size: u64) -> bool {
303        fs_block_size != 0 && !(page_size as u64).is_multiple_of(fs_block_size)
304    }
305
306    #[cfg(test)]
307    fn cow_filesystem_test_override() -> Option<bool> {
308        match COW_ATOMIC_WRITE_TEST_OVERRIDE.load(Ordering::Relaxed) {
309            1 => Some(true),
310            2 => Some(false),
311            _ => None,
312        }
313    }
314
315    #[cfg(not(test))]
316    fn cow_filesystem_test_override() -> Option<bool> {
317        None
318    }
319
320    fn cow_filesystem_has_atomic_page_writes(
321        path: &Path,
322        file: &File,
323    ) -> Option<CowFilesystemKind> {
324        if let Some(allowed) = Self::cow_filesystem_test_override() {
325            return allowed.then_some(CowFilesystemKind::TestOverride);
326        }
327        Self::probe_cow_filesystem(path, file)
328    }
329
330    #[cfg(target_os = "linux")]
331    fn probe_cow_filesystem(path: &Path, file: &File) -> Option<CowFilesystemKind> {
332        let fs_type = linux_fstatfs_type(file)?;
333        match fs_type {
334            ZFS_SUPER_MAGIC => Some(CowFilesystemKind::Zfs),
335            BTRFS_SUPER_MAGIC => {
336                let mount_options = linux_mount_options_for_path(path)?;
337                let inode_flags = linux_inode_flags(file)?;
338                classify_cow_filesystem(fs_type, Some(&mount_options), Some(inode_flags))
339            }
340            _ => None,
341        }
342    }
343
344    #[cfg(not(target_os = "linux"))]
345    fn probe_cow_filesystem(_path: &Path, _file: &File) -> Option<CowFilesystemKind> {
346        None
347    }
348
349    /// Inspect page 0 for the `RDBE` encryption marker, then resolve
350    /// the (key, marker) matrix:
351    ///
352    /// | Marker | Key supplied | Result                              |
353    /// |--------|--------------|-------------------------------------|
354    /// | yes    | yes          | Bind encryptor; validate key        |
355    /// | yes    | no           | `EncryptionRequired` (fail closed)  |
356    /// | no     | yes          | `PlainDatabaseRefusesKey`           |
357    /// | no     | no           | Plain pager — no binding needed     |
358    fn bind_encryption_for_existing(&mut self) -> Result<(), PagerError> {
359        if self.page_count().unwrap_or(0) == 0 {
360            return self.bind_encryption_for_new();
361        }
362        let (_, image) = self.newest_superblock()?;
363        let data = &image[..];
364        let has_marker = reddb_file::paged_encryption_marker_present(data);
365
366        let key = self.config.encryption.clone();
367        match (has_marker, key) {
368            (true, Some(key)) => {
369                let header_bytes =
370                    reddb_file::paged_encryption_header_bytes(data).ok_or_else(|| {
371                        PagerError::InvalidDatabase("encryption header parse failed".to_string())
372                    })?;
373                let header = crate::storage::encryption::EncryptionHeader::from_bytes(header_bytes)
374                    .map_err(|e| {
375                        PagerError::InvalidDatabase(format!("encryption header parse failed: {e}"))
376                    })?;
377                if !header.validate(&key) {
378                    return Err(PagerError::InvalidKey);
379                }
380                let encryptor = crate::storage::encryption::PageEncryptor::new(key);
381                self.encryption = Some((encryptor, header));
382                Ok(())
383            }
384            (true, None) => Err(PagerError::EncryptionRequired),
385            (false, Some(_)) => Err(PagerError::PlainDatabaseRefusesKey),
386            (false, None) => Ok(()),
387        }
388    }
389
390    /// New DB: if a key is configured, write the marker + header to
391    /// page 0 so subsequent opens detect encryption.
392    fn bind_encryption_for_new(&mut self) -> Result<(), PagerError> {
393        let Some(key) = self.config.encryption.clone() else {
394            return Ok(());
395        };
396        let header = crate::storage::encryption::EncryptionHeader::new(&key);
397        let encryptor = crate::storage::encryption::PageEncryptor::new(key);
398
399        // Stamp the marker + header into the superblock zone if `initialize()`
400        // has already published it.
401        if self.page_count().unwrap_or(0) > 0 {
402            let header_bytes = header.to_bytes();
403            self.commit_superblock(|image| {
404                reddb_file::write_paged_encryption_marker_and_header(image, &header_bytes)
405                    .map_err(|err| PagerError::InvalidDatabase(err.to_string()))
406            })?;
407        }
408        self.encryption = Some((encryptor, header));
409        Ok(())
410    }
411
412    /// Open with default configuration
413    pub fn open_default<P: AsRef<Path>>(path: P) -> Result<Self, PagerError> {
414        Self::open(path, PagerConfig::default())
415    }
416
417    /// Initialize a new database
418    fn initialize(&self) -> Result<(), PagerError> {
419        if self.config.read_only {
420            return Err(PagerError::ReadOnly);
421        }
422
423        // Create header page. Page ids 1 and 2 are reserved for fixed
424        // metadata/vault pages. ADR 0038 phase 3 reserves the following DWB
425        // zone in-file so normal B-tree allocation starts after it.
426        let initial_page_count = FIRST_ALLOCATABLE_PAGE;
427        let header_page = Page::new_header_page(initial_page_count);
428        self.header_write()?.page_count = initial_page_count;
429
430        // Reserve page 0 (the superblock zone) plus the reserved pages so any
431        // scan over 0..page_count can read every allocated page in a brand-new
432        // database. The zone's own bytes are published below, one slot at a
433        // time; this first whole-page write only sizes the file.
434        self.write_page_raw(0, &header_page)?;
435        let mut metadata_page = Page::new(PageType::Header, 1);
436        metadata_page.update_checksum();
437        self.write_page_raw(1, &metadata_page)?;
438        let mut vault_page = Page::new(PageType::Vault, 2);
439        vault_page.update_checksum();
440        self.write_page_raw(2, &vault_page)?;
441        self.clear_dwb_zone()?;
442
443        // Publish both superblock copies so the ping-pong invariant — one
444        // valid copy always survives a crash — holds from the very first
445        // update onward, not just from the second.
446        let mut slot = superblock_slot_image(header_page.as_bytes());
447        self.write_superblock_slot(&mut slot, 0, 1)?;
448        self.write_superblock_slot(&mut slot, 1, 2)?;
449
450        // Sync to disk
451        self.sync()?;
452
453        Ok(())
454    }
455
456    /// Acquire a write lock on the header RwLock, mapping poison errors.
457    fn header_write(&self) -> Result<std::sync::RwLockWriteGuard<'_, DatabaseHeader>, PagerError> {
458        self.header.write().map_err(|_| PagerError::LockPoisoned)
459    }
460
461    /// Acquire a read lock on the header RwLock, mapping poison errors.
462    fn header_read(&self) -> Result<std::sync::RwLockReadGuard<'_, DatabaseHeader>, PagerError> {
463        self.header.read().map_err(|_| PagerError::LockPoisoned)
464    }
465
466    /// Acquire a write lock on the freelist RwLock, mapping poison errors.
467    fn freelist_write(&self) -> Result<std::sync::RwLockWriteGuard<'_, FreeList>, PagerError> {
468        self.freelist.write().map_err(|_| PagerError::LockPoisoned)
469    }
470
471    /// Acquire a lock on the file Mutex, mapping poison errors.
472    fn file_lock(&self) -> Result<std::sync::MutexGuard<'_, File>, PagerError> {
473        self.file.lock().map_err(|_| PagerError::LockPoisoned)
474    }
475
476    /// Acquire a lock on the header_dirty Mutex, mapping poison errors.
477    fn header_dirty_lock(&self) -> Result<std::sync::MutexGuard<'_, bool>, PagerError> {
478        self.header_dirty
479            .lock()
480            .map_err(|_| PagerError::LockPoisoned)
481    }
482
483    /// Read the whole superblock zone (both ping-pong copies) from the head of
484    /// the file. A file shorter than the zone yields zero-filled tail bytes,
485    /// which decode as "slot never written".
486    fn read_superblock_zone(&self) -> Result<[u8; SUPERBLOCK_ZONE_SIZE], PagerError> {
487        let mut file = self.file_lock()?;
488        let len = file.metadata().map(|meta| meta.len()).unwrap_or(0);
489        let mut zone = [0u8; SUPERBLOCK_ZONE_SIZE];
490        let readable = len.min(SUPERBLOCK_ZONE_SIZE as u64) as usize;
491        if readable > 0 {
492            file.seek(SeekFrom::Start(0))?;
493            file.read_exact(&mut zone[..readable])?;
494        }
495        Ok(zone)
496    }
497
498    /// The newest valid superblock copy, with its slot image.
499    ///
500    /// Both copies invalid is the one unrecoverable superblock state (ADR 0074
501    /// §2): the store does not open, and the error names the zone and points
502    /// at salvage rather than panicking or silently re-initialising.
503    fn newest_superblock(&self) -> Result<NewestSuperblock, PagerError> {
504        let zone = self.read_superblock_zone()?;
505        let selection = reddb_file::select_paged_superblock(&zone)
506            .ok_or_else(|| PagerError::SuperblockZoneUnrecoverable(self.path.clone()))?;
507
508        let start = selection.copy_index * SUPERBLOCK_SLOT_SIZE;
509        let mut image = [0u8; SUPERBLOCK_SLOT_SIZE];
510        image.copy_from_slice(&zone[start..start + SUPERBLOCK_SLOT_SIZE]);
511        Ok((selection, image))
512    }
513
514    fn ensure_phase3_dwb_zone_layout(&self) -> Result<(), PagerError> {
515        let (_, image) = self.newest_superblock()?;
516        let header = reddb_file::decode_database_header(&image)
517            .map_err(|err| PagerError::InvalidDatabase(err.to_string()))?;
518        if header.version < PHASE3_DWB_ZONE_FILE_VERSION {
519            return Err(PagerError::LegacyPagedStore {
520                path: self.path.clone(),
521            });
522        }
523        Ok(())
524    }
525
526    /// Seal `slot` for `copy_index`/`generation` and write exactly that slot,
527    /// then fsync. The caller guarantees `copy_index` is the stale copy.
528    fn write_superblock_slot(
529        &self,
530        slot: &mut [u8; SUPERBLOCK_SLOT_SIZE],
531        copy_index: usize,
532        generation: u64,
533    ) -> Result<(), PagerError> {
534        if self.config.read_only {
535            return Err(PagerError::ReadOnly);
536        }
537        reddb_file::seal_paged_superblock_slot(slot, copy_index, generation)
538            .map_err(|err| PagerError::InvalidDatabase(err.to_string()))?;
539
540        let mut file = self.file_lock()?;
541        file.seek(SeekFrom::Start(reddb_file::paged_superblock_slot_offset(
542            copy_index,
543        )))?;
544        file.write_all(slot)?;
545        file.sync_all()?;
546        Ok(())
547    }
548
549    /// Publish `mutate`'s edit of the newest slot image into the *stale* copy.
550    ///
551    /// Reading the newest image first means every byte the pager does not
552    /// explicitly rewrite — most importantly the encryption marker and header —
553    /// carries forward untouched.
554    fn commit_superblock(
555        &self,
556        mutate: impl FnOnce(&mut [u8; SUPERBLOCK_SLOT_SIZE]) -> Result<(), PagerError>,
557    ) -> Result<(), PagerError> {
558        if self.config.read_only {
559            return Err(PagerError::ReadOnly);
560        }
561        let (newest, mut image) = self.newest_superblock()?;
562        mutate(&mut image)?;
563        let next_copy = reddb_file::paged_superblock_next_copy(Some(newest));
564        self.write_superblock_slot(&mut image, next_copy, newest.generation.saturating_add(1))
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
579            .file_lock()?
580            .metadata()?
581            .len()
582            .div_ceil(PAGE_SIZE as u64);
583        let physical_pages = u32::try_from(physical_pages).map_err(|_| {
584            PagerError::InvalidDatabase("data file exceeds the page address space".into())
585        })?;
586        if decoded_header.page_count < physical_pages {
587            decoded_header.page_count = physical_pages;
588            if !self.config.read_only {
589                *self.header_dirty_lock()? = true;
590            }
591        }
592        let freelist_head = decoded_header.freelist_head;
593
594        {
595            let mut header = self.header_write()?;
596            *header = decoded_header;
597        }
598
599        // Initialize freelist
600        {
601            let mut freelist = self.freelist_write()?;
602            *freelist = FreeList::from_header(freelist_head, 0);
603        }
604
605        Ok(())
606    }
607
608    /// Publish the in-memory database header as a new superblock generation.
609    fn write_header(&self) -> Result<(), PagerError> {
610        if self.config.read_only {
611            return Err(PagerError::ReadOnly);
612        }
613
614        let header = self.header_read()?.clone();
615        self.commit_superblock(|image| {
616            reddb_file::encode_database_header(image, &header)
617                .map_err(|err| PagerError::InvalidDatabase(err.to_string()))
618        })?;
619        *self.header_dirty_lock()? = false;
620
621        Ok(())
622    }
623
624    /// Read a page from disk (bypassing cache)
625    fn read_page_raw(&self, page_id: u32) -> Result<Page, PagerError> {
626        let mut file = self.file_lock()?;
627        let offset = (page_id as u64) * (PAGE_SIZE as u64);
628
629        file.seek(SeekFrom::Start(offset))?;
630
631        let mut buf = [0u8; PAGE_SIZE];
632        file.read_exact(&mut buf)?;
633
634        let page = Page::from_bytes(buf);
635
636        // Page 0 is the superblock zone, not a checksummed page: its two slots
637        // are sealed independently, so no whole-page checksum can hold across
638        // a ping-pong write. Slot CRCs are its integrity contract.
639        if self.config.verify_checksums && page_id != 0 {
640            page.verify_checksum()?;
641        }
642
643        Ok(page)
644    }
645
646    /// Write a page to disk (bypassing cache)
647    fn write_page_raw(&self, page_id: u32, page: &Page) -> Result<(), PagerError> {
648        if self.config.read_only {
649            return Err(PagerError::ReadOnly);
650        }
651
652        let mut file = self.file_lock()?;
653        let offset = (page_id as u64) * (PAGE_SIZE as u64);
654
655        file.seek(SeekFrom::Start(offset))?;
656        file.write_all(page.as_bytes())?;
657
658        Ok(())
659    }
660
661    /// Read a page (cache-aware)
662    pub fn read_page(&self, page_id: u32) -> Result<Page, PagerError> {
663        // Check cache first
664        if let Some(page) = self.cache.get(page_id) {
665            return Ok(page);
666        }
667
668        // Cache miss - read from disk
669        let page = self.read_page_raw(page_id)?;
670
671        // Add to cache
672        if let Some(dirty_page) = self.cache.insert(page_id, page.clone()) {
673            // Evicted page was dirty, need to write it back
674            let evicted_id = dirty_page.page_id();
675            self.write_page_raw(evicted_id, &dirty_page)?;
676        }
677
678        Ok(page)
679    }
680
681    /// Read a page without verifying checksum (for encrypted pages)
682    ///
683    /// Use this when the page content has its own integrity protection
684    /// (e.g., AES-GCM authentication tag for encrypted pages).
685    pub fn read_page_no_checksum(&self, page_id: u32) -> Result<Page, PagerError> {
686        // Check cache first
687        if let Some(page) = self.cache.get(page_id) {
688            return Ok(page);
689        }
690
691        // Cache miss - read from disk (skip checksum verification)
692        let mut file = self.file_lock()?;
693        let offset = (page_id as u64) * (PAGE_SIZE as u64);
694
695        file.seek(SeekFrom::Start(offset))?;
696
697        let mut buf = [0u8; PAGE_SIZE];
698        file.read_exact(&mut buf)?;
699        drop(file);
700
701        let page = Page::from_bytes(buf);
702
703        // Add to cache (no checksum verification)
704        if let Some(dirty_page) = self.cache.insert(page_id, page.clone()) {
705            // Evicted page was dirty, need to write it back
706            let evicted_id = dirty_page.page_id();
707            self.write_page_raw(evicted_id, &dirty_page)?;
708        }
709
710        Ok(page)
711    }
712
713    /// Write a page (cache-aware)
714    pub fn write_page(&self, page_id: u32, mut page: Page) -> Result<(), PagerError> {
715        if self.config.read_only {
716            return Err(PagerError::ReadOnly);
717        }
718
719        // Update checksum
720        page.update_checksum();
721
722        // Add to cache and mark dirty. If the cache had to evict a
723        // dirty entry, write it through immediately — the evicted
724        // page will never be flushed otherwise (same bug fixed in
725        // `allocate_page`).
726        if let Some(dirty_page) = self.cache.insert(page_id, page) {
727            let evicted_id = dirty_page.page_id();
728            self.write_page_raw(evicted_id, &dirty_page)?;
729        }
730        self.cache.mark_dirty(page_id);
731
732        Ok(())
733    }
734
735    /// Read a page through the configured encryptor if any. Page 0
736    /// is always returned plaintext (it carries the encryption marker
737    /// + header). Callers that want raw cipher bytes can use
738    ///   `read_page_no_checksum` directly.
739    pub fn read_page_decrypted(&self, page_id: u32) -> Result<Page, PagerError> {
740        if page_id == 0 || self.encryption.is_none() {
741            return self.read_page(page_id);
742        }
743        let raw = self.read_page_no_checksum(page_id)?;
744        let (enc, _) = self
745            .encryption
746            .as_ref()
747            .expect("encryption presence checked above");
748        let plaintext = enc
749            .decrypt(page_id, raw.as_bytes())
750            .map_err(|e| PagerError::InvalidDatabase(format!("decrypt page {page_id}: {e}")))?;
751        let mut buf = [0u8; PAGE_SIZE];
752        let n = plaintext.len().min(PAGE_SIZE);
753        buf[..n].copy_from_slice(&plaintext[..n]);
754        Ok(Page::from_bytes(buf))
755    }
756
757    /// Write a page through the configured encryptor if any. Page 0
758    /// bypasses encryption and goes through the normal checksummed
759    /// path. Encrypted pages skip the checksum update because
760    /// AES-GCM's authentication tag is the integrity guarantee.
761    pub fn write_page_encrypted(&self, page_id: u32, page: Page) -> Result<(), PagerError> {
762        if page_id == 0 || self.encryption.is_none() {
763            return self.write_page(page_id, page);
764        }
765        // Canonical per-page envelope overhead (nonce + GCM tag),
766        // owned by reddb-io-crypto (#1053, ADR 0054).
767        const OVERHEAD: usize = crate::storage::encryption::page_encryptor::OVERHEAD;
768        let plaintext_len = PAGE_SIZE - OVERHEAD;
769        let plaintext = &page.as_bytes()[..plaintext_len];
770        let (enc, _) = self
771            .encryption
772            .as_ref()
773            .expect("encryption presence checked above");
774        let ciphertext = enc.encrypt(page_id, plaintext);
775        debug_assert_eq!(ciphertext.len(), PAGE_SIZE);
776        let mut buf = [0u8; PAGE_SIZE];
777        buf.copy_from_slice(&ciphertext);
778        let cipher_page = Page::from_bytes(buf);
779        self.write_page_no_checksum(page_id, cipher_page)
780    }
781
782    /// Write a page without updating checksum (for encrypted pages)
783    ///
784    /// Use this when the page content has its own integrity protection
785    /// (e.g., AES-GCM authentication tag for encrypted pages).
786    pub fn write_page_no_checksum(&self, page_id: u32, page: Page) -> Result<(), PagerError> {
787        if self.config.read_only {
788            return Err(PagerError::ReadOnly);
789        }
790
791        // Add to cache and mark dirty (no checksum update). Same
792        // eviction-write-through guard as `write_page`.
793        if let Some(dirty_page) = self.cache.insert(page_id, page) {
794            let evicted_id = dirty_page.page_id();
795            self.write_page_raw(evicted_id, &dirty_page)?;
796        }
797        self.cache.mark_dirty(page_id);
798
799        Ok(())
800    }
801
802    /// Allocate a new page
803    pub fn allocate_page(&self, page_type: PageType) -> Result<Page, PagerError> {
804        if self.config.read_only {
805            return Err(PagerError::ReadOnly);
806        }
807
808        // Try to get from freelist first
809        let page_id = {
810            let mut freelist = self.freelist_write()?;
811            // A previous fence can fail after loading the trunk into memory.
812            // Retry it before the fast path can consume any of those pages.
813            self.complete_allocation_fence()?;
814            if let Some(id) = freelist.allocate() {
815                self.allocation_reused
816                    .store(true, std::sync::atomic::Ordering::Release);
817                id
818            } else if freelist.trunk_head() != 0 {
819                let trunk_id = freelist.trunk_head();
820
821                let trunk = self.read_page(trunk_id).map_err(|e| match e {
822                    PagerError::PageNotFound(_) => {
823                        PagerError::InvalidDatabase("Freelist trunk missing".to_string())
824                    }
825                    other => other,
826                })?;
827
828                freelist
829                    .load_from_trunk(&trunk)
830                    .map_err(|e| PagerError::InvalidDatabase(format!("Freelist: {}", e)))?;
831                let mut header = self.header_write()?;
832                header.freelist_head = freelist.trunk_head();
833                *self.header_dirty_lock()? = true;
834                drop(header);
835                // The consumed trunk itself becomes reusable. Forget its old
836                // allocator role durably before cache eviction can overwrite it.
837                // Keep the allocator locked so another allocator cannot race
838                // ahead of this fence. A failed fence leaves allocation stopped.
839                self.allocation_fence_pending
840                    .store(true, std::sync::atomic::Ordering::Release);
841                self.complete_allocation_fence()?;
842                let id = freelist.allocate().ok_or_else(|| {
843                    PagerError::InvalidDatabase("Freelist empty after trunk load".to_string())
844                })?;
845                self.allocation_reused
846                    .store(true, std::sync::atomic::Ordering::Release);
847                id
848            } else {
849                // No free pages, extend file
850                let mut header = self.header_write()?;
851                let id = header.page_count;
852                header.page_count += 1;
853                *self.header_dirty_lock()? = true;
854                id
855            }
856        };
857
858        let page = Page::new(page_type, page_id);
859
860        // Write to cache. The evicted page (if any) is dirty by
861        // definition — `cache.insert` only returns `Some` when it
862        // had to evict a dirty entry to make room. The previous
863        // version dropped that return value, which silently lost
864        // writes whenever a freshly-allocated page caused an LRU
865        // eviction. This shows up under heavy ingest as
866        // "B-tree insert error: Pager error: I/O error: failed to
867        // fill whole buffer" later, when something tries to read
868        // back the never-flushed page. Mirror `read_page`'s
869        // handling: write the evicted page through immediately so
870        // the on-disk image stays consistent.
871        if let Some(dirty_page) = self.cache.insert(page_id, page.clone()) {
872            let evicted_id = dirty_page.page_id();
873            self.write_page_raw(evicted_id, &dirty_page)?;
874        }
875        self.cache.mark_dirty(page_id);
876
877        Ok(page)
878    }
879
880    /// Reserve a contiguous extent of vector pages.
881    pub fn reserve_contig_extent(&self, n_pages: u32) -> Result<super::ExtentId, PagerError> {
882        if self.config.read_only {
883            return Err(PagerError::ReadOnly);
884        }
885        if n_pages == 0 {
886            return Err(PagerError::InvalidDatabase(
887                "contiguous extent must reserve at least one page".to_string(),
888            ));
889        }
890
891        let start_page = {
892            let mut header = self.header_write()?;
893            let start = header.page_count;
894            header.page_count = header.page_count.checked_add(n_pages).ok_or_else(|| {
895                PagerError::InvalidDatabase("contiguous extent page count overflow".to_string())
896            })?;
897            *self.header_dirty_lock()? = true;
898            start
899        };
900
901        for page_id in start_page..start_page + n_pages {
902            let mut page = Page::new(PageType::Vector, page_id);
903            page.update_checksum();
904            if let Some(dirty_page) = self.cache.insert(page_id, page) {
905                let evicted_id = dirty_page.page_id();
906                self.write_page_raw(evicted_id, &dirty_page)?;
907            }
908            self.cache.mark_dirty(page_id);
909        }
910
911        Ok(super::ExtentId {
912            start_page,
913            n_pages,
914        })
915    }
916
917    /// Free a page (return to freelist)
918    pub fn free_page(&self, page_id: u32) -> Result<(), PagerError> {
919        if self.config.read_only {
920            return Err(PagerError::ReadOnly);
921        }
922
923        let mut deferred = self
924            .deferred_reclamation
925            .lock()
926            .map_err(|_| PagerError::LockPoisoned)?;
927        if deferred.enabled {
928            if page_id < FIRST_ALLOCATABLE_PAGE || page_id >= self.page_count()? {
929                return Err(PagerError::InvalidDatabase(
930                    "invalid deferred free page".into(),
931                ));
932            }
933            // Keep dirty forwarding/empty nodes until publication. An
934            // incidental flush must leave the previous root traversable.
935            deferred.pending.insert(page_id);
936            return Ok(());
937        }
938        drop(deferred);
939
940        // Remove from cache
941        self.cache.remove(page_id);
942
943        // Add to freelist
944        let mut freelist = self.freelist_write()?;
945        freelist.free(page_id);
946
947        *self.header_dirty_lock()? = true;
948
949        Ok(())
950    }
951
952    pub(crate) fn enable_deferred_reclamation(&self) -> Result<(), PagerError> {
953        self.deferred_reclamation
954            .lock()
955            .map_err(|_| PagerError::LockPoisoned)?
956            .enabled = true;
957        Ok(())
958    }
959
960    pub(crate) fn deferred_reclamation_pages(&self) -> Result<(Vec<u32>, Vec<u32>), PagerError> {
961        let deferred = self
962            .deferred_reclamation
963            .lock()
964            .map_err(|_| PagerError::LockPoisoned)?;
965        Ok((
966            deferred.pending.iter().copied().collect(),
967            deferred.published.iter().copied().collect(),
968        ))
969    }
970
971    pub(crate) fn restore_deferred_reclamation(&self, pages: &[u32]) -> Result<(), PagerError> {
972        let count = self.page_count()?;
973        if pages
974            .iter()
975            .any(|id| *id < FIRST_ALLOCATABLE_PAGE || *id >= count)
976        {
977            return Err(PagerError::InvalidDatabase(
978                "invalid persisted deferred free page".into(),
979            ));
980        }
981        let mut deferred = self
982            .deferred_reclamation
983            .lock()
984            .map_err(|_| PagerError::LockPoisoned)?;
985        deferred.pending.extend(pages);
986        deferred.published.extend(pages);
987        Ok(())
988    }
989
990    /// The caller has durably published a catalog that retains `retained`
991    /// and no longer names `released`, after waiting for old tree readers.
992    pub(crate) fn complete_deferred_publication(
993        &self,
994        retained: &[u32],
995        released: &[u32],
996    ) -> Result<(), PagerError> {
997        let mut deferred = self
998            .deferred_reclamation
999            .lock()
1000            .map_err(|_| PagerError::LockPoisoned)?;
1001        if !released.is_empty() {
1002            self.free_retired_pages(released)?;
1003        }
1004        for id in released {
1005            deferred.pending.remove(id);
1006        }
1007        deferred.published = retained.iter().copied().collect();
1008        Ok(())
1009    }
1010
1011    /// Return a fully validated, unpublished tree generation in one allocator
1012    /// mutation. Call only after its removal from the manifest is durable.
1013    pub(crate) fn free_retired_pages(&self, pages: &[u32]) -> Result<(), PagerError> {
1014        if self.config.read_only {
1015            return Err(PagerError::ReadOnly);
1016        }
1017        let page_count = self.page_count()?;
1018        if pages
1019            .iter()
1020            .any(|id| *id < FIRST_ALLOCATABLE_PAGE || *id >= page_count)
1021        {
1022            return Err(PagerError::InvalidDatabase(
1023                "invalid retired page range".into(),
1024            ));
1025        }
1026        let mut freelist = self.freelist_write()?;
1027        for id in pages {
1028            self.cache.remove(*id);
1029        }
1030        freelist.free_batch(pages);
1031        *self.header_dirty_lock()? = true;
1032        Ok(())
1033    }
1034
1035    /// Get database header
1036    /// Pages available to the allocator, including pages on unloaded trunks.
1037    pub fn free_page_count(&self) -> Result<u32, PagerError> {
1038        Ok(self.freelist_write()?.total_free())
1039    }
1040
1041    pub fn header(&self) -> Result<DatabaseHeader, PagerError> {
1042        Ok(self.header_read()?.clone())
1043    }
1044
1045    pub fn physical_header(&self) -> Result<PhysicalFileHeader, PagerError> {
1046        Ok(self.header_read()?.physical)
1047    }
1048
1049    pub fn update_physical_header(&self, physical: PhysicalFileHeader) -> Result<(), PagerError> {
1050        if self.config.read_only {
1051            return Err(PagerError::ReadOnly);
1052        }
1053
1054        let mut header = self.header_write()?;
1055        header.physical = physical;
1056        *self.header_dirty_lock()? = true;
1057        Ok(())
1058    }
1059
1060    /// Get page count
1061    pub fn page_count(&self) -> Result<u32, PagerError> {
1062        Ok(self.header_read()?.page_count)
1063    }
1064
1065    /// Attach a WAL writer to enforce WAL-first flush ordering.
1066    ///
1067    /// After this call, [`Pager::flush`] computes the maximum
1068    /// `header.lsn` over all dirty pages and calls
1069    /// `WalWriter::flush_until(max_lsn)` before any page is written
1070    /// to the data file. This is the postgres rule: a page on disk
1071    /// implies its WAL record is already durable on disk.
1072    ///
1073    /// Existing call sites that construct a Pager without a WAL
1074    /// keep their previous behaviour (no LSN check) — wiring is
1075    /// strictly opt-in.
1076    pub fn set_wal_writer(&self, wal: Arc<Mutex<crate::storage::wal::writer::WalWriter>>) {
1077        let mut slot = self.wal.write().unwrap_or_else(|p| p.into_inner());
1078        *slot = Some(wal);
1079    }
1080
1081    /// Detach the WAL writer (test / shutdown path).
1082    pub fn clear_wal_writer(&self) {
1083        let mut slot = self.wal.write().unwrap_or_else(|p| p.into_inner());
1084        *slot = None;
1085    }
1086
1087    /// Has a WAL writer been attached?
1088    pub fn has_wal_writer(&self) -> bool {
1089        self.wal.read().map(|s| s.is_some()).unwrap_or(false)
1090    }
1091
1092    /// Flush all dirty pages to disk
1093    pub fn flush(&self) -> Result<(), PagerError> {
1094        if self.config.read_only {
1095            return Ok(());
1096        }
1097
1098        // Persist freelist to trunk pages when dirty
1099        let trunks = {
1100            let mut freelist = self.freelist_write()?;
1101            if freelist.is_dirty() {
1102                let mut header = self.header_write()?;
1103                let trunks = freelist.flush_to_trunks(0, || {
1104                    let id = header.page_count;
1105                    header.page_count += 1;
1106                    id
1107                });
1108                header.freelist_head = freelist.trunk_head();
1109                *self.header_dirty_lock()? = true;
1110                freelist.mark_clean();
1111                trunks
1112            } else {
1113                Vec::new()
1114            }
1115        };
1116
1117        let mut dirty_pages = Vec::new();
1118        for trunk in trunks {
1119            let page_id = trunk.page_id();
1120            if let Some(evicted) = self.cache.insert(page_id, trunk) {
1121                // Trunk publication can evict dirty data. Keep it in the same
1122                // WAL-first, double-write-protected batch as resident pages.
1123                dirty_pages.push((evicted.page_id(), evicted));
1124            }
1125            self.cache.mark_dirty(page_id);
1126        }
1127
1128        // Flush dirty pages from cache (through DWB if enabled)
1129        dirty_pages.extend(self.cache.flush_dirty());
1130        if !dirty_pages.is_empty() {
1131            // WAL-FIRST: ensure every WAL record describing a dirty
1132            // page is durable BEFORE the page itself reaches disk.
1133            // Pages with `lsn == 0` are exempt (freelist trunks, header
1134            // shadow pages, anything not produced by a WAL append).
1135            let max_lsn = dirty_pages
1136                .iter()
1137                .filter_map(|(_, page)| page.header().ok().map(|h| h.lsn))
1138                .max()
1139                .unwrap_or(0);
1140            if max_lsn > 0 {
1141                if let Ok(slot) = self.wal.read() {
1142                    if let Some(wal) = slot.as_ref() {
1143                        let wal = Arc::clone(wal);
1144                        // Drop the read lock before taking the WAL
1145                        // mutex so an unrelated reader cannot block
1146                        // the flush path.
1147                        drop(slot);
1148                        let mut wal_guard = wal.lock().unwrap_or_else(|p| p.into_inner());
1149                        wal_guard.flush_until(max_lsn).map_err(PagerError::Io)?;
1150                    }
1151                }
1152            }
1153            self.write_pages_through_dwb(&dirty_pages)?;
1154        }
1155
1156        // Write header if dirty
1157        if *self.header_dirty_lock()? {
1158            self.write_header()?;
1159        }
1160
1161        Ok(())
1162    }
1163
1164    /// Sync file to disk (fsync)
1165    pub fn sync(&self) -> Result<(), PagerError> {
1166        self.flush()?;
1167
1168        if self
1169            .allocation_reused
1170            .swap(false, std::sync::atomic::Ordering::AcqRel)
1171        {
1172            // flush published the current allocator in one slot. Mirror that
1173            // ownership before callers publish roots that use reclaimed pages;
1174            // recovery through the sibling slot must never allocate them again.
1175            if let Err(error) = self.write_header_and_sync() {
1176                self.allocation_reused
1177                    .store(true, std::sync::atomic::Ordering::Release);
1178                return Err(error);
1179            }
1180        }
1181
1182        let file = self.file_lock()?;
1183        file.sync_all()?;
1184
1185        Ok(())
1186    }
1187
1188    /// Get cache statistics
1189    pub fn cache_stats(&self) -> crate::storage::engine::page_cache::CacheStats {
1190        self.cache.stats()
1191    }
1192
1193    /// Pages currently resident in the page cache. Slots are fixed size, so
1194    /// this times the page size is the cache's memory footprint (ADR 0073 §2).
1195    pub fn cache_len(&self) -> usize {
1196        self.cache.len()
1197    }
1198
1199    /// Preallocated page-cache slots — the sizing this process derived from
1200    /// its page-cache budget share.
1201    pub fn cache_capacity(&self) -> usize {
1202        self.cache.capacity()
1203    }
1204
1205    /// Count dirty pages currently in the page cache.
1206    pub fn dirty_page_count(&self) -> usize {
1207        self.cache.dirty_count()
1208    }
1209
1210    /// Estimated fraction of the page cache holding dirty pages.
1211    /// Returned in `[0, 1]`. Used by the background writer to
1212    /// decide when to kick in aggressive flushing.
1213    pub fn dirty_fraction(&self) -> f64 {
1214        let capacity = self.cache.capacity().max(1) as f64;
1215        self.cache.dirty_count() as f64 / capacity
1216    }
1217
1218    /// Flush up to `max` dirty pages from the cache. Returns the
1219    /// number actually written. Background-writer entry point —
1220    /// reuses the same persistence path as `flush()` but bounded.
1221    pub fn flush_some_dirty(&self, max: usize) -> Result<usize, PagerError> {
1222        if self.config.read_only || max == 0 {
1223            return Ok(0);
1224        }
1225        let dirty_pages = self.cache.flush_some_dirty(max);
1226        if dirty_pages.is_empty() {
1227            return Ok(0);
1228        }
1229        let count = dirty_pages.len();
1230        // WAL-first: every cached dirty page carries an LSN that the
1231        // WAL must have already persisted. The full `flush()` path
1232        // enforces this with `wal.flush(max_lsn)`; here we simply
1233        // write through the pager — safe because callers only reach
1234        // this path via the bgwriter, which runs asynchronously
1235        // alongside normal commits that already respect WAL-first.
1236        for (page_id, page) in dirty_pages {
1237            self.write_page(page_id, page)?;
1238        }
1239        Ok(count)
1240    }
1241
1242    /// Get database file path
1243    pub fn path(&self) -> &Path {
1244        &self.path
1245    }
1246
1247    /// Check if database is read-only
1248    pub fn is_read_only(&self) -> bool {
1249        self.config.read_only
1250    }
1251
1252    /// Get file size in bytes
1253    pub fn file_size(&self) -> Result<u64, PagerError> {
1254        let file = self.file_lock()?;
1255        Ok(file.metadata()?.len())
1256    }
1257
1258    /// Issue an OS-level read-ahead hint for `page_id`.
1259    ///
1260    /// A6 prefetch wire: called from `BTreeCursor::next` when the
1261    /// cursor passes 50% of the current leaf, so the kernel fetches
1262    /// the next leaf page while CPU processes the remaining half of
1263    /// the current one. Failures are silent — a missed prefetch is a
1264    /// performance miss, never a correctness bug.
1265    pub fn prefetch_hint(&self, page_id: u32) {
1266        if let Ok(file) = self.file_lock() {
1267            let _ = super::super::prefetch::prefetch_page(&file, page_id as u64, PAGE_SIZE as u32);
1268        }
1269    }
1270
1271    // ── Corruption defense helpers ──────────────────────────────────
1272
1273    /// Path for the double-write buffer file
1274    fn dwb_path(db_path: &Path) -> PathBuf {
1275        reddb_file::layout::pager_dwb_shadow_path(db_path)
1276    }
1277
1278    fn dwb_zone_offset() -> u64 {
1279        DWB_ZONE_START_PAGE as u64 * PAGE_SIZE as u64
1280    }
1281
1282    fn write_dwb_zone_frame(&self, buf: &[u8]) -> Result<(), PagerError> {
1283        if buf.len() > DWB_ZONE_BYTES {
1284            return Err(PagerError::InvalidDatabase(
1285                "DWB frame exceeds the reserved in-file zone".to_string(),
1286            ));
1287        }
1288        let mut file = self.file_lock()?;
1289        file.seek(SeekFrom::Start(Self::dwb_zone_offset()))?;
1290        file.write_all(buf)?;
1291        file.sync_all()?;
1292        Ok(())
1293    }
1294
1295    /// Clear the in-file DWB zone with valid reserved pages.
1296    fn clear_dwb_zone(&self) -> Result<(), PagerError> {
1297        let mut file = self.file_lock()?;
1298        for page_id in DWB_ZONE_START_PAGE..FIRST_ALLOCATABLE_PAGE {
1299            let mut page = Page::new(PageType::Header, page_id);
1300            page.update_checksum();
1301            file.seek(SeekFrom::Start(page_id as u64 * PAGE_SIZE as u64))?;
1302            file.write_all(page.as_bytes())?;
1303        }
1304        file.sync_all()?;
1305        Ok(())
1306    }
1307
1308    fn dwb_zone_is_clear(buf: &[u8]) -> bool {
1309        if buf.len() != DWB_ZONE_BYTES {
1310            return false;
1311        }
1312        for (idx, chunk) in buf.chunks_exact(PAGE_SIZE).enumerate() {
1313            let Ok(page) = Page::from_slice(chunk) else {
1314                return false;
1315            };
1316            if page.verify_checksum().is_err() {
1317                return false;
1318            }
1319            if page.page_id() != DWB_ZONE_START_PAGE + idx as u32 {
1320                return false;
1321            }
1322            if page.page_type().ok() != Some(PageType::Header) {
1323                return false;
1324            }
1325        }
1326        true
1327    }
1328
1329    /// Read the internal manifest page (page 1), the zone the superblock roots.
1330    ///
1331    /// ADR 0074 §2: manifest corruption fails the open didactically, naming the
1332    /// zone — it never falls back to a sidecar and never returns garbage.
1333    pub fn read_manifest_page(&self) -> Result<Page, PagerError> {
1334        self.read_page(1)
1335            .map_err(|_| PagerError::ManifestZoneCorrupt(self.path.clone()))
1336    }
1337
1338    /// Write pages through the double-write buffer for torn page protection.
1339    ///
1340    /// 1. Write pages to the in-file DWB zone with a header (magic + count + checksum)
1341    /// 2. fsync the DWB zone
1342    /// 3. Write all pages to their final locations in the .rdb file
1343    /// 4. Clear the DWB zone (marks as consumed)
1344    fn write_pages_through_dwb(&self, pages: &[(u32, Page)]) -> Result<(), PagerError> {
1345        if self.dwb_enabled {
1346            for chunk in pages.chunks(DWB_MAX_PAGES_PER_FRAME.max(1)) {
1347                let buf = reddb_file::encode_paged_dwb_frame(
1348                    chunk
1349                        .iter()
1350                        .map(|(page_id, page)| (*page_id, page.as_bytes())),
1351                );
1352
1353                // Write DWB zone and fsync
1354                self.write_dwb_zone_frame(&buf)?;
1355
1356                // Now write pages to their final locations
1357                for (page_id, page) in chunk {
1358                    self.write_page_raw(*page_id, page)?;
1359                }
1360                {
1361                    let file = self.file_lock()?;
1362                    file.sync_all()?;
1363                }
1364
1365                // Reset the zone to its inactive page images.
1366                self.clear_dwb_zone()?;
1367            }
1368
1369            Ok(())
1370        } else {
1371            // DWB disabled — write directly
1372            for (page_id, page) in pages {
1373                self.write_page_raw(*page_id, page)?;
1374            }
1375            Ok(())
1376        }
1377    }
1378
1379    /// Recover from double-write buffer after a crash.
1380    ///
1381    /// If the DWB zone contains valid pages, they were written before the crash
1382    /// interrupted writing to their home locations. Re-apply them.
1383    fn recover_from_dwb(&self) -> Result<(), PagerError> {
1384        let len = {
1385            let file = self.file_lock()?;
1386            file.metadata()?.len()
1387        };
1388        let zone_end = Self::dwb_zone_offset() + DWB_ZONE_BYTES as u64;
1389        if len < zone_end {
1390            return Ok(());
1391        }
1392
1393        let mut buf = vec![0u8; DWB_ZONE_BYTES];
1394        {
1395            let mut file = self.file_lock()?;
1396            file.seek(SeekFrom::Start(Self::dwb_zone_offset()))?;
1397            file.read_exact(&mut buf)?;
1398        }
1399        if buf.get(0..4) != Some(reddb_file::DWB_MAGIC.as_slice()) {
1400            if Self::dwb_zone_is_clear(&buf) {
1401                return Ok(());
1402            }
1403            self.clear_dwb_zone()?;
1404            return Ok(());
1405        }
1406
1407        let entries = match reddb_file::decode_paged_dwb_frame(&buf) {
1408            Ok(entries) => entries,
1409            Err(_) => {
1410                self.clear_dwb_zone()?;
1411                return Ok(());
1412            }
1413        };
1414
1415        // DWB is valid — re-apply pages to main file
1416        for entry in entries {
1417            let page = Page::from_bytes(entry.page);
1418            self.write_page_raw(entry.page_id, &page)?;
1419        }
1420
1421        // Sync and clean up
1422        {
1423            let file = self.file_lock()?;
1424            file.sync_all()?;
1425        }
1426
1427        self.clear_dwb_zone()
1428    }
1429
1430    /// Finish a consumed trunk's durable ownership change, retaining errors
1431    /// across allocation attempts until both copies have been synchronized.
1432    /// Called with the freelist locked when consuming or retrying a trunk.
1433    fn complete_allocation_fence(&self) -> Result<(), PagerError> {
1434        if self
1435            .allocation_fence_pending
1436            .load(std::sync::atomic::Ordering::Acquire)
1437        {
1438            self.write_header_and_sync()?;
1439            self.write_header_and_sync()?;
1440            self.allocation_fence_pending
1441                .store(false, std::sync::atomic::Ordering::Release);
1442        }
1443        Ok(())
1444    }
1445
1446    /// Write header and sync to disk (public for checkpointer).
1447    pub fn write_header_and_sync(&self) -> Result<(), PagerError> {
1448        self.write_header()?;
1449        let file = self.file_lock()?;
1450        file.sync_all()?;
1451        Ok(())
1452    }
1453
1454    /// Set the checkpoint_in_progress flag in the header.
1455    pub fn set_checkpoint_in_progress(
1456        &self,
1457        in_progress: bool,
1458        target_lsn: u64,
1459    ) -> Result<(), PagerError> {
1460        let mut header = self.header_write()?;
1461        header.checkpoint_in_progress = in_progress;
1462        header.checkpoint_target_lsn = target_lsn;
1463        *self.header_dirty_lock()? = true;
1464        drop(header);
1465        self.write_header_and_sync()
1466    }
1467
1468    /// Update the checkpoint LSN and clear the in-progress flag.
1469    pub fn complete_checkpoint(&self, lsn: u64) -> Result<(), PagerError> {
1470        let mut header = self.header_write()?;
1471        header.checkpoint_lsn = lsn;
1472        header.checkpoint_in_progress = false;
1473        header.checkpoint_target_lsn = 0;
1474        *self.header_dirty_lock()? = true;
1475        drop(header);
1476        self.write_header_and_sync()
1477    }
1478}
1479
1480#[cfg(test)]
1481mod tests {
1482    use super::*;
1483
1484    fn temp_db_path(name: &str) -> PathBuf {
1485        std::env::temp_dir().join(format!(
1486            "reddb-pager-{}-{}-{}.rdb",
1487            name,
1488            std::process::id(),
1489            crate::utils::now_unix_nanos()
1490        ))
1491    }
1492
1493    fn cleanup(path: &Path) {
1494        let _ = std::fs::remove_file(path);
1495        let _ = std::fs::remove_file(Pager::dwb_path(path));
1496    }
1497
1498    fn read_zone(path: &Path) -> Vec<u8> {
1499        let mut zone = vec![0u8; SUPERBLOCK_ZONE_SIZE];
1500        let mut file = File::open(path).expect("open() should succeed");
1501        file.read_exact(&mut zone)
1502            .expect("read_exact() should succeed");
1503        zone
1504    }
1505
1506    /// Overwrite one superblock slot in place, leaving the sibling untouched.
1507    fn poke_slot(path: &Path, copy_index: usize, bytes: &[u8]) {
1508        let mut file = OpenOptions::new()
1509            .write(true)
1510            .open(path)
1511            .expect("open() should succeed");
1512        file.seek(SeekFrom::Start(reddb_file::paged_superblock_slot_offset(
1513            copy_index,
1514        )))
1515        .expect("operation should succeed");
1516        file.write_all(bytes).expect("write_all() should succeed");
1517        file.sync_all().expect("sync_all() should succeed");
1518    }
1519
1520    fn stamp_pre_phase3_layout(path: &Path, page_count: u32) {
1521        let zone = read_zone(path);
1522        for copy_index in 0..reddb_file::PAGED_SUPERBLOCK_SLOT_COUNT {
1523            let start = copy_index * SUPERBLOCK_SLOT_SIZE;
1524            let mut slot = zone[start..start + SUPERBLOCK_SLOT_SIZE].to_vec();
1525            let generation = reddb_file::paged_superblock_slot_generation(&slot, copy_index)
1526                .expect("fresh store should publish both slots");
1527            reddb_file::set_database_header_version(&mut slot, PHASE3_DWB_ZONE_FILE_VERSION - 1)
1528                .expect("set_database_header_version() should succeed");
1529            reddb_file::set_database_header_page_count(&mut slot, page_count)
1530                .expect("set_database_header_page_count() should succeed");
1531            reddb_file::seal_paged_superblock_slot(&mut slot, copy_index, generation)
1532                .expect("seal_paged_superblock_slot() should succeed");
1533            poke_slot(path, copy_index, &slot);
1534        }
1535    }
1536
1537    #[test]
1538    fn open_refuses_future_database_version() {
1539        let path = temp_db_path("future-version");
1540        let pager = Pager::open_default(&path).expect("open_default() should succeed");
1541        drop(pager);
1542
1543        // A *valid* superblock slot — correct magic, copy index and CRC — whose
1544        // database header names a version we cannot read. The slot is intact;
1545        // the header is from the future.
1546        let selection = reddb_file::select_paged_superblock(&read_zone(&path))
1547            .expect("operation should succeed");
1548        let mut slot = vec![0u8; SUPERBLOCK_SLOT_SIZE];
1549        reddb_file::init_database_header_page(&mut slot, 1)
1550            .expect("init_database_header_page() should succeed");
1551        reddb_file::set_database_header_version(&mut slot, reddb_file::PAGE_FILE_VERSION + 1)
1552            .expect("operation should succeed");
1553        reddb_file::seal_paged_superblock_slot(
1554            &mut slot,
1555            selection.copy_index,
1556            selection.generation + 1,
1557        )
1558        .expect("operation should succeed");
1559        poke_slot(&path, selection.copy_index, &slot);
1560
1561        let err = match Pager::open_default(&path) {
1562            Ok(_) => panic!("future database version should be rejected"),
1563            Err(err) => err,
1564        };
1565        match err {
1566            PagerError::InvalidDatabase(msg) => {
1567                assert!(msg.contains("newer than supported"), "{msg}");
1568            }
1569            other => panic!("expected InvalidDatabase, got {other:?}"),
1570        }
1571
1572        cleanup(&path);
1573    }
1574
1575    #[test]
1576    fn a_fresh_store_publishes_both_superblock_copies() {
1577        // The ping-pong invariant must hold from the very first update, which
1578        // means creation cannot leave one copy blank.
1579        let path = temp_db_path("fresh-pair");
1580        let pager = Pager::open_default(&path).expect("open_default() should succeed");
1581        drop(pager);
1582
1583        let zone = read_zone(&path);
1584        for copy_index in 0..reddb_file::PAGED_SUPERBLOCK_SLOT_COUNT {
1585            let start = copy_index * SUPERBLOCK_SLOT_SIZE;
1586            assert!(
1587                reddb_file::paged_superblock_slot_generation(
1588                    &zone[start..start + SUPERBLOCK_SLOT_SIZE],
1589                    copy_index
1590                )
1591                .is_some(),
1592                "copy {copy_index} must be valid on a fresh store"
1593            );
1594        }
1595        cleanup(&path);
1596    }
1597
1598    #[test]
1599    fn an_update_writes_the_stale_copy_and_leaves_the_newest_one_intact() {
1600        let path = temp_db_path("ping-pong");
1601        let pager = Pager::open_default(&path).expect("open_default() should succeed");
1602        let before = reddb_file::select_paged_superblock(&read_zone(&path))
1603            .expect("operation should succeed");
1604
1605        pager
1606            .allocate_page(PageType::BTreeLeaf)
1607            .expect("allocate_page() should succeed");
1608        pager.sync().expect("sync() should succeed");
1609
1610        let zone = read_zone(&path);
1611        let after = reddb_file::select_paged_superblock(&zone)
1612            .expect("select_paged_superblock() should succeed");
1613        assert_ne!(
1614            after.copy_index, before.copy_index,
1615            "an update must target the stale copy"
1616        );
1617        assert!(after.generation > before.generation);
1618
1619        // The copy that was newest before the update is still readable — this
1620        // is the byte-level statement of "a crash mid-write always leaves one
1621        // valid copy".
1622        let start = before.copy_index * SUPERBLOCK_SLOT_SIZE;
1623        assert_eq!(
1624            reddb_file::paged_superblock_slot_generation(
1625                &zone[start..start + SUPERBLOCK_SLOT_SIZE],
1626                before.copy_index
1627            ),
1628            Some(before.generation)
1629        );
1630
1631        drop(pager);
1632        cleanup(&path);
1633    }
1634
1635    #[test]
1636    fn failed_trunk_fence_is_completed_before_retrying_allocation() {
1637        let path = temp_db_path("retry-trunk-fence");
1638        let pager = Pager::open_default(&path).expect("pager");
1639        let mut pages = Vec::new();
1640        for _ in 0..8 {
1641            pages.push(pager.allocate_page(PageType::BTreeLeaf).unwrap().page_id());
1642        }
1643        pager.sync().unwrap();
1644        for id in pages {
1645            pager.free_page(id).unwrap();
1646        }
1647        pager.sync().unwrap();
1648        let trunk = pager.header().unwrap().freelist_head;
1649        assert_ne!(trunk, 0);
1650
1651        // A real write error after the trunk has been read must not turn a
1652        // subsequent allocation into an unfenced fast-path reuse.
1653        let writable = {
1654            let mut file = pager.file_lock().unwrap();
1655            std::mem::replace(&mut *file, File::open(&path).unwrap())
1656        };
1657        assert!(pager.allocate_page(PageType::BTreeLeaf).is_err());
1658        *pager.file_lock().unwrap() = writable;
1659        pager.allocate_page(PageType::BTreeLeaf).unwrap();
1660
1661        let zone = read_zone(&path);
1662        for copy in 0..reddb_file::PAGED_SUPERBLOCK_SLOT_COUNT {
1663            let start = copy * SUPERBLOCK_SLOT_SIZE;
1664            let header =
1665                reddb_file::decode_database_header(&zone[start..start + SUPERBLOCK_SLOT_SIZE])
1666                    .unwrap();
1667            assert_ne!(
1668                header.freelist_head, trunk,
1669                "copy {copy} still owns consumed trunk"
1670            );
1671        }
1672        drop(pager);
1673        cleanup(&path);
1674    }
1675
1676    #[test]
1677    fn sibling_superblock_recovery_does_not_reallocate_live_reused_pages() {
1678        let path = temp_db_path("reused-allocator-pair");
1679        let pager = Pager::open_default(&path).expect("pager");
1680        let mut pages = Vec::new();
1681        for _ in 0..32 {
1682            pages.push(
1683                pager
1684                    .allocate_page(PageType::BTreeLeaf)
1685                    .expect("allocate")
1686                    .page_id(),
1687            );
1688        }
1689        pager.sync().expect("seed");
1690        for id in pages {
1691            pager.free_page(id).expect("free");
1692        }
1693        pager.sync().expect("publish free list");
1694        let mut live = std::collections::HashSet::new();
1695        for _ in 0..8 {
1696            let page = pager.allocate_page(PageType::BTreeLeaf).expect("reuse");
1697            live.insert(page.page_id());
1698            pager
1699                .write_page(page.page_id(), page)
1700                .expect("write valid page");
1701        }
1702        pager.sync().expect("publish reused ownership");
1703        drop(pager);
1704        let selection = reddb_file::select_paged_superblock(&read_zone(&path)).expect("superblock");
1705        poke_slot(&path, selection.copy_index, &[0; SUPERBLOCK_SLOT_SIZE]);
1706        let recovered = Pager::open_default(&path).expect("sibling recovery");
1707        for _ in 0..64 {
1708            let id = recovered
1709                .allocate_page(PageType::BTreeLeaf)
1710                .expect("allocate after recovery")
1711                .page_id();
1712            assert!(
1713                !live.contains(&id),
1714                "sibling allocator still considers live page {id} free"
1715            );
1716        }
1717        drop(recovered);
1718        cleanup(&path);
1719    }
1720
1721    #[test]
1722    fn open_recovers_from_the_older_copy_when_the_newest_is_torn() {
1723        let path = temp_db_path("torn-newest");
1724        {
1725            let pager = Pager::open_default(&path).expect("open_default() should succeed");
1726            pager
1727                .allocate_page(PageType::BTreeLeaf)
1728                .expect("allocate_page() should succeed");
1729            pager.sync().expect("sync() should succeed");
1730        }
1731
1732        let newest = reddb_file::select_paged_superblock(&read_zone(&path))
1733            .expect("operation should succeed");
1734        // Tear the newest copy: a torn write leaves its CRC broken.
1735        let mut torn = vec![0u8; SUPERBLOCK_SLOT_SIZE];
1736        torn.copy_from_slice(
1737            &read_zone(&path)[newest.copy_index * SUPERBLOCK_SLOT_SIZE
1738                ..newest.copy_index * SUPERBLOCK_SLOT_SIZE + SUPERBLOCK_SLOT_SIZE],
1739        );
1740        torn[64] ^= 0xFF;
1741        poke_slot(&path, newest.copy_index, &torn);
1742
1743        let pager = Pager::open_default(&path).expect("older copy must root the store");
1744        assert!(pager.page_count().expect("page_count() should succeed") >= 3);
1745        drop(pager);
1746        cleanup(&path);
1747    }
1748
1749    #[test]
1750    fn open_refuses_didactically_when_both_superblock_copies_are_invalid() {
1751        let path = temp_db_path("both-invalid");
1752        {
1753            let _ = Pager::open_default(&path).expect("open_default() should succeed");
1754        }
1755
1756        let zone = read_zone(&path);
1757        for copy_index in 0..reddb_file::PAGED_SUPERBLOCK_SLOT_COUNT {
1758            let start = copy_index * SUPERBLOCK_SLOT_SIZE;
1759            let mut slot = zone[start..start + SUPERBLOCK_SLOT_SIZE].to_vec();
1760            slot[64] ^= 0xFF;
1761            poke_slot(&path, copy_index, &slot);
1762        }
1763
1764        let err = match Pager::open_default(&path) {
1765            Ok(_) => panic!("a rootless store must not open"),
1766            Err(err) => err,
1767        };
1768        let rendered = err.to_string();
1769        assert!(matches!(err, PagerError::SuperblockZoneUnrecoverable(_)));
1770        assert!(rendered.contains("superblock zone"), "{rendered}");
1771        assert!(rendered.contains("salvage"), "{rendered}");
1772
1773        cleanup(&path);
1774    }
1775
1776    #[test]
1777    fn open_refuses_a_legacy_sidecar_store_and_names_the_migration_tool() {
1778        let path = temp_db_path("legacy-sidecar");
1779        {
1780            let _ = Pager::open_default(&path).expect("open_default() should succeed");
1781        }
1782
1783        let sidecar = reddb_file::layout::retired::pager_header_path_v0(&path);
1784        std::fs::write(&sidecar, b"legacy header shadow").expect("write() should succeed");
1785
1786        let err = match Pager::open_default(&path) {
1787            Ok(_) => panic!("legacy stores need migration first"),
1788            Err(err) => err,
1789        };
1790        let rendered = err.to_string();
1791        assert!(matches!(err, PagerError::LegacySidecarStore { .. }));
1792        assert!(rendered.contains("migration tool"), "{rendered}");
1793
1794        let _ = std::fs::remove_file(&sidecar);
1795        cleanup(&path);
1796    }
1797
1798    #[test]
1799    fn pre_phase3_large_store_without_sidecar_routes_to_migration() {
1800        let path = temp_db_path("pre-phase3-large");
1801        {
1802            let _ = Pager::open_default(&path).expect("open_default() should succeed");
1803        }
1804        stamp_pre_phase3_layout(&path, FIRST_ALLOCATABLE_PAGE);
1805
1806        let err = match Pager::open_default(&path) {
1807            Ok(_) => panic!("pre-phase-3 stores need migration first"),
1808            Err(err) => err,
1809        };
1810        let rendered = err.to_string();
1811        assert!(matches!(err, PagerError::LegacyPagedStore { .. }));
1812        assert!(rendered.contains("migration tool"), "{rendered}");
1813
1814        cleanup(&path);
1815    }
1816
1817    #[test]
1818    fn pre_phase3_small_store_without_sidecar_routes_to_migration() {
1819        let path = temp_db_path("pre-phase3-small");
1820        {
1821            let _ = Pager::open_default(&path).expect("open_default() should succeed");
1822        }
1823        stamp_pre_phase3_layout(&path, 3);
1824        OpenOptions::new()
1825            .write(true)
1826            .open(&path)
1827            .expect("open() should succeed")
1828            .set_len(3 * PAGE_SIZE as u64)
1829            .expect("set_len() should succeed");
1830
1831        let err = match Pager::open_default(&path) {
1832            Ok(_) => panic!("pre-phase-3 stores need migration first"),
1833            Err(err) => err,
1834        };
1835        assert!(matches!(err, PagerError::LegacyPagedStore { .. }));
1836
1837        cleanup(&path);
1838    }
1839
1840    #[test]
1841    fn no_phase_one_sidecar_survives_a_create_write_reopen_cycle() {
1842        let path = temp_db_path("census");
1843        {
1844            let pager = Pager::open_default(&path).expect("open_default() should succeed");
1845            let page = pager
1846                .allocate_page(PageType::BTreeLeaf)
1847                .expect("allocate_page() should succeed");
1848            pager
1849                .write_page(page.page_id(), page)
1850                .expect("operation should succeed");
1851            pager.sync().expect("sync() should succeed");
1852        }
1853        {
1854            let pager = Pager::open_default(&path).expect("open_default() should succeed");
1855            pager.sync().expect("sync() should succeed");
1856        }
1857
1858        assert_eq!(
1859            reddb_file::layout::retired::first_present_phase1_sidecar(&path),
1860            None,
1861            "the pager must never create a retired phase-1 sidecar"
1862        );
1863        cleanup(&path);
1864    }
1865}