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#[cfg(test)]
8const PHASE3_DWB_ZONE_FILE_VERSION: u32 = 0x0001_0001;
9
10#[cfg(test)]
11pub(super) const SUPERBLOCK_SLOT_SIZE: usize = reddb_file::PAGED_SUPERBLOCK_SLOT_SIZE;
12#[cfg(test)]
13pub(super) const SUPERBLOCK_ZONE_SIZE: usize = reddb_file::PAGED_SUPERBLOCK_ZONE_SIZE;
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub(super) enum CowFilesystemKind {
17    Zfs,
18    BtrfsDataCow,
19    TestOverride,
20}
21
22pub(super) fn classify_cow_filesystem(
23    fs_type: i64,
24    mount_options: Option<&str>,
25    inode_flags: Option<u64>,
26) -> Option<CowFilesystemKind> {
27    match fs_type {
28        ZFS_SUPER_MAGIC => Some(CowFilesystemKind::Zfs),
29        BTRFS_SUPER_MAGIC => {
30            let mount_options = mount_options?;
31            if mount_options.split(',').any(|option| option == "nodatacow") {
32                return None;
33            }
34
35            let inode_flags = inode_flags?;
36            if inode_flags & FS_NOCOW_FL != 0 {
37                return None;
38            }
39
40            Some(CowFilesystemKind::BtrfsDataCow)
41        }
42        _ => None,
43    }
44}
45
46#[cfg(target_os = "linux")]
47fn linux_fstatfs_type(file: &File) -> Option<i64> {
48    use std::mem::MaybeUninit;
49    use std::os::fd::AsRawFd;
50
51    let mut stat = MaybeUninit::<libc::statfs>::uninit();
52    let rc = unsafe { libc::fstatfs(file.as_raw_fd(), stat.as_mut_ptr()) };
53    if rc != 0 {
54        return None;
55    }
56    let stat = unsafe { stat.assume_init() };
57    // `statfs.f_type` is i32 on 32-bit glibc targets (armv7) and i64 on
58    // 64-bit ones — widen explicitly so both compile.
59    #[allow(clippy::unnecessary_cast)]
60    Some(stat.f_type as i64)
61}
62
63#[cfg(target_os = "linux")]
64fn linux_inode_flags(file: &File) -> Option<u64> {
65    use std::os::fd::AsRawFd;
66
67    let mut flags: libc::c_long = 0;
68    let rc = unsafe { libc::ioctl(file.as_raw_fd(), libc::FS_IOC_GETFLAGS, &mut flags) };
69    if rc != 0 {
70        return None;
71    }
72    Some(flags as u64)
73}
74
75#[cfg(target_os = "linux")]
76fn linux_mount_options_for_path(path: &Path) -> Option<String> {
77    let path = path.canonicalize().ok()?;
78    let mountinfo = std::fs::read_to_string("/proc/self/mountinfo").ok()?;
79    parse_mountinfo_options_for_path(&mountinfo, &path)
80}
81
82#[cfg(target_os = "linux")]
83pub(super) fn parse_mountinfo_options_for_path(mountinfo: &str, path: &Path) -> Option<String> {
84    let mut best: Option<(usize, String)> = None;
85
86    for line in mountinfo.lines() {
87        let fields: Vec<&str> = line.split(' ').collect();
88        if fields.len() < 10 {
89            continue;
90        }
91
92        let Some(separator) = fields.iter().position(|field| *field == "-") else {
93            continue;
94        };
95        if separator + 3 >= fields.len() || separator < 6 {
96            continue;
97        }
98
99        let mount_point = mountinfo_unescape_path(fields[4]);
100        if !path.starts_with(&mount_point) {
101            continue;
102        }
103
104        let fs_type = fields[separator + 1];
105        if fs_type != "btrfs" && fs_type != "zfs" {
106            continue;
107        }
108
109        let mount_options = fields[5];
110        let super_options = fields[separator + 3];
111        let options = format!("{mount_options},{super_options}");
112        let depth = mount_point.components().count();
113        if best
114            .as_ref()
115            .map(|(best_depth, _)| depth > *best_depth)
116            .unwrap_or(true)
117        {
118            best = Some((depth, options));
119        }
120    }
121
122    best.map(|(_, options)| options)
123}
124
125#[cfg(target_os = "linux")]
126fn mountinfo_unescape_path(value: &str) -> PathBuf {
127    let bytes = value.as_bytes();
128    let mut out = Vec::with_capacity(bytes.len());
129    let mut i = 0;
130
131    while i < bytes.len() {
132        if bytes[i] == b'\\' && i + 3 < bytes.len() {
133            let octal = &value[i + 1..i + 4];
134            if let Ok(byte) = u8::from_str_radix(octal, 8) {
135                out.push(byte);
136                i += 4;
137                continue;
138            }
139        }
140        out.push(bytes[i]);
141        i += 1;
142    }
143
144    PathBuf::from(String::from_utf8_lossy(&out).into_owned())
145}
146
147impl Pager {
148    /// Open or create a database file
149    pub fn open<P: AsRef<Path>>(path: P, mut config: PagerConfig) -> Result<Self, PagerError> {
150        let path = path.as_ref().to_path_buf();
151        let exists = path.exists();
152
153        if !exists && !config.create {
154            return Err(PagerError::InvalidDatabase(
155                "Database does not exist".into(),
156            ));
157        }
158
159        // ADR 0038 §4 phase 1 is a clean break: a store still carrying the
160        // retired `rdb-hdr`/`rdb-meta` sidecars is never read by the engine.
161        // The offline migration tool is the only path forward.
162        if exists {
163            if let Some(sidecar) = reddb_file::layout::retired::first_present_phase1_sidecar(&path)
164            {
165                return Err(PagerError::LegacySidecarStore { sidecar });
166            }
167        }
168
169        if !exists && config.read_only {
170            return Err(PagerError::InvalidDatabase(
171                "Cannot create read-only database".into(),
172            ));
173        }
174
175        // Open file
176        // Note: create requires write access, so disable it for read-only mode
177        let file = OpenOptions::new()
178            .read(true)
179            .write(!config.read_only)
180            .create(config.create && !config.read_only)
181            .open(&path)?;
182
183        // gh-892: diagnostic probe of the filesystem block size. If the
184        // compile-time 16 KiB PAGE_SIZE is not a multiple of the FS block
185        // size, page writes straddle FS blocks and incur read-modify-write
186        // amplification. Pure diagnostic — emitted once per open, never
187        // mutates the page size. `blksize()` is the fstat `st_blksize`.
188        #[cfg(unix)]
189        {
190            use std::os::unix::fs::MetadataExt;
191            if let Ok(meta) = file.metadata() {
192                let fs_block_size = meta.blksize();
193                if Self::page_size_misaligned_with_block(PAGE_SIZE, fs_block_size) {
194                    tracing::warn!(
195                        page_size = PAGE_SIZE,
196                        fs_block_size,
197                        path = %path.display(),
198                        "database page size is not a multiple of the filesystem \
199                         block size; page writes will straddle FS blocks \
200                         (read-modify-write amplification). Diagnostic only — \
201                         the page size is unchanged."
202                    );
203                }
204            }
205        }
206
207        // Lock the same handle that performs page I/O. Windows byte-range
208        // locks deny reads/writes through a separately opened handle even in
209        // this process. The owned VFS handle holds the fence until it closes.
210        if !config.read_only {
211            file.try_lock_exclusive().map_err(|_| PagerError::Locked)?;
212        } else {
213            let _ = FileExt::try_lock_shared(&file);
214        }
215
216        // ADR 0038 phase 3: the DWB sidecar is retired. When DWB is active,
217        // the recovery frame is written to a reserved zone inside the data
218        // file and fsynced before in-place page writes. A pre-existing sidecar
219        // marks a legacy store that must go through the offline migration tool.
220        //
221        // gh-895: an explicit `double_write = false` request is honored only
222        // when the already-open data file is proven to live on a filesystem
223        // with atomic CoW page writes. Unknown and non-CoW filesystems fail
224        // closed by keeping the in-file DWB zone enabled.
225        let fold_dwb = crate::physical::fold_dwb_into_wal_enabled();
226        if exists {
227            if let Some(sidecar) =
228                reddb_file::layout::retired::first_present_phase3_dwb_sidecar(&path)
229            {
230                return Err(PagerError::LegacySidecarStore { sidecar });
231            }
232        }
233        if !config.double_write && !config.read_only && !fold_dwb {
234            let skip_dwb_on_cow =
235                Self::cow_filesystem_has_atomic_page_writes(&path, &file).is_some();
236            if !skip_dwb_on_cow {
237                tracing::warn!(
238                    path = %path.display(),
239                    "double_write=false requested, but the data file is not proven to be on \
240                     ZFS or btrfs datacow; keeping the in-file double-write buffer enabled"
241                );
242                config.double_write = true;
243            }
244        }
245        let dwb_enabled = config.double_write && !config.read_only && !fold_dwb;
246
247        let core = SharedPager::open_owned(
248            NativePagerFile(file),
249            path,
250            SharedPagerConfig {
251                cache_size: config.cache_size,
252                read_only: config.read_only,
253                verify_checksums: config.verify_checksums,
254                double_write: dwb_enabled,
255            },
256            NativeWalBarrier::default(),
257            !exists,
258        )?;
259        let mut pager = Self {
260            core,
261            config,
262            encryption: None,
263        };
264
265        if exists {
266            pager.bind_encryption_for_existing()?;
267        } else {
268            // Initialize new database
269            pager.bind_encryption_for_new()?;
270        }
271
272        Ok(pager)
273    }
274
275    /// gh-892 diagnostic predicate: returns `true` when the database
276    /// `page_size` is **not** an integer multiple of the filesystem's
277    /// reported block size (`st_blksize`), i.e. `page_size % fs_block_size
278    /// != 0`. A `fs_block_size` of `0` (probe unavailable / unknown) is
279    /// treated as aligned so a missing probe never produces a warning.
280    /// Pure function with no I/O so the warn decision is unit-testable.
281    pub(crate) fn page_size_misaligned_with_block(page_size: usize, fs_block_size: u64) -> bool {
282        fs_block_size != 0 && !(page_size as u64).is_multiple_of(fs_block_size)
283    }
284
285    #[cfg(test)]
286    fn cow_filesystem_test_override() -> Option<bool> {
287        match COW_ATOMIC_WRITE_TEST_OVERRIDE.load(Ordering::Relaxed) {
288            1 => Some(true),
289            2 => Some(false),
290            _ => None,
291        }
292    }
293
294    #[cfg(not(test))]
295    fn cow_filesystem_test_override() -> Option<bool> {
296        None
297    }
298
299    fn cow_filesystem_has_atomic_page_writes(
300        path: &Path,
301        file: &File,
302    ) -> Option<CowFilesystemKind> {
303        if let Some(allowed) = Self::cow_filesystem_test_override() {
304            return allowed.then_some(CowFilesystemKind::TestOverride);
305        }
306        Self::probe_cow_filesystem(path, file)
307    }
308
309    #[cfg(target_os = "linux")]
310    fn probe_cow_filesystem(path: &Path, file: &File) -> Option<CowFilesystemKind> {
311        let fs_type = linux_fstatfs_type(file)?;
312        match fs_type {
313            ZFS_SUPER_MAGIC => Some(CowFilesystemKind::Zfs),
314            BTRFS_SUPER_MAGIC => {
315                let mount_options = linux_mount_options_for_path(path)?;
316                let inode_flags = linux_inode_flags(file)?;
317                classify_cow_filesystem(fs_type, Some(&mount_options), Some(inode_flags))
318            }
319            _ => None,
320        }
321    }
322
323    #[cfg(not(target_os = "linux"))]
324    fn probe_cow_filesystem(_path: &Path, _file: &File) -> Option<CowFilesystemKind> {
325        None
326    }
327
328    /// Inspect page 0 for the `RDBE` encryption marker, then resolve
329    /// the (key, marker) matrix:
330    ///
331    /// | Marker | Key supplied | Result                              |
332    /// |--------|--------------|-------------------------------------|
333    /// | yes    | yes          | Bind encryptor; validate key        |
334    /// | yes    | no           | `EncryptionRequired` (fail closed)  |
335    /// | no     | yes          | `PlainDatabaseRefusesKey`           |
336    /// | no     | no           | Plain pager — no binding needed     |
337    fn bind_encryption_for_existing(&mut self) -> Result<(), PagerError> {
338        if self.page_count().unwrap_or(0) == 0 {
339            return self.bind_encryption_for_new();
340        }
341        let (_, image) = self.newest_superblock()?;
342        let data = &image[..];
343        let has_marker = reddb_file::paged_encryption_marker_present(data);
344
345        let key = self.config.encryption.clone();
346        match (has_marker, key) {
347            (true, Some(key)) => {
348                let header_bytes =
349                    reddb_file::paged_encryption_header_bytes(data).ok_or_else(|| {
350                        PagerError::InvalidDatabase("encryption header parse failed".to_string())
351                    })?;
352                let header = crate::storage::encryption::EncryptionHeader::from_bytes(header_bytes)
353                    .map_err(|e| {
354                        PagerError::InvalidDatabase(format!("encryption header parse failed: {e}"))
355                    })?;
356                if !header.validate(&key) {
357                    return Err(PagerError::InvalidKey);
358                }
359                let encryptor = crate::storage::encryption::PageEncryptor::new(key);
360                self.encryption = Some((encryptor, header));
361                Ok(())
362            }
363            (true, None) => Err(PagerError::EncryptionRequired),
364            (false, Some(_)) => Err(PagerError::PlainDatabaseRefusesKey),
365            (false, None) => Ok(()),
366        }
367    }
368
369    /// New DB: if a key is configured, write the marker + header to
370    /// page 0 so subsequent opens detect encryption.
371    fn bind_encryption_for_new(&mut self) -> Result<(), PagerError> {
372        let Some(key) = self.config.encryption.clone() else {
373            return Ok(());
374        };
375        let header = crate::storage::encryption::EncryptionHeader::new(&key);
376        let encryptor = crate::storage::encryption::PageEncryptor::new(key);
377
378        // Stamp the marker + header into the superblock zone if `initialize()`
379        // has already published it.
380        if self.page_count().unwrap_or(0) > 0 {
381            let header_bytes = header.to_bytes();
382            self.commit_superblock(|image| {
383                reddb_file::write_paged_encryption_marker_and_header(image, &header_bytes)
384                    .map_err(|err| PagerError::InvalidDatabase(err.to_string()))
385            })?;
386        }
387        self.encryption = Some((encryptor, header));
388        Ok(())
389    }
390
391    /// Open with default configuration
392    pub fn open_default<P: AsRef<Path>>(path: P) -> Result<Self, PagerError> {
393        Self::open(path, PagerConfig::default())
394    }
395
396    /// Read a page through the configured encryptor if any. Page 0
397    /// is always returned plaintext (it carries the encryption marker
398    /// + header). Callers that want raw cipher bytes can use
399    ///   `read_page_no_checksum` directly.
400    pub fn read_page_decrypted(&self, page_id: u32) -> Result<Page, PagerError> {
401        if page_id == 0 || self.encryption.is_none() {
402            return self.read_page(page_id);
403        }
404        let raw = self.read_page_no_checksum(page_id)?;
405        let (enc, _) = self
406            .encryption
407            .as_ref()
408            .expect("encryption presence checked above");
409        let plaintext = enc
410            .decrypt(page_id, raw.as_bytes())
411            .map_err(|e| PagerError::InvalidDatabase(format!("decrypt page {page_id}: {e}")))?;
412        let mut buf = [0u8; PAGE_SIZE];
413        let n = plaintext.len().min(PAGE_SIZE);
414        buf[..n].copy_from_slice(&plaintext[..n]);
415        Ok(Page::from_bytes(buf))
416    }
417
418    /// Write a page through the configured encryptor if any. Page 0
419    /// bypasses encryption and goes through the normal checksummed
420    /// path. Encrypted pages skip the checksum update because
421    /// AES-GCM's authentication tag is the integrity guarantee.
422    pub fn write_page_encrypted(&self, page_id: u32, page: Page) -> Result<(), PagerError> {
423        if page_id == 0 || self.encryption.is_none() {
424            return self.write_page(page_id, page);
425        }
426        // Canonical per-page envelope overhead (nonce + GCM tag),
427        // owned by reddb-io-crypto (#1053, ADR 0054).
428        const OVERHEAD: usize = crate::storage::encryption::page_encryptor::OVERHEAD;
429        let plaintext_len = PAGE_SIZE - OVERHEAD;
430        let plaintext = &page.as_bytes()[..plaintext_len];
431        let (enc, _) = self
432            .encryption
433            .as_ref()
434            .expect("encryption presence checked above");
435        let ciphertext = enc.encrypt(page_id, plaintext);
436        debug_assert_eq!(ciphertext.len(), PAGE_SIZE);
437        let mut buf = [0u8; PAGE_SIZE];
438        buf.copy_from_slice(&ciphertext);
439        let cipher_page = Page::from_bytes(buf);
440        self.write_page_no_checksum(page_id, cipher_page)
441    }
442
443    /// Attach a WAL writer to enforce WAL-first flush ordering.
444    ///
445    /// After this call, [`Pager::flush`] computes the maximum
446    /// `header.lsn` over all dirty pages and calls
447    /// `WalWriter::flush_until(max_lsn)` before any page is written
448    /// to the data file. This is the postgres rule: a page on disk
449    /// implies its WAL record is already durable on disk.
450    ///
451    /// Existing call sites that construct a Pager without a WAL
452    /// keep their previous behaviour (no LSN check) — wiring is
453    /// strictly opt-in.
454    pub fn set_wal_writer(&self, wal: Arc<Mutex<crate::storage::wal::writer::WalWriter>>) {
455        let mut slot = self
456            .core
457            .wal_barrier()
458            .0
459            .write()
460            .unwrap_or_else(|p| p.into_inner());
461        *slot = Some(wal);
462    }
463
464    /// Detach the WAL writer (test / shutdown path).
465    pub fn clear_wal_writer(&self) {
466        let mut slot = self
467            .core
468            .wal_barrier()
469            .0
470            .write()
471            .unwrap_or_else(|p| p.into_inner());
472        *slot = None;
473    }
474
475    /// Has a WAL writer been attached?
476    pub fn has_wal_writer(&self) -> bool {
477        self.core
478            .wal_barrier()
479            .0
480            .read()
481            .map(|s| s.is_some())
482            .unwrap_or(false)
483    }
484
485    /// Install an ownership recorder while all other mutators are fenced.
486    #[allow(dead_code)] // Used when ownership recovery is activated.
487    pub(crate) fn install_ownership_recorder(
488        &self,
489        recorder: Arc<dyn super::PageOwnershipRecorder>,
490    ) -> std::io::Result<()> {
491        let mut slot = self
492            .core
493            .wal_barrier()
494            .1
495            .write()
496            .map_err(|_| std::io::Error::other("ownership recorder lock poisoned"))?;
497        if slot.is_some() {
498            return Err(std::io::Error::other(
499                "ownership recorder already installed",
500            ));
501        }
502        *slot = Some(recorder);
503        Ok(())
504    }
505
506    /// Remove the recorder after allocator copies and journal completion are
507    /// durable, while other mutators remain fenced.
508    pub(crate) fn clear_ownership_recorder(&self) -> std::io::Result<()> {
509        let mut slot = self
510            .core
511            .wal_barrier()
512            .1
513            .write()
514            .map_err(|_| std::io::Error::other("ownership recorder lock poisoned"))?;
515        if slot.take().is_none() {
516            return Err(std::io::Error::other("ownership recorder is not installed"));
517        }
518        Ok(())
519    }
520
521    /// Issue a best-effort OS read-ahead hint under the page-I/O lock.
522    pub fn prefetch_hint(&self, page_id: u32) {
523        let _ = self.core.with_file(|file| {
524            super::super::prefetch::prefetch_page(&file.0, u64::from(page_id), PAGE_SIZE as u32)
525        });
526    }
527
528    #[cfg(test)]
529    fn dwb_path(path: &Path) -> PathBuf {
530        reddb_file::layout::pager_dwb_shadow_path(path)
531    }
532}
533
534#[cfg(test)]
535mod tests {
536    use super::*;
537
538    #[test]
539    fn ownership_scope_serializes_other_allocators_and_fences_errors() {
540        let directory = tempfile::tempdir().expect("temporary directory");
541        let pager = Arc::new(
542            Pager::open(directory.path().join("data.rdb"), Default::default()).expect("pager"),
543        );
544        let scope = pager.lock_ownership_mutations();
545        let (started_tx, started_rx) = std::sync::mpsc::channel();
546        let (done_tx, done_rx) = std::sync::mpsc::channel();
547        let other_pager = Arc::clone(&pager);
548        let worker = std::thread::spawn(move || {
549            started_tx.send(()).expect("signal worker started");
550            let page = other_pager.allocate_page(PageType::BTreeLeaf);
551            done_tx
552                .send(page.map(|page| page.page_id()))
553                .expect("signal allocation");
554        });
555        started_rx.recv().expect("worker started");
556        assert!(done_rx
557            .recv_timeout(std::time::Duration::from_millis(100))
558            .is_err());
559        let own = pager
560            .allocate_page(PageType::BTreeLeaf)
561            .expect("reentrant allocation");
562        drop(scope);
563        let other = done_rx
564            .recv()
565            .expect("worker result")
566            .expect("other allocation");
567        worker.join().expect("worker");
568        assert_ne!(own.page_id(), other);
569        pager.fence_interrupted_ownership();
570        assert!(pager.allocate_page(PageType::BTreeLeaf).is_err());
571    }
572
573    fn temp_db_path(name: &str) -> PathBuf {
574        std::env::temp_dir().join(format!(
575            "reddb-pager-{}-{}-{}.rdb",
576            name,
577            std::process::id(),
578            crate::utils::now_unix_nanos()
579        ))
580    }
581
582    fn cleanup(path: &Path) {
583        let _ = std::fs::remove_file(path);
584        let _ = std::fs::remove_file(Pager::dwb_path(path));
585    }
586
587    fn read_zone(path: &Path) -> Vec<u8> {
588        let mut zone = vec![0u8; SUPERBLOCK_ZONE_SIZE];
589        let mut file = File::open(path).expect("open() should succeed");
590        file.read_exact(&mut zone)
591            .expect("read_exact() should succeed");
592        zone
593    }
594
595    /// Overwrite one superblock slot in place, leaving the sibling untouched.
596    fn poke_slot(path: &Path, copy_index: usize, bytes: &[u8]) {
597        let mut file = OpenOptions::new()
598            .write(true)
599            .open(path)
600            .expect("open() should succeed");
601        file.seek(SeekFrom::Start(reddb_file::paged_superblock_slot_offset(
602            copy_index,
603        )))
604        .expect("operation should succeed");
605        file.write_all(bytes).expect("write_all() should succeed");
606        file.sync_all().expect("sync_all() should succeed");
607    }
608
609    fn stamp_pre_phase3_layout(path: &Path, page_count: u32) {
610        let zone = read_zone(path);
611        for copy_index in 0..reddb_file::PAGED_SUPERBLOCK_SLOT_COUNT {
612            let start = copy_index * SUPERBLOCK_SLOT_SIZE;
613            let mut slot = zone[start..start + SUPERBLOCK_SLOT_SIZE].to_vec();
614            let generation = reddb_file::paged_superblock_slot_generation(&slot, copy_index)
615                .expect("fresh store should publish both slots");
616            reddb_file::set_database_header_version(&mut slot, PHASE3_DWB_ZONE_FILE_VERSION - 1)
617                .expect("set_database_header_version() should succeed");
618            reddb_file::set_database_header_page_count(&mut slot, page_count)
619                .expect("set_database_header_page_count() should succeed");
620            reddb_file::seal_paged_superblock_slot(&mut slot, copy_index, generation)
621                .expect("seal_paged_superblock_slot() should succeed");
622            poke_slot(path, copy_index, &slot);
623        }
624    }
625
626    #[test]
627    fn open_refuses_future_database_version() {
628        let path = temp_db_path("future-version");
629        let pager = Pager::open_default(&path).expect("open_default() should succeed");
630        drop(pager);
631
632        // A *valid* superblock slot — correct magic, copy index and CRC — whose
633        // database header names a version we cannot read. The slot is intact;
634        // the header is from the future.
635        let selection = reddb_file::select_paged_superblock(&read_zone(&path))
636            .expect("operation should succeed");
637        let mut slot = vec![0u8; SUPERBLOCK_SLOT_SIZE];
638        reddb_file::init_database_header_page(&mut slot, 1)
639            .expect("init_database_header_page() should succeed");
640        reddb_file::set_database_header_version(&mut slot, reddb_file::PAGE_FILE_VERSION + 1)
641            .expect("operation should succeed");
642        reddb_file::seal_paged_superblock_slot(
643            &mut slot,
644            selection.copy_index,
645            selection.generation + 1,
646        )
647        .expect("operation should succeed");
648        poke_slot(&path, selection.copy_index, &slot);
649
650        let err = match Pager::open_default(&path) {
651            Ok(_) => panic!("future database version should be rejected"),
652            Err(err) => err,
653        };
654        match err {
655            PagerError::InvalidDatabase(msg) => {
656                assert!(msg.contains("newer than supported"), "{msg}");
657            }
658            other => panic!("expected InvalidDatabase, got {other:?}"),
659        }
660
661        cleanup(&path);
662    }
663
664    #[test]
665    fn a_fresh_store_publishes_both_superblock_copies() {
666        // The ping-pong invariant must hold from the very first update, which
667        // means creation cannot leave one copy blank.
668        let path = temp_db_path("fresh-pair");
669        let pager = Pager::open_default(&path).expect("open_default() should succeed");
670        drop(pager);
671
672        let zone = read_zone(&path);
673        for copy_index in 0..reddb_file::PAGED_SUPERBLOCK_SLOT_COUNT {
674            let start = copy_index * SUPERBLOCK_SLOT_SIZE;
675            assert!(
676                reddb_file::paged_superblock_slot_generation(
677                    &zone[start..start + SUPERBLOCK_SLOT_SIZE],
678                    copy_index
679                )
680                .is_some(),
681                "copy {copy_index} must be valid on a fresh store"
682            );
683        }
684        cleanup(&path);
685    }
686
687    #[test]
688    fn an_update_writes_the_stale_copy_and_leaves_the_newest_one_intact() {
689        let path = temp_db_path("ping-pong");
690        let pager = Pager::open_default(&path).expect("open_default() should succeed");
691        let before = reddb_file::select_paged_superblock(&read_zone(&path))
692            .expect("operation should succeed");
693
694        pager
695            .allocate_page(PageType::BTreeLeaf)
696            .expect("allocate_page() should succeed");
697        pager.sync().expect("sync() should succeed");
698
699        let zone = read_zone(&path);
700        let after = reddb_file::select_paged_superblock(&zone)
701            .expect("select_paged_superblock() should succeed");
702        assert_ne!(
703            after.copy_index, before.copy_index,
704            "an update must target the stale copy"
705        );
706        assert!(after.generation > before.generation);
707
708        // The copy that was newest before the update is still readable — this
709        // is the byte-level statement of "a crash mid-write always leaves one
710        // valid copy".
711        let start = before.copy_index * SUPERBLOCK_SLOT_SIZE;
712        assert_eq!(
713            reddb_file::paged_superblock_slot_generation(
714                &zone[start..start + SUPERBLOCK_SLOT_SIZE],
715                before.copy_index
716            ),
717            Some(before.generation)
718        );
719
720        drop(pager);
721        cleanup(&path);
722    }
723
724    #[test]
725    fn failed_trunk_fence_is_completed_before_retrying_allocation() {
726        let path = temp_db_path("retry-trunk-fence");
727        let pager = Pager::open_default(&path).expect("pager");
728        let mut pages = Vec::new();
729        for _ in 0..8 {
730            pages.push(pager.allocate_page(PageType::BTreeLeaf).unwrap().page_id());
731        }
732        pager.sync().unwrap();
733        for id in pages {
734            pager.free_page(id).unwrap();
735        }
736        pager.sync().unwrap();
737        let trunk = pager.header().unwrap().freelist_head;
738        assert_ne!(trunk, 0);
739
740        // A real write error after the trunk has been read must not turn a
741        // subsequent allocation into an unfenced fast-path reuse.
742        let writable = pager
743            .core
744            .with_file(|file| std::mem::replace(&mut file.0, File::open(&path).unwrap()))
745            .unwrap();
746        assert!(pager.allocate_page(PageType::BTreeLeaf).is_err());
747        pager.core.with_file(|file| file.0 = writable).unwrap();
748        pager.allocate_page(PageType::BTreeLeaf).unwrap();
749
750        let zone = read_zone(&path);
751        for copy in 0..reddb_file::PAGED_SUPERBLOCK_SLOT_COUNT {
752            let start = copy * SUPERBLOCK_SLOT_SIZE;
753            let header =
754                reddb_file::decode_database_header(&zone[start..start + SUPERBLOCK_SLOT_SIZE])
755                    .unwrap();
756            assert_ne!(
757                header.freelist_head, trunk,
758                "copy {copy} still owns consumed trunk"
759            );
760        }
761        drop(pager);
762        cleanup(&path);
763    }
764
765    #[test]
766    fn sibling_superblock_recovery_does_not_reallocate_live_reused_pages() {
767        let path = temp_db_path("reused-allocator-pair");
768        let pager = Pager::open_default(&path).expect("pager");
769        let mut pages = Vec::new();
770        for _ in 0..32 {
771            pages.push(
772                pager
773                    .allocate_page(PageType::BTreeLeaf)
774                    .expect("allocate")
775                    .page_id(),
776            );
777        }
778        pager.sync().expect("seed");
779        for id in pages {
780            pager.free_page(id).expect("free");
781        }
782        pager.sync().expect("publish free list");
783        let mut live = std::collections::HashSet::new();
784        for _ in 0..8 {
785            let page = pager.allocate_page(PageType::BTreeLeaf).expect("reuse");
786            live.insert(page.page_id());
787            pager
788                .write_page(page.page_id(), page)
789                .expect("write valid page");
790        }
791        pager.sync().expect("publish reused ownership");
792        drop(pager);
793        let selection = reddb_file::select_paged_superblock(&read_zone(&path)).expect("superblock");
794        poke_slot(&path, selection.copy_index, &[0; SUPERBLOCK_SLOT_SIZE]);
795        let recovered = Pager::open_default(&path).expect("sibling recovery");
796        for _ in 0..64 {
797            let id = recovered
798                .allocate_page(PageType::BTreeLeaf)
799                .expect("allocate after recovery")
800                .page_id();
801            assert!(
802                !live.contains(&id),
803                "sibling allocator still considers live page {id} free"
804            );
805        }
806        drop(recovered);
807        cleanup(&path);
808    }
809
810    #[test]
811    fn open_recovers_from_the_older_copy_when_the_newest_is_torn() {
812        let path = temp_db_path("torn-newest");
813        {
814            let pager = Pager::open_default(&path).expect("open_default() should succeed");
815            pager
816                .allocate_page(PageType::BTreeLeaf)
817                .expect("allocate_page() should succeed");
818            pager.sync().expect("sync() should succeed");
819        }
820
821        let newest = reddb_file::select_paged_superblock(&read_zone(&path))
822            .expect("operation should succeed");
823        // Tear the newest copy: a torn write leaves its CRC broken.
824        let mut torn = vec![0u8; SUPERBLOCK_SLOT_SIZE];
825        torn.copy_from_slice(
826            &read_zone(&path)[newest.copy_index * SUPERBLOCK_SLOT_SIZE
827                ..newest.copy_index * SUPERBLOCK_SLOT_SIZE + SUPERBLOCK_SLOT_SIZE],
828        );
829        torn[64] ^= 0xFF;
830        poke_slot(&path, newest.copy_index, &torn);
831
832        let pager = Pager::open_default(&path).expect("older copy must root the store");
833        assert!(pager.page_count().expect("page_count() should succeed") >= 3);
834        drop(pager);
835        cleanup(&path);
836    }
837
838    #[test]
839    fn open_refuses_didactically_when_both_superblock_copies_are_invalid() {
840        let path = temp_db_path("both-invalid");
841        {
842            let _ = Pager::open_default(&path).expect("open_default() should succeed");
843        }
844
845        let zone = read_zone(&path);
846        for copy_index in 0..reddb_file::PAGED_SUPERBLOCK_SLOT_COUNT {
847            let start = copy_index * SUPERBLOCK_SLOT_SIZE;
848            let mut slot = zone[start..start + SUPERBLOCK_SLOT_SIZE].to_vec();
849            slot[64] ^= 0xFF;
850            poke_slot(&path, copy_index, &slot);
851        }
852
853        let err = match Pager::open_default(&path) {
854            Ok(_) => panic!("a rootless store must not open"),
855            Err(err) => err,
856        };
857        let rendered = err.to_string();
858        assert!(matches!(err, PagerError::SuperblockZoneUnrecoverable(_)));
859        assert!(rendered.contains("superblock zone"), "{rendered}");
860        assert!(rendered.contains("salvage"), "{rendered}");
861
862        cleanup(&path);
863    }
864
865    #[test]
866    fn open_refuses_a_legacy_sidecar_store_and_names_the_migration_tool() {
867        let path = temp_db_path("legacy-sidecar");
868        {
869            let _ = Pager::open_default(&path).expect("open_default() should succeed");
870        }
871
872        let sidecar = reddb_file::layout::retired::pager_header_path_v0(&path);
873        std::fs::write(&sidecar, b"legacy header shadow").expect("write() should succeed");
874
875        let err = match Pager::open_default(&path) {
876            Ok(_) => panic!("legacy stores need migration first"),
877            Err(err) => err,
878        };
879        let rendered = err.to_string();
880        assert!(matches!(err, PagerError::LegacySidecarStore { .. }));
881        assert!(rendered.contains("migration tool"), "{rendered}");
882
883        let _ = std::fs::remove_file(&sidecar);
884        cleanup(&path);
885    }
886
887    #[test]
888    fn pre_phase3_large_store_without_sidecar_routes_to_migration() {
889        let path = temp_db_path("pre-phase3-large");
890        {
891            let _ = Pager::open_default(&path).expect("open_default() should succeed");
892        }
893        stamp_pre_phase3_layout(&path, FIRST_ALLOCATABLE_PAGE);
894
895        let err = match Pager::open_default(&path) {
896            Ok(_) => panic!("pre-phase-3 stores need migration first"),
897            Err(err) => err,
898        };
899        let rendered = err.to_string();
900        assert!(matches!(err, PagerError::LegacyPagedStore { .. }));
901        assert!(rendered.contains("migration tool"), "{rendered}");
902
903        cleanup(&path);
904    }
905
906    #[test]
907    fn pre_phase3_small_store_without_sidecar_routes_to_migration() {
908        let path = temp_db_path("pre-phase3-small");
909        {
910            let _ = Pager::open_default(&path).expect("open_default() should succeed");
911        }
912        stamp_pre_phase3_layout(&path, 3);
913        OpenOptions::new()
914            .write(true)
915            .open(&path)
916            .expect("open() should succeed")
917            .set_len(3 * PAGE_SIZE as u64)
918            .expect("set_len() should succeed");
919
920        let err = match Pager::open_default(&path) {
921            Ok(_) => panic!("pre-phase-3 stores need migration first"),
922            Err(err) => err,
923        };
924        assert!(matches!(err, PagerError::LegacyPagedStore { .. }));
925
926        cleanup(&path);
927    }
928
929    #[test]
930    fn no_phase_one_sidecar_survives_a_create_write_reopen_cycle() {
931        let path = temp_db_path("census");
932        {
933            let pager = Pager::open_default(&path).expect("open_default() should succeed");
934            let page = pager
935                .allocate_page(PageType::BTreeLeaf)
936                .expect("allocate_page() should succeed");
937            pager
938                .write_page(page.page_id(), page)
939                .expect("operation should succeed");
940            pager.sync().expect("sync() should succeed");
941        }
942        {
943            let pager = Pager::open_default(&path).expect("open_default() should succeed");
944            pager.sync().expect("sync() should succeed");
945        }
946
947        assert_eq!(
948            reddb_file::layout::retired::first_present_phase1_sidecar(&path),
949            None,
950            "the pager must never create a retired phase-1 sidecar"
951        );
952        cleanup(&path);
953    }
954}