Skip to main content

reddb_server/storage/unified/store/
impl_pages.rs

1use super::*;
2use parking_lot::RwLock;
3
4const CHECKPOINT_BATCH_BYTES: usize = 1024 * 1024;
5const CHECKPOINT_BATCH_ROWS: usize = 512;
6
7fn restore_published_tree(
8    pager: &Arc<Pager>,
9    root: u32,
10    proofs: Option<&reddb_file::page_proof::CatalogProofs>,
11) -> Result<BTree, StoreError> {
12    let proof = proofs.and_then(|proofs| proofs.get(&root)).copied();
13    let tree = match (root, proof) {
14        (0, _) if proofs.is_some() => BTree::new_verified(Arc::clone(pager)),
15        (_, Some(proof)) => BTree::with_verified_root(Arc::clone(pager), root, proof),
16        (_, None) if proofs.is_some() => {
17            return Err(page_integrity(
18                root,
19                None,
20                "missing published tree generation proof",
21            ));
22        }
23        _ => return Ok(BTree::with_root(Arc::clone(pager), root)),
24    };
25    tree.map_err(|error| page_integrity(root, None, error.to_string()))
26}
27
28#[derive(Debug)]
29struct SystemCollectionCollision {
30    survivor: String,
31    loser: String,
32    canonical: String,
33    shared_physical_state: bool,
34    resume_counter_drift: bool,
35    merged_resume: Option<reddb_file::native_resume::CollectionResume>,
36}
37
38#[derive(Debug, Default)]
39struct CatalogLoadOutcome {
40    migrated: bool,
41    collisions: Vec<SystemCollectionCollision>,
42}
43
44fn merge_shared_alias_resume(
45    legacy: &reddb_file::native_resume::CollectionResume,
46    canonical: &reddb_file::native_resume::CollectionResume,
47) -> Option<reddb_file::native_resume::CollectionResume> {
48    let (data_root_match, key_index_root_match, secondary_index_state_match) =
49        shared_alias_resume_matches(legacy, canonical);
50    if !data_root_match || !key_index_root_match || !secondary_index_state_match {
51        return None;
52    }
53    let mut merged = legacy.clone();
54    // The shared roots bind the exact durable data and derived indexes. Resume
55    // counters are advisory allocation state; retaining each high-water mark
56    // prevents identity reuse after an interrupted name migration. The exact
57    // entity count is read from the shared data tree while loading.
58    merged.entities = legacy.entities.max(canonical.entities);
59    merged.entity_id_next = legacy.entity_id_next.max(canonical.entity_id_next);
60    merged.row_id_next = legacy.row_id_next.max(canonical.row_id_next);
61    merged.sequence_id_next = legacy.sequence_id_next.max(canonical.sequence_id_next);
62    Some(merged)
63}
64
65fn shared_alias_resume_matches(
66    legacy: &reddb_file::native_resume::CollectionResume,
67    canonical: &reddb_file::native_resume::CollectionResume,
68) -> (bool, bool, bool) {
69    (
70        legacy.root_page == canonical.root_page,
71        legacy.key_index_root == canonical.key_index_root,
72        legacy.secondary_indices == canonical.secondary_indices
73            && legacy.metadata_expiries == canonical.metadata_expiries,
74    )
75}
76
77/// Keep encoded payload memory proportional to a small batch, not a collection.
78/// A single large value retains the existing B-tree value-size contract.
79struct CheckpointTreeBuilder {
80    tree: Arc<BTree>,
81    records: Vec<(Vec<u8>, Vec<u8>)>,
82    bytes: usize,
83}
84
85impl CheckpointTreeBuilder {
86    fn new(pager: &Arc<Pager>) -> Result<Self, StoreError> {
87        pager
88            .fence_required_capabilities(
89                reddb_file::file_format::FILE_CAPABILITY_TREE_PROOFS
90                    | reddb_file::file_format::FILE_CAPABILITY_DWB_PUBLICATION,
91            )
92            .map_err(|error| StoreError::Io(std::io::Error::other(error.to_string())))?;
93        Ok(Self {
94            tree: UnifiedStore::new_derived_btree(pager)?,
95            records: Vec::new(),
96            bytes: 0,
97        })
98    }
99
100    fn push(&mut self, record: (Vec<u8>, Vec<u8>)) -> Result<(), StoreError> {
101        let bytes = record.0.len() + record.1.len();
102        if !self.records.is_empty()
103            && (self.bytes.saturating_add(bytes) > CHECKPOINT_BATCH_BYTES
104                || self.records.len() >= CHECKPOINT_BATCH_ROWS)
105        {
106            self.flush()?;
107        }
108        self.bytes += bytes;
109        self.records.push(record);
110        #[cfg(test)]
111        CHECKPOINT_BUFFERED_BYTES.with(|peak| peak.set(peak.get().max(self.bytes)));
112        Ok(())
113    }
114
115    fn flush(&mut self) -> Result<(), StoreError> {
116        self.tree
117            .bulk_insert_sorted(&self.records)
118            .map_err(|e| StoreError::Io(std::io::Error::other(e.to_string())))?;
119        self.records.clear();
120        self.bytes = 0;
121        Ok(())
122    }
123
124    fn copy_prefix(&mut self, previous: &BTree, count: usize) -> Result<(), StoreError> {
125        let mut cursor = previous
126            .cursor_first()
127            .map_err(|e| StoreError::Io(std::io::Error::other(e.to_string())))?;
128        for _ in 0..count {
129            let record = cursor
130                .next()
131                .map_err(|e| StoreError::Io(std::io::Error::other(e.to_string())))?
132                .ok_or_else(|| StoreError::Serialization("checkpoint prefix disappeared".into()))?;
133            self.push(record)?;
134        }
135        Ok(())
136    }
137}
138
139#[cfg(test)]
140thread_local! {
141    static CHECKPOINT_SERIALIZED_ROWS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
142    static REPLAY_SERIALIZED_ROWS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
143    static CHECKPOINT_BUFFERED_BYTES: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
144}
145
146fn checkpoint_visibility_matches(
147    previous: &CheckpointVersion,
148    current: &CheckpointVersion,
149) -> bool {
150    (previous.visibility_revision == current.visibility_revision
151        || previous.excluded_xids == current.excluded_xids)
152        && previous.pending_vault == current.pending_vault
153}
154
155// ── Pager-meta overflow chain (gh-477) ──────────────────────────────────────
156// When the serialized collection registry + cross-refs exceed a single page,
157// page 1 carries a native metadata-overflow header pointing at an overflow chain of
158// `PageType::Overflow` pages. Single-page metadata keeps the historical
159// bit-identical layout (`METADATA_MAGIC = "RDM2"` written directly at the
160// content offset).
161//
162// Page 1 (overflow form), starting at `HEADER_SIZE`:
163//   [0..4]   native metadata-overflow magic
164//   [4..8]   format_version (u32, mirrors inner payload version for debug)
165//   [8..12]  total_payload_bytes (u32)
166//   [12..16] next_overflow_page_id (u32, > 0)
167//   [16..]   first payload chunk (up to META_V3_FIRST_PAYLOAD_CAP bytes)
168//
169// Overflow continuation page, starting at `HEADER_SIZE`:
170//   [0..4]   next_overflow_page_id (u32, 0 if last)
171//   [4..8]   chunk_bytes (u32)
172//   [8..]    chunk payload (up to META_V3_OVERFLOW_PAYLOAD_CAP bytes)
173const META_PAGE_CONTENT_CAP: usize =
174    crate::storage::engine::PAGE_SIZE - crate::storage::engine::HEADER_SIZE;
175const META_V3_PAGE1_HEADER: usize = reddb_file::METADATA_OVERFLOW_HEADER_BYTES;
176const META_V3_OVERFLOW_HEADER: usize = reddb_file::METADATA_OVERFLOW_CONTINUATION_HEADER_BYTES;
177const META_V3_FIRST_PAYLOAD_CAP: usize = META_PAGE_CONTENT_CAP - META_V3_PAGE1_HEADER;
178const META_V3_OVERFLOW_PAYLOAD_CAP: usize = META_PAGE_CONTENT_CAP - META_V3_OVERFLOW_HEADER;
179
180fn existing_overflow_chain(pager: &Pager) -> Result<Vec<u32>, PagerError> {
181    let cs = crate::storage::engine::HEADER_SIZE;
182    let page = pager.read_manifest_page()?;
183    let mut digest = reddb_file::page_proof::CatalogDigest::default();
184    digest.add_page(page.as_bytes());
185    let bytes = page.as_bytes();
186    if bytes.len() < cs + META_V3_PAGE1_HEADER {
187        return Ok(Vec::new());
188    }
189    let Some(header) =
190        reddb_file::decode_native_metadata_overflow_header(&bytes[cs..]).map_err(|err| {
191            PagerError::InvalidDatabase(format!("invalid metadata overflow header: {err}"))
192        })?
193    else {
194        verify_catalog_digest(pager, digest.finish())?;
195        return Ok(Vec::new());
196    };
197    let mut next = header.next_overflow_page_id;
198    let mut pages = Vec::new();
199    let mut seen = std::collections::HashSet::new();
200    while next != 0 {
201        if !seen.insert(next) || pages.len() >= pager.page_count()? as usize {
202            return Err(PagerError::InvalidDatabase(
203                "cyclic metadata overflow chain".into(),
204            ));
205        }
206        let ov = pager.read_page(next)?;
207        digest.add_page(ov.as_bytes());
208        if ov.page_type()? != crate::storage::engine::PageType::Overflow {
209            return Err(PagerError::InvalidDatabase(
210                "invalid metadata overflow page type".into(),
211            ));
212        }
213        let ob = ov.as_bytes();
214        let nn = reddb_file::decode_native_metadata_overflow_continuation_header(&ob[cs..])
215            .map_err(|err| PagerError::InvalidDatabase(err.to_string()))?
216            .next_overflow_page_id;
217        pages.push(next);
218        next = nn;
219    }
220    verify_catalog_digest(pager, digest.finish())?;
221    Ok(pages)
222}
223
224fn verify_catalog_digest(pager: &Pager, digest: [u8; 32]) -> Result<(), PagerError> {
225    let reference = pager.manifest_reference()?;
226    if reference.page_id != 0 && reference.digest != digest {
227        return Err(PagerError::InvalidDatabase(format!(
228            "catalog page {} generation proof does not match the superblock",
229            reference.page_id
230        )));
231    }
232    Ok(())
233}
234
235fn publish_paged_metadata(pager: &Pager, meta_data: &[u8]) -> Result<(), PagerError> {
236    let _ownership = pager.lock_ownership_mutations();
237    let mut retired = existing_overflow_chain(pager)?;
238    let previous_root = pager.manifest_page_id()?;
239    if previous_root >= reddb_engine::storage::pager_io::FIRST_ALLOCATABLE_PAGE {
240        retired.push(previous_root);
241    }
242    if !retired.is_empty() {
243        pager.record_retirement_intent(&retired)?;
244    }
245    pager.fence_required_capabilities(reddb_file::FILE_CAPABILITY_CATALOG_ROOT)?;
246    let root = pager
247        .allocate_page(crate::storage::engine::PageType::Header)?
248        .page_id();
249    let (page, digest) = build_meta_page1_with_overflow(pager, meta_data, root)?;
250    pager.write_page(root, page)?;
251    #[cfg(test)]
252    if let Ok(prefix) = std::env::var("REDDB_TEST_CATALOG_HOME_IO_PREFIX") {
253        crate::storage::engine::pager::test_fail_next_pager_write(
254            Some(u64::from(root) * crate::storage::engine::PAGE_SIZE as u64),
255            prefix.parse().expect("catalog home prefix"),
256        );
257    }
258    // Publish only after all children, data pages and proof paths are durable.
259    pager.sync()?;
260    #[cfg(test)]
261    if std::env::var_os("REDDB_TEST_EXIT_BEFORE_CATALOG_ROOT_PUBLICATION").is_some() {
262        std::process::exit(197);
263    }
264    pager.publish_manifest_reference(reddb_file::page_proof::PageReference {
265        page_id: root,
266        digest,
267    })?;
268    #[cfg(test)]
269    if std::env::var_os("REDDB_TEST_EXIT_AFTER_CATALOG_ROOT_PUBLICATION").is_some() {
270        std::process::exit(198);
271    }
272    if !retired.is_empty() {
273        // Metadata readers hold the ownership fence. Both superblocks have
274        // forgotten these pages, so no reader or fallback can still name them.
275        pager.free_retired_pages(&retired)?;
276        pager.sync()?;
277    }
278    Ok(())
279}
280
281fn build_meta_page1_with_overflow(
282    pager: &Pager,
283    meta_data: &[u8],
284    root_page_id: u32,
285) -> Result<(crate::storage::engine::Page, [u8; 32]), PagerError> {
286    use crate::storage::engine::{Page, PageType, HEADER_SIZE};
287
288    let mut page1 = Page::new(PageType::Header, root_page_id);
289    let cs = HEADER_SIZE;
290
291    if meta_data.len() <= META_PAGE_CONTENT_CAP {
292        // Single-page: bit-identical to the historical layout.
293        let buf = page1.as_bytes_mut();
294        buf[cs..cs + meta_data.len()].copy_from_slice(meta_data);
295        page1.update_checksum();
296        let digest = reddb_file::page_proof::page_digest(page1.as_bytes());
297        return Ok((page1, digest));
298    }
299
300    // Multi-page overflow form. Split the inner payload into the first chunk
301    // (held on page 1) followed by zero-or-more continuation chunks chained
302    // through `PageType::Overflow` pages.
303    let first_chunk = &meta_data[..META_V3_FIRST_PAYLOAD_CAP];
304    let mut tail = &meta_data[META_V3_FIRST_PAYLOAD_CAP..];
305    let mut chunks: Vec<&[u8]> = Vec::new();
306    while !tail.is_empty() {
307        let take = tail.len().min(META_V3_OVERFLOW_PAYLOAD_CAP);
308        chunks.push(&tail[..take]);
309        tail = &tail[take..];
310    }
311
312    let mut overflow_pages: Vec<Page> = Vec::with_capacity(chunks.len());
313    let mut overflow_ids: Vec<u32> = Vec::with_capacity(chunks.len());
314    for _ in 0..chunks.len() {
315        let pg = pager.allocate_page(PageType::Overflow)?;
316        overflow_ids.push(pg.page_id());
317        overflow_pages.push(pg);
318    }
319
320    for i in 0..chunks.len() {
321        let next = if i + 1 < chunks.len() {
322            overflow_ids[i + 1]
323        } else {
324            0u32
325        };
326        let len = chunks[i].len() as u32;
327        let buf = overflow_pages[i].as_bytes_mut();
328        reddb_file::encode_native_metadata_overflow_continuation_header(
329            &mut buf[cs..cs + META_V3_OVERFLOW_HEADER],
330            reddb_file::NativeMetadataOverflowContinuationHeader {
331                next_overflow_page_id: next,
332                chunk_bytes: len,
333            },
334        )
335        .map_err(|err| PagerError::InvalidDatabase(err.to_string()))?;
336        buf[cs + 8..cs + 8 + chunks[i].len()].copy_from_slice(chunks[i]);
337    }
338
339    // Mirror the inner format_version for debug-friendly hex dumps.
340    let format_version = reddb_file::decode_native_paged_metadata_header(meta_data)
341        .ok()
342        .flatten()
343        .map_or(0, |header| header.format_version);
344
345    let buf = page1.as_bytes_mut();
346    reddb_file::encode_native_metadata_overflow_header(
347        &mut buf[cs..cs + META_V3_PAGE1_HEADER],
348        reddb_file::NativeMetadataOverflowHeader {
349            format_version,
350            total_payload_bytes: meta_data.len() as u32,
351            next_overflow_page_id: overflow_ids[0],
352        },
353    )
354    .map_err(|err| PagerError::InvalidDatabase(err.to_string()))?;
355    buf[cs + META_V3_PAGE1_HEADER..cs + META_V3_PAGE1_HEADER + first_chunk.len()]
356        .copy_from_slice(first_chunk);
357
358    page1.update_checksum();
359    let mut digest = reddb_file::page_proof::CatalogDigest::default();
360    digest.add_page(page1.as_bytes());
361    for mut page in overflow_pages {
362        page.update_checksum();
363        digest.add_page(page.as_bytes());
364        pager.write_page(page.page_id(), page)?;
365    }
366    Ok((page1, digest.finish()))
367}
368
369fn storage_integrity(
370    zone: &str,
371    id: impl Into<String>,
372    collection: Option<String>,
373    detail: impl Into<String>,
374) -> StoreError {
375    StoreError::StorageIntegrity(crate::api::StorageIntegrityError::new(
376        zone, id, collection, detail,
377    ))
378}
379
380fn page_integrity(
381    page_id: u32,
382    collection: Option<String>,
383    detail: impl Into<String>,
384) -> StoreError {
385    storage_integrity("page", page_id.to_string(), collection, detail)
386}
387
388/// Assemble the full metadata payload from the internal manifest zone: page 1
389/// plus its overflow chain when the native overflow wrapper is present. Returns
390/// the bytes that the metadata parser would see starting from the content
391/// offset of page 1. Single-page metadata returns the raw page content
392/// (including trailing zero-pad), so the legacy parser sees the same bytes it
393/// always saw.
394///
395/// The zone is rooted by the superblock and checksummed per ADR 0074 §2: a
396/// failing checksum surfaces the didactic manifest-zone integrity error
397/// directly — there is no sidecar copy to fall back to since ADR 0038 §4
398/// phase 1 retired the shadows.
399fn read_meta_payload(pager: &Pager) -> Result<Option<Vec<u8>>, StoreError> {
400    let _ownership = pager.lock_ownership_mutations();
401    let cs = crate::storage::engine::HEADER_SIZE;
402    let meta_page = pager.read_manifest_page().map_err(|err| {
403        storage_integrity(
404            "manifest",
405            "manifest",
406            None,
407            format!("internal manifest zone read failed ({err})"),
408        )
409    })?;
410    let mut digest = reddb_file::page_proof::CatalogDigest::default();
411    digest.add_page(meta_page.as_bytes());
412    let bytes = meta_page.as_bytes();
413    if bytes.len() < cs + 4 {
414        return Ok(Some(bytes.get(cs..).unwrap_or(&[]).to_vec()));
415    }
416    let header = match reddb_file::decode_native_metadata_overflow_header(&bytes[cs..])
417        .map_err(|err| page_integrity(1, None, err.to_string()))?
418    {
419        Some(header) => header,
420        None => {
421            verify_catalog_digest(pager, digest.finish())
422                .map_err(|error| page_integrity(meta_page.page_id(), None, error.to_string()))?;
423            return Ok(Some(bytes[cs..].to_vec()));
424        }
425    };
426    if bytes.len() < cs + META_V3_PAGE1_HEADER {
427        return Err(page_integrity(
428            1,
429            None,
430            "metadata overflow header is truncated",
431        ));
432    }
433    let total = header.total_payload_bytes as usize;
434    if total == 0 || total > 64 * 1024 * 1024 {
435        return Err(page_integrity(
436            meta_page.page_id(),
437            None,
438            "catalog exceeds metadata budget",
439        ));
440    }
441    let mut next = header.next_overflow_page_id;
442    let mut payload: Vec<u8> = Vec::with_capacity(total);
443    let first_take = total.min(META_V3_FIRST_PAYLOAD_CAP);
444    payload.extend_from_slice(
445        &bytes[cs + META_V3_PAGE1_HEADER..cs + META_V3_PAGE1_HEADER + first_take],
446    );
447    let mut seen = std::collections::HashSet::new();
448    while next != 0 && payload.len() < total {
449        if !seen.insert(next)
450            || seen.len()
451                >= pager
452                    .page_count()
453                    .map_err(|err| page_integrity(next, None, err.to_string()))?
454                    as usize
455        {
456            return Err(page_integrity(next, None, "cyclic metadata overflow chain"));
457        }
458        let ov = pager
459            .read_page(next)
460            .map_err(|err| page_integrity(next, None, err.to_string()))?;
461        digest.add_page(ov.as_bytes());
462        if ov
463            .page_type()
464            .map_err(|err| page_integrity(next, None, err.to_string()))?
465            != crate::storage::engine::PageType::Overflow
466        {
467            return Err(page_integrity(
468                next,
469                None,
470                "invalid metadata overflow page type",
471            ));
472        }
473        let ob = ov.as_bytes();
474        if ob.len() < cs + META_V3_OVERFLOW_HEADER {
475            return Err(page_integrity(
476                next,
477                None,
478                "metadata overflow continuation is truncated",
479            ));
480        }
481        let continuation =
482            reddb_file::decode_native_metadata_overflow_continuation_header(&ob[cs..])
483                .map_err(|err| page_integrity(next, None, err.to_string()))?;
484        let nn = continuation.next_overflow_page_id;
485        let len = continuation.chunk_bytes as usize;
486        let remaining = total - payload.len();
487        let take = len.min(remaining).min(META_V3_OVERFLOW_PAYLOAD_CAP);
488        payload.extend_from_slice(
489            &ob[cs + META_V3_OVERFLOW_HEADER..cs + META_V3_OVERFLOW_HEADER + take],
490        );
491        next = nn;
492    }
493    if payload.len() != total || next != 0 {
494        return Err(page_integrity(
495            1,
496            None,
497            "metadata overflow chain is incomplete",
498        ));
499    }
500    verify_catalog_digest(pager, digest.finish())
501        .map_err(|error| page_integrity(meta_page.page_id(), None, error.to_string()))?;
502    Ok(Some(payload))
503}
504
505impl UnifiedStore {
506    pub(super) fn new_derived_btree(pager: &Arc<Pager>) -> Result<Arc<BTree>, StoreError> {
507        let header = pager
508            .header()
509            .map_err(|error| StoreError::Io(std::io::Error::other(error.to_string())))?;
510        let tree = if header.required_capabilities
511            & reddb_file::file_format::FILE_CAPABILITY_TREE_PROOFS
512            != 0
513        {
514            BTree::new_verified(Arc::clone(pager))
515                .map_err(|error| StoreError::Io(std::io::Error::other(error.to_string())))?
516        } else {
517            BTree::new(Arc::clone(pager))
518        };
519        Ok(Arc::new(tree))
520    }
521
522    pub(super) fn immutable_paged_base(&self) -> Result<bool, StoreError> {
523        if self.config.resident_memory_bytes_max.is_some() {
524            return Ok(true);
525        }
526        let Some(pager) = &self.pager else {
527            return Ok(false);
528        };
529        let header = pager
530            .header()
531            .map_err(|error| StoreError::Io(std::io::Error::other(error.to_string())))?;
532        Ok(
533            header.required_capabilities & reddb_file::file_format::FILE_CAPABILITY_TREE_PROOFS
534                != 0,
535        )
536    }
537
538    /// Offline reconstruction uses the same catalog-chain proof and publication
539    /// ordering as the live store, with an exclusively owned destination pager.
540    pub(crate) fn publish_native_catalog(pager: &Pager, metadata: &[u8]) -> Result<(), PagerError> {
541        publish_paged_metadata(pager, metadata)
542    }
543
544    pub(crate) fn fence_required_capabilities(&self, capabilities: u64) -> Result<(), StoreError> {
545        if capabilities == 0 {
546            return Ok(());
547        }
548        let Some(pager) = &self.pager else {
549            return Ok(());
550        };
551        pager
552            .fence_required_capabilities(capabilities)
553            .map_err(|error| StoreError::Io(std::io::Error::other(error.to_string())))
554    }
555
556    /// Compare and encode one borrowed row at a time. Begin a new tree only at
557    /// the first difference, copying its already-verified prefix in bounded
558    /// batches. Unchanged trees require no replacement allocation.
559    fn checkpoint_tree(
560        &self,
561        pager: &Arc<Pager>,
562        manager: &SegmentManager,
563        previous: Option<&Arc<BTree>>,
564        mut encode: impl FnMut(&UnifiedEntity, Option<Metadata>) -> Option<Vec<u8>>,
565    ) -> Result<Arc<BTree>, StoreError> {
566        let previous = previous.filter(|tree| tree.has_page_proofs());
567        if let Some((base, overlay)) = manager.backing_overlay(&mut encode) {
568            use reddb_engine::storage::collection_read::{CollectionRead, MergedCollection};
569            let merged = MergedCollection::new(base.as_ref(), overlay);
570            let mut cursor = merged.cursor()?;
571            let mut builder = CheckpointTreeBuilder::new(pager)?;
572            while let Some(record) = cursor.next_record(
573                usize::try_from(self.config.resident_memory_bytes_max.unwrap_or(u64::MAX))
574                    .unwrap_or(usize::MAX),
575            )? {
576                builder.push((record.id.raw().to_be_bytes().to_vec(), record.bytes))?;
577            }
578            builder.flush()?;
579            return Ok(builder.tree);
580        }
581        let mut cursor = previous
582            .map(|tree| tree.cursor_first())
583            .transpose()
584            .map_err(|e| StoreError::Io(std::io::Error::other(e.to_string())))?;
585        let mut matched = 0;
586        let mut builder = None;
587        let mut failure = None;
588        manager.for_each_entity_with_metadata_in_id_order(|entity, metadata| {
589            let Some(value) = encode(entity, metadata) else {
590                return true;
591            };
592            let record = (entity.id.raw().to_be_bytes().to_vec(), value);
593            let result = (|| {
594                if builder.is_none() {
595                    if let Some(cursor) = cursor.as_mut() {
596                        if cursor
597                            .next()
598                            .map_err(|e| StoreError::Io(std::io::Error::other(e.to_string())))?
599                            .as_ref()
600                            == Some(&record)
601                        {
602                            matched += 1;
603                            return Ok(());
604                        }
605                    }
606                    let mut replacement = CheckpointTreeBuilder::new(pager)?;
607                    if let Some(previous) = previous {
608                        replacement.copy_prefix(previous, matched)?;
609                    }
610                    builder = Some(replacement);
611                }
612                builder
613                    .as_mut()
614                    .expect("replacement initialized")
615                    .push(record)
616            })();
617            if let Err(error) = result {
618                failure = Some(error);
619                return false;
620            }
621            true
622        });
623        if let Some(error) = failure {
624            return Err(error);
625        }
626        if builder.is_none() {
627            if let (Some(previous), Some(cursor)) = (previous, cursor.as_mut()) {
628                if cursor
629                    .next()
630                    .map_err(|e| StoreError::Io(std::io::Error::other(e.to_string())))?
631                    .is_none()
632                {
633                    return Ok(Arc::clone(previous));
634                }
635            }
636            let mut replacement = CheckpointTreeBuilder::new(pager)?;
637            if let Some(previous) = previous {
638                replacement.copy_prefix(previous, matched)?;
639            }
640            builder = Some(replacement);
641        }
642        let mut builder = builder.expect("replacement or reused tree");
643        builder.flush()?;
644        Ok(builder.tree)
645    }
646
647    /// Publication first, reuse second. The immediately preceding generation
648    /// stays in the durable ledger for one more publication. Cursors and cloned
649    /// tree handles defer retirement further without holding the writer fence.
650    fn publish_reclaimable_trees(
651        &self,
652        pager: &Pager,
653        metadata: &mut Vec<u8>,
654        live: &HashMap<String, Arc<BTree>>,
655        keys: &HashMap<String, PublishedKeyIndex>,
656    ) -> Result<(), StoreError> {
657        let mut next_xid_summary = None;
658        let publishes_proofs = pager
659            .header()
660            .map_err(|error| StoreError::Io(std::io::Error::other(error.to_string())))?
661            .required_capabilities
662            & reddb_file::file_format::FILE_CAPABILITY_TREE_PROOFS
663            != 0;
664        if publishes_proofs {
665            pager
666                .fence_required_capabilities(
667                    reddb_file::file_format::FILE_CAPABILITY_DWB_PUBLICATION,
668                )
669                .map_err(|error| StoreError::Io(std::io::Error::other(error.to_string())))?;
670        }
671        let mut retired = self.retired_btrees.lock();
672        let mut roots = Vec::new();
673        let mut reclaim = Vec::new();
674        let mut remove = std::collections::HashSet::new();
675        for (index, entry) in retired.iter_mut().enumerate() {
676            if live
677                .values()
678                .chain(keys.values().flat_map(PublishedKeyIndex::trees))
679                .any(|tree| Arc::ptr_eq(tree, &entry.tree))
680            {
681                remove.insert(index);
682                continue;
683            }
684            if entry.published
685                && Arc::strong_count(&entry.tree) == 1
686                && !entry.tree.has_retirement_readers()
687            {
688                // Deliberately unsafe experimental control: keep the old
689                // root in the catalog while releasing its pages. Never enable
690                // this outside the isolated recovery test.
691                #[cfg(test)]
692                if std::env::var_os("REDDB_EXPERIMENTAL_RETAIN_RELEASED_ROOT").is_some() {
693                    roots.push(entry.tree.root_page_id());
694                }
695                reclaim.extend(
696                    entry
697                        .tree
698                        .retired_pages()
699                        .map_err(|e| StoreError::Io(std::io::Error::other(e.to_string())))?,
700                );
701                remove.insert(index);
702            } else if entry.tree.root_page_id() != 0 {
703                if publishes_proofs && !entry.tree.has_page_proofs() {
704                    let prepared = entry.tree.prepare_legacy_page_proofs().map_err(|error| {
705                        page_integrity(entry.tree.root_page_id(), None, error.to_string())
706                    })?;
707                    entry.tree = Arc::new(prepared);
708                }
709                roots.push(entry.tree.root_page_id());
710            }
711        }
712        roots.sort_unstable();
713        roots.dedup();
714        reddb_file::encode_native_paged_retired_trees(metadata, &roots)
715            .map_err(|e| StoreError::Serialization(e.to_string()))?;
716        let (pending_pages, published_pages) = pager
717            .deferred_reclamation_pages()
718            .map_err(|e| StoreError::Io(std::io::Error::other(e.to_string())))?;
719        let readers = live
720            .values()
721            .chain(keys.values().flat_map(PublishedKeyIndex::trees))
722            .any(|tree| tree.has_retirement_readers())
723            || retired
724                .iter()
725                .any(|entry| entry.tree.has_retirement_readers());
726        let released_pages = if readers { Vec::new() } else { published_pages };
727        let released: std::collections::HashSet<_> = released_pages.iter().copied().collect();
728        let retained_pages: Vec<_> = pending_pages
729            .into_iter()
730            .filter(|id| !released.contains(id))
731            .collect();
732        reddb_file::encode_native_paged_deferred_pages(metadata, &retained_pages)
733            .map_err(|e| StoreError::Serialization(e.to_string()))?;
734        let active_epoch = self.ownership_active_epoch.load(Ordering::Acquire);
735        let catalog_epoch = if active_epoch == 0 {
736            self.ownership_catalog_epoch.load(Ordering::Acquire)
737        } else {
738            active_epoch
739        };
740        if catalog_epoch != 0 {
741            reddb_file::native_store::encode_native_paged_ownership_epoch(metadata, catalog_epoch)
742                .map_err(|e| StoreError::Serialization(e.to_string()))?;
743        }
744        if publishes_proofs {
745            let mut proofs = reddb_file::page_proof::CatalogProofs::new();
746            for tree in live
747                .values()
748                .chain(keys.values().flat_map(PublishedKeyIndex::trees))
749                .chain(
750                    retired
751                        .iter()
752                        .enumerate()
753                        .filter(|(index, _)| !remove.contains(index))
754                        .map(|(_, entry)| &entry.tree),
755                )
756            {
757                if let Some((root, proof)) = tree
758                    .seal_page_proofs()
759                    .map_err(|error| page_integrity(tree.root_page_id(), None, error.to_string()))?
760                {
761                    if root != 0 {
762                        if proofs.insert(root, proof).is_some_and(|old| old != proof) {
763                            return Err(page_integrity(
764                                root,
765                                None,
766                                "conflicting tree generation proofs",
767                            ));
768                        }
769                    }
770                }
771            }
772            if live
773                .values()
774                .chain(keys.values().flat_map(PublishedKeyIndex::trees))
775                .any(|tree| tree.root_page_id() != 0 && !proofs.contains_key(&tree.root_page_id()))
776            {
777                return Err(page_integrity(1, None, "live tree has no generation proof"));
778            }
779            reddb_file::page_proof::encode_catalog(metadata, &proofs)
780                .map_err(|error| StoreError::Serialization(error.to_string()))?;
781        }
782        if self.config.resident_memory_bytes_max.is_some() {
783            pager
784                .fence_required_capabilities(reddb_file::FILE_CAPABILITY_DISK_RESIDENCY)
785                .map_err(|error| StoreError::Io(std::io::Error::other(error.to_string())))?;
786            use reddb_file::native_resume::{
787                encode_catalog_resume, CatalogResume, CollectionResume,
788            };
789            let mut states = std::collections::BTreeMap::new();
790            let mut entity_id_next = self.resident.next_entity_id.load(Ordering::SeqCst);
791            let collections = self.resident.collections.read();
792            let metadata_expiry_prepared = collections
793                .values()
794                .all(|manager| manager.metadata_expiry_projection_ready());
795            let publishes_rows = {
796                let summary = self.recovery_xid_summary.read();
797                live.iter().any(|(name, tree)| {
798                    let root = tree.root_page_id();
799                    root != 0
800                        && summary.as_ref().and_then(|state| state.roots.get(name)) != Some(&root)
801                })
802            };
803            let xid_high_water_mark =
804                self.base_xid_high_water_mark(&collections).map(|mut high| {
805                    // Ordinary metadata flushes retain the old immutable-base
806                    // bound; WAL overlays are observed at boot. Only replacement
807                    // data roots require a borrowed visit over resident headers.
808                    // The publication fence excludes concurrent header mutations.
809                    if publishes_rows {
810                        for manager in collections.values() {
811                            manager.for_each_entity(|entity| {
812                                high = high.max(entity.max_transaction_id());
813                                true
814                            });
815                        }
816                    }
817                    if let Some(snapshots) = self.snapshot_manager.get() {
818                        high = high.max(snapshots.peek_next_xid().saturating_sub(1));
819                    }
820                    high
821                });
822            for (name, manager) in collections.iter() {
823                let tree = live.get(name);
824                let (entity_next, row_id_next) = manager.identity_counters();
825                entity_id_next = entity_id_next.max(entity_next);
826                let root_page = tree.map_or(0, |tree| tree.root_page_id());
827                let entities = if root_page == 0 {
828                    0
829                } else if let Some(count) = manager.backing_count_at_root(root_page) {
830                    count
831                } else {
832                    // Changed projections may exclude pending/aborted rows. Count
833                    // published physical keys, not the live manager's cardinality.
834                    let mut cursor = tree
835                        .expect("invariant: nonzero root has a tree")
836                        .cursor_first()
837                        .map_err(|error| {
838                            StoreError::Io(std::io::Error::other(error.to_string()))
839                        })?;
840                    let mut count = 0_u64;
841                    while cursor
842                        .skip_next()
843                        .map_err(|error| StoreError::Io(std::io::Error::other(error.to_string())))?
844                    {
845                        count += 1;
846                    }
847                    count
848                };
849                let metadata_expiries = if metadata_expiry_prepared {
850                    let mut expiries = Vec::new();
851                    if let Some(tree) = tree {
852                        for (deadline_ms, entity_id) in manager.metadata_expiry_entries() {
853                            if tree
854                                .get(&entity_id.raw().to_be_bytes())
855                                .map_err(|error| {
856                                    StoreError::Io(std::io::Error::other(error.to_string()))
857                                })?
858                                .is_some()
859                            {
860                                expiries.push(reddb_file::native_resume::MetadataExpiryResume {
861                                    deadline_ms,
862                                    entity_id: entity_id.raw(),
863                                });
864                            }
865                        }
866                    }
867                    expiries
868                } else {
869                    Vec::new()
870                };
871                states.insert(
872                    name.clone(),
873                    CollectionResume {
874                        root_page,
875                        entities,
876                        entity_id_next: entity_next,
877                        row_id_next,
878                        sequence_id_next: manager.sequence_id_next(),
879                        key_index_root: tree.and_then(|tree| {
880                            keys.get(name)
881                                .filter(|index| {
882                                    index.indexed && index.source.ptr_eq(&Arc::downgrade(tree))
883                                })
884                                .map(|index| index.tree.root_page_id())
885                        }),
886                        secondary_indices: tree
887                            .and_then(|tree| {
888                                keys.get(name)
889                                    .filter(|index| index.source.ptr_eq(&Arc::downgrade(tree)))
890                            })
891                            .map(|index| {
892                                index
893                                    .secondary
894                                    .iter()
895                                    .map(|(name, index)| (name.clone(), index.resume()))
896                                    .collect()
897                            })
898                            .unwrap_or_default(),
899                        metadata_expiries,
900                    },
901                );
902            }
903            let resume = CatalogResume {
904                entity_id_next,
905                xid_high_water_mark,
906                metadata_expiry_prepared,
907                collections: states,
908            };
909            encode_catalog_resume(metadata, &resume)
910                .map_err(|error| StoreError::Serialization(error.to_string()))?;
911            next_xid_summary = xid_high_water_mark.map(|high_water_mark| {
912                super::recovery_xid::RecoveryXidSummary {
913                    high_water_mark,
914                    roots: resume
915                        .collections
916                        .into_iter()
917                        .map(|(name, state)| (name, state.root_page))
918                        .collect(),
919                }
920            });
921        }
922        // Record the exact pages before the catalog can forget their owners.
923        // Recovery resolves this intent against the durable catalog epoch.
924        let mut retirement_intent = reclaim.clone();
925        retirement_intent.extend_from_slice(&released_pages);
926        if !retirement_intent.is_empty() {
927            pager
928                .record_retirement_intent(&retirement_intent)
929                .map_err(|e| StoreError::Io(std::io::Error::other(e.to_string())))?;
930        }
931        // Do not mutate retirement state or the free list before both the new
932        // children and the manifest that forgets old roots have been synced.
933        publish_paged_metadata(pager, metadata)
934            .map_err(|e| StoreError::Io(std::io::Error::other(e.to_string())))?;
935        // PAGE_RESULT_CACHE_FILE_VERSION predates the disk-resident cache
936        // catalog. Publish the complete resume metadata first, then promote
937        // the header so the next boot may trust and reuse its derived roots.
938        if self.config.resident_memory_bytes_max.is_some()
939            && pager
940                .header()
941                .map_err(|error| StoreError::Io(std::io::Error::other(error.to_string())))?
942                .version
943                == reddb_file::file_format::PAGE_RESULT_CACHE_FILE_VERSION
944        {
945            pager
946                .fence_file_format_version(
947                    reddb_file::file_format::PAGE_RESULT_CACHE_DISK_FILE_VERSION,
948                )
949                .map_err(|error| StoreError::Io(std::io::Error::other(error.to_string())))?;
950        }
951        *self.recovery_xid_summary.write() = next_xid_summary;
952        self.ownership_catalog_epoch
953            .store(catalog_epoch, Ordering::Release);
954        #[cfg(test)]
955        if !reclaim.is_empty()
956            && std::env::var_os("REDDB_EXPERIMENTAL_EXIT_BEFORE_RETIRED_FREE").is_some()
957        {
958            eprintln!(
959                "experimental exit before retired free pages={}",
960                reclaim.len()
961            );
962            std::process::exit(184);
963        }
964        let mut index = 0;
965        retired.retain_mut(|entry| {
966            let keep = !remove.contains(&index);
967            entry.published = true;
968            index += 1;
969            keep
970        });
971        if !reclaim.is_empty() {
972            pager
973                .free_retired_pages(&reclaim)
974                .map_err(|e| StoreError::Io(std::io::Error::other(e.to_string())))?;
975            #[cfg(test)]
976            if std::env::var_os("REDDB_EXPERIMENTAL_EXIT_AFTER_RETIRED_ENQUEUE").is_some() {
977                eprintln!(
978                    "experimental exit after retired enqueue pages={}",
979                    reclaim.len()
980                );
981                std::process::exit(186);
982            }
983            pager
984                .sync()
985                .map_err(|e| StoreError::Io(std::io::Error::other(e.to_string())))?;
986            #[cfg(test)]
987            if std::env::var_os("REDDB_EXPERIMENTAL_EXIT_AFTER_RETIRED_FREE").is_some() {
988                eprintln!(
989                    "experimental exit after retired free pages={}",
990                    reclaim.len()
991                );
992                std::process::exit(185);
993            }
994        }
995        pager
996            .complete_deferred_publication(&retained_pages, &released_pages)
997            .map_err(|e| StoreError::Io(std::io::Error::other(e.to_string())))?;
998        if !released_pages.is_empty() {
999            pager
1000                .sync()
1001                .map_err(|e| StoreError::Io(std::io::Error::other(e.to_string())))?;
1002        }
1003        Ok(())
1004    }
1005
1006    pub(super) fn retire_btree(&self, tree: Arc<BTree>) {
1007        let mut retired = self.retired_btrees.lock();
1008        if tree.root_page_id() != 0 && !retired.iter().any(|entry| Arc::ptr_eq(&entry.tree, &tree))
1009        {
1010            retired.push(RetiredBTree {
1011                tree,
1012                published: false,
1013            });
1014        }
1015    }
1016
1017    /// A completed inline insert already made this tree match its manager.
1018    /// Advance the reuse proof only for that single, fenced mutation. Any
1019    /// untracked edit or segment publication keeps the full comparison path.
1020    pub(crate) fn note_paged_insert(
1021        &self,
1022        collection: &str,
1023        manager: &Arc<SegmentManager>,
1024        before: &[(u64, u64)],
1025        id: EntityId,
1026    ) {
1027        let after = manager.checkpoint_revisions();
1028        if before.len() != after.len() {
1029            return;
1030        }
1031        let mut changed = 0;
1032        for ((old_id, old_revision), (new_id, new_revision)) in before.iter().zip(&after) {
1033            if old_id != new_id {
1034                return;
1035            }
1036            if old_revision != new_revision {
1037                if old_revision.checked_add(1) != Some(*new_revision) {
1038                    return;
1039                }
1040                changed += 1;
1041            }
1042        }
1043        if changed != 1 {
1044            return;
1045        }
1046        let mut versions = self.checkpoint_versions.write();
1047        let Some(previous) = versions.get_mut(collection) else {
1048            return;
1049        };
1050        if previous.manager.ptr_eq(&Arc::downgrade(manager))
1051            && previous.segments == before
1052            && previous.format_version == self.format_version()
1053            && !previous.pending_vault.contains(&id)
1054            && !previous.excluded_xids.contains(&0)
1055        {
1056            previous.segments = after;
1057        }
1058    }
1059
1060    /// Successful physical deletes already changed both the manager and tree.
1061    /// Reuse remains valid only when their exact revision delta accounts for
1062    /// this batch, with no intervening edits or segment replacement.
1063    pub(crate) fn note_paged_delete(
1064        &self,
1065        collection: &str,
1066        manager: &Arc<SegmentManager>,
1067        before: &[(u64, u64)],
1068        deleted: &[EntityId],
1069    ) {
1070        let after = manager.checkpoint_revisions();
1071        if before.len() != after.len() {
1072            return;
1073        }
1074        let mut changes = 0_u64;
1075        for ((old_id, old_revision), (new_id, new_revision)) in before.iter().zip(&after) {
1076            if old_id != new_id {
1077                return;
1078            }
1079            let Some(delta) = new_revision.checked_sub(*old_revision) else {
1080                return;
1081            };
1082            let Some(total) = changes.checked_add(delta) else {
1083                return;
1084            };
1085            changes = total;
1086        }
1087        let Ok(expected) = u64::try_from(deleted.len()) else {
1088            return;
1089        };
1090        if changes != expected {
1091            return;
1092        }
1093        let mut versions = self.checkpoint_versions.write();
1094        let Some(previous) = versions.get_mut(collection) else {
1095            return;
1096        };
1097        if !previous.manager.ptr_eq(&Arc::downgrade(manager))
1098            || previous.format_version != self.format_version()
1099            || deleted.iter().any(|id| previous.pending_vault.contains(id))
1100            || previous.excluded_xids.contains(&0)
1101        {
1102            return;
1103        }
1104        if previous.segments != before {
1105            let Some(deferred) = previous.deferred_rows.as_ref() else {
1106                return;
1107            };
1108            let Some(changed) = manager.checkpoint_mutations_since(&previous.segments) else {
1109                return;
1110            };
1111            let removed: std::collections::HashSet<_> = deleted.iter().copied().collect();
1112            let pending: std::collections::BTreeSet<_> = deferred
1113                .iter()
1114                .copied()
1115                .chain(changed)
1116                .filter(|id| !removed.contains(id))
1117                .collect();
1118            if pending.len() > crate::storage::unified::segment::CHECKPOINT_MUTATIONS_MAX {
1119                return;
1120            }
1121            // GC already applied its deletes to the tree. Carry earlier dirty
1122            // IDs forward instead of spending their journal on these deletes.
1123            // Sparse projection still requires exact WAL proof for every
1124            // differing row, so an unlogged manager edit cannot be concealed.
1125            previous.has_unprojected_rows = !pending.is_empty();
1126            previous.deferred_rows = Some(Arc::new(pending.into_iter().collect()));
1127        }
1128        previous.segments = after;
1129    }
1130
1131    pub(crate) fn mark_paged_registry_dirty(&self) {
1132        self.resident.mark_paged_registry_dirty()
1133    }
1134
1135    /// Get (or lazily create) the per-collection B-tree under a *read*
1136    /// lock whenever possible. Returns a cloned `Arc<BTree>` so callers
1137    /// can mutate the tree without holding the outer map's RwLock —
1138    /// previously every insert serialised on `btree_indices.write()`,
1139    /// costing ~60% of the concurrent-insert throughput ceiling.
1140    pub(crate) fn get_or_create_btree(&self, collection: &str) -> Option<Arc<BTree>> {
1141        let collection = Self::canonical_collection(collection);
1142        let pager = self.pager.as_ref()?;
1143        if let Some(btree) = self.btree_indices.read().get(collection).cloned() {
1144            return Some(btree);
1145        }
1146        let mut write = self.btree_indices.write();
1147        let btree = write
1148            .entry(collection.to_string())
1149            .or_insert_with(|| Arc::new(BTree::new(Arc::clone(pager))))
1150            .clone();
1151        Some(btree)
1152    }
1153
1154    pub(crate) fn flush_paged_state(&self) -> Result<(), StoreError> {
1155        if self.is_read_only() {
1156            return Ok(());
1157        }
1158        let Some(pager) = &self.pager else {
1159            return Ok(());
1160        };
1161
1162        if self.resident.paged_registry_dirty.load(Ordering::Acquire) {
1163            self.flush_paged_registry()?;
1164            self.resident
1165                .paged_registry_dirty
1166                .store(false, Ordering::Release);
1167            return Ok(());
1168        }
1169
1170        pager
1171            .flush()
1172            .map_err(|e| StoreError::Io(std::io::Error::other(e.to_string())))
1173    }
1174
1175    pub(crate) fn flush_paged_registry(&self) -> Result<(), StoreError> {
1176        if self.is_read_only() {
1177            return Ok(());
1178        }
1179        let _vault_guard = self.vault_publication_lock.lock();
1180        let Some(pager) = &self.pager else {
1181            return Ok(());
1182        };
1183        let _ownership_guard = pager.lock_ownership_mutations();
1184        struct RegistryPublication<'a> {
1185            pager: &'a Pager,
1186            active: bool,
1187        }
1188        impl Drop for RegistryPublication<'_> {
1189            fn drop(&mut self) {
1190                if self.active {
1191                    self.pager.fence_interrupted_ownership();
1192                }
1193            }
1194        }
1195        let epoch = if let Some(commit) = &self.commit {
1196            if self.ownership_active_epoch.load(Ordering::Acquire) == 0 {
1197                commit.force_sync().map_err(StoreError::Io)?;
1198                let epoch = commit
1199                    .next_page_ownership_epoch(self)
1200                    .map_err(StoreError::Io)?;
1201                commit
1202                    .begin_page_ownership_epoch(self, epoch)
1203                    .map_err(StoreError::Io)?;
1204                Some(epoch)
1205            } else {
1206                None
1207            }
1208        } else {
1209            None
1210        };
1211        let mut publication = RegistryPublication {
1212            pager,
1213            active: epoch.is_some(),
1214        };
1215
1216        match pager.read_manifest_page() {
1217            Ok(_) => {}
1218            Err(PagerError::PageNotFound(_)) => {
1219                let meta_page = pager
1220                    .allocate_page(crate::storage::engine::PageType::Header)
1221                    .map_err(|e| StoreError::Io(std::io::Error::other(e.to_string())))?;
1222                pager
1223                    .write_page(meta_page.page_id(), meta_page)
1224                    .map_err(|e| StoreError::Io(std::io::Error::other(e.to_string())))?;
1225            }
1226            Err(e) => {
1227                return Err(StoreError::Io(std::io::Error::other(e.to_string())));
1228            }
1229        }
1230
1231        let format_version = self.format_version().max(STORE_VERSION_V11);
1232        self.set_format_version(format_version);
1233
1234        let collections = self.resident.collections.read();
1235        let btree_indices = self.btree_indices.read();
1236        let mut collection_roots = Vec::with_capacity(collections.len());
1237        for (name, _) in collections.iter() {
1238            let root_page = btree_indices
1239                .get(name)
1240                .map_or(0, |btree| btree.root_page_id());
1241            collection_roots.push((name.clone(), root_page));
1242        }
1243        drop(btree_indices);
1244        drop(collections);
1245
1246        let mut meta_data = Vec::with_capacity(4096);
1247        reddb_file::encode_native_paged_metadata_header(
1248            &mut meta_data,
1249            reddb_file::NativePagedMetadataHeader {
1250                format_version,
1251                collection_count: collection_roots.len() as u32,
1252            },
1253        );
1254        for (name, root_page) in &collection_roots {
1255            reddb_file::encode_native_paged_collection_root(&mut meta_data, name, *root_page);
1256        }
1257
1258        let excluded = self
1259            .snapshot_manager
1260            .get()
1261            .map_or_else(Default::default, |manager| {
1262                manager.checkpoint_excluded_state().1
1263            });
1264        let hidden_graph_ids = self
1265            .resident
1266            .snapshot_excluded_graph_ids(&Default::default(), &excluded);
1267        let cross_refs = self.resident.cross_refs.read();
1268        let total_refs: usize = cross_refs
1269            .iter()
1270            .map(|(source, refs)| {
1271                if hidden_graph_ids.contains(source) {
1272                    0
1273                } else {
1274                    refs.iter()
1275                        .filter(|(target, _, _)| !hidden_graph_ids.contains(target))
1276                        .count()
1277                }
1278            })
1279            .sum();
1280        meta_data.extend_from_slice(&(total_refs as u32).to_le_bytes());
1281        for (source_id, refs) in cross_refs.iter() {
1282            if hidden_graph_ids.contains(source_id) {
1283                continue;
1284            }
1285            for (target_id, ref_type, collection) in refs {
1286                if hidden_graph_ids.contains(target_id) {
1287                    continue;
1288                }
1289                reddb_file::encode_native_paged_cross_ref(
1290                    &mut meta_data,
1291                    source_id.raw(),
1292                    target_id.raw(),
1293                    ref_type.to_byte(),
1294                    reddb_engine::storage::system_collection_names::canonical_or_self(collection),
1295                );
1296            }
1297        }
1298
1299        self.publish_reclaimable_trees(
1300            pager,
1301            &mut meta_data,
1302            &self.btree_indices.read(),
1303            &self.key_indices.read(),
1304        )?;
1305        if let Some(epoch) = epoch {
1306            self.commit
1307                .as_ref()
1308                .expect("registry epoch has a coordinator")
1309                .finish_count_batch_ownership_epoch(self, epoch)
1310                .map_err(StoreError::Io)?;
1311            publication.active = false;
1312        }
1313        Ok(())
1314    }
1315
1316    /// Get a reference to the underlying pager (if in paged mode).
1317    pub fn pager(&self) -> Option<&Arc<Pager>> {
1318        self.pager.as_ref()
1319    }
1320
1321    pub(crate) fn ensure_pager_writeback_available(&self) -> Result<(), StoreError> {
1322        self.pager.as_ref().map_or(Ok(()), |pager| {
1323            pager
1324                .check_writeback_available()
1325                .map_err(|error| StoreError::Io(std::io::Error::other(error.to_string())))
1326        })
1327    }
1328
1329    pub(crate) fn is_read_only(&self) -> bool {
1330        self.pager
1331            .as_ref()
1332            .is_some_and(|pager| pager.is_read_only())
1333    }
1334
1335    /// Bytes of page-cache memory currently occupied by resident pages
1336    /// (ADR 0073 §2). Slots are fixed size, so occupancy times the page size
1337    /// *is* the footprint; a store without a pager occupies none.
1338    pub fn page_cache_bytes_in_use(&self) -> u64 {
1339        self.pager.as_ref().map_or(0, |pager| {
1340            pager.cache_len() as u64 * crate::storage::memory_pools::PAGE_CACHE_PAGE_SIZE_BYTES
1341        })
1342    }
1343
1344    /// Bytes of WAL buffer memory held by this store's group-commit
1345    /// coordinator: queued records plus the writer's fixed append buffer.
1346    pub fn wal_buffer_bytes_in_use(&self) -> u64 {
1347        self.commit
1348            .as_ref()
1349            .map_or(0, |commit| commit.buffered_bytes())
1350    }
1351
1352    /// Approximate resident bytes across every collection's segment arena
1353    /// (ADR 0073 §2). Same number `stats().total_memory_bytes` reports, without
1354    /// building the per-collection stats map a sampler would throw away.
1355    pub fn segment_memory_bytes(&self) -> u64 {
1356        let managers: Vec<Arc<SegmentManager>> = self
1357            .resident
1358            .collections
1359            .read()
1360            .values()
1361            .map(Arc::clone)
1362            .collect();
1363
1364        managers
1365            .iter()
1366            .map(|manager| manager.resident_bytes())
1367            .fold(0, u64::saturating_add)
1368    }
1369
1370    /// Borrow the immutable store configuration. Runtime hooks (e.g. the
1371    /// `auto_index_id` first-insert hook in `MutationEngine`) read knobs
1372    /// off this struct without going through the legacy global config tree.
1373    pub fn config(&self) -> &UnifiedStoreConfig {
1374        &self.config
1375    }
1376
1377    pub fn with_config(config: UnifiedStoreConfig) -> Self {
1378        Self {
1379            config,
1380            resident: reddb_engine::storage::resident_store::ResidentStore::new(),
1381            format_version: AtomicU32::new(STORE_VERSION_V11),
1382            pager: None,
1383            db_path: None,
1384            operational_manifest: parking_lot::Mutex::new(None),
1385            btree_indices: RwLock::new(HashMap::new()),
1386            key_indices: RwLock::new(HashMap::new()),
1387            secondary_index_specs: RwLock::new(HashMap::new()),
1388            count_builds: RwLock::new(HashMap::new()),
1389            count_projection_seeds: parking_lot::Mutex::new(HashMap::new()),
1390            queue_dedup_cursors: parking_lot::Mutex::new(HashMap::new()),
1391            recovery_xid_summary: RwLock::new(None),
1392            checkpoint_versions: RwLock::new(HashMap::new()),
1393            checkpoint_durable_rows: RwLock::new(super::checkpoint_delta::DurableRows::default()),
1394            retired_btrees: parking_lot::Mutex::new(Vec::new()),
1395            ownership_catalog_epoch: AtomicU64::new(0),
1396            ownership_active_epoch: AtomicU64::new(0),
1397            detached_native_blob_pages: parking_lot::Mutex::new(Default::default()),
1398            commit: None,
1399            replayed_turbo_inserts: parking_lot::Mutex::new(HashMap::new()),
1400            replayed_probabilistic_deltas: parking_lot::Mutex::new(Vec::new()),
1401            result_cache_epoch: Arc::new(result_cache_epoch::ResultCacheEpochState::default()),
1402            snapshot_manager: std::sync::OnceLock::new(),
1403            embedded_commit_session: std::sync::OnceLock::new(),
1404            #[cfg(test)]
1405            test_fail_deferred_wal_append: std::sync::atomic::AtomicBool::new(false),
1406            #[cfg(test)]
1407            test_versioned_update_after_old: parking_lot::Mutex::new(None),
1408            #[cfg(test)]
1409            test_fail_versioned_update_phase: std::sync::atomic::AtomicU8::new(0),
1410            #[cfg(test)]
1411            test_require_deferred_publication_fence: std::sync::atomic::AtomicBool::new(false),
1412            vault_publication_lock: parking_lot::ReentrantMutex::new(()),
1413            row_publications: Default::default(),
1414            pending_vault_versions: RwLock::new(std::collections::HashSet::new()),
1415        }
1416    }
1417
1418    /// Open or create a page-based database
1419    ///
1420    /// This uses the page engine for ACID durability with B-tree indices.
1421    /// The database file uses 16 KiB pages with checksums and efficient caching.
1422    ///
1423    /// # Arguments
1424    ///
1425    /// * `path` - Path to the database file (e.g., "data.rdb")
1426    ///
1427    /// # Example
1428    ///
1429    /// ```rust,ignore
1430    /// let store = UnifiedStore::open("security.rdb")?;
1431    /// store.create_collection("hosts")?;
1432    /// // ... operations ...
1433    /// store.persist()?; // Flush to disk
1434    /// ```
1435    pub fn open(path: impl AsRef<Path>) -> Result<Self, StoreError> {
1436        Self::open_with_config(path, UnifiedStoreConfig::default())
1437    }
1438
1439    pub fn open_with_config(
1440        path: impl AsRef<Path>,
1441        config: UnifiedStoreConfig,
1442    ) -> Result<Self, StoreError> {
1443        Self::open_with_config_for_recovery(path, config, false, None, None)
1444    }
1445
1446    /// Recover a native database into a private read view. No WAL writer,
1447    /// checkpoint, manifest publication or allocator reclamation is attached.
1448    pub fn open_read_only_with_config(
1449        path: impl AsRef<Path>,
1450        mut config: UnifiedStoreConfig,
1451    ) -> Result<Self, StoreError> {
1452        let path = path.as_ref();
1453        config.embedded_wal_path = None;
1454        let pager = Pager::open(
1455            path,
1456            PagerConfig {
1457                read_only: true,
1458                create: false,
1459                disk_residency: config.resident_memory_bytes_max.is_some(),
1460                cache_size: config
1461                    .page_cache_slots
1462                    .unwrap_or_else(|| PagerConfig::default().cache_size),
1463                ..PagerConfig::default()
1464            },
1465        )
1466        .map_err(|error| StoreError::Io(std::io::Error::other(error.to_string())))?;
1467        pager
1468            .enable_deferred_reclamation()
1469            .map_err(|error| StoreError::Io(std::io::Error::other(error.to_string())))?;
1470        let mut store = Self::with_config(config);
1471        store.pager = Some(Arc::new(pager));
1472        store.db_path = Some(path.to_path_buf());
1473        let wal_path = reddb_file::layout::unified_wal_path(path);
1474        StoreCommitCoordinator::restore_checkpoint_page_images_from_path(&store, &wal_path)
1475            .map_err(StoreError::Io)?;
1476        let catalog = store.load_from_pages(false, None)?;
1477        StoreCommitCoordinator::replay_read_only(&store, &wal_path).map_err(StoreError::Io)?;
1478        store.load_result_cache_epoch()?;
1479        store.require_normalized_red_config_keys()?;
1480        let pending_drops = store.legacy_pending_collection_drops()?.unwrap_or_default();
1481        for name in pending_drops {
1482            if !catalog
1483                .collisions
1484                .iter()
1485                .any(|collision| collision.loser == name)
1486                && store.get_collection(&name).is_some()
1487            {
1488                store.drop_collection_in_memory(&name)?;
1489            }
1490        }
1491        Ok(store)
1492    }
1493
1494    /// Only for an isolated, operator-selected recovery copy. The caller must
1495    /// first validate every WAL action addressing the discarded identity.
1496    pub(crate) fn open_with_config_for_recovery(
1497        path: impl AsRef<Path>,
1498        config: UnifiedStoreConfig,
1499        retain_canonical_system_config: bool,
1500        boot_wal: Option<&crate::storage::compact::boot_system_wal::BootSystemWalPlan>,
1501        catalog_plan: Option<&crate::storage::compact::catalog_aliases::CanonicalCatalogPlan>,
1502    ) -> Result<Self, StoreError> {
1503        let path = path.as_ref();
1504        let mut pager_config = PagerConfig {
1505            disk_residency: config.resident_memory_bytes_max.is_some(),
1506            ..PagerConfig::default()
1507        };
1508        // ADR 0073 §2 — the page cache is pre-sized from its budget share.
1509        // The pager's own `cache_size` default survives only for direct
1510        // library callers who never resolved a budget.
1511        if let Some(slots) = config.page_cache_slots {
1512            pager_config.cache_size = slots;
1513        }
1514        // Tunables via env — experimental, used by the benchmark harness
1515        // to compare durability profiles head-to-head with Postgres.
1516        // REDDB_DOUBLE_WRITE=0 requests skipping the double-write buffer,
1517        // which otherwise adds two fsyncs per pager flush (one on DWB, one
1518        // on the main file). The pager honors this only when the actual
1519        // data file is proven to live on a CoW filesystem with atomic page
1520        // writes; otherwise it fails closed and keeps DWB enabled.
1521        if matches!(
1522            std::env::var("REDDB_DOUBLE_WRITE").ok().as_deref(),
1523            Some("0") | Some("false") | Some("off")
1524        ) {
1525            pager_config.double_write = false;
1526        }
1527        let pager_open_started = std::time::Instant::now();
1528        let pager = Pager::open(path, pager_config)
1529            .map_err(|e| StoreError::Io(std::io::Error::other(e.to_string())))?;
1530        pager
1531            .enable_deferred_reclamation()
1532            .map_err(|e| StoreError::Io(std::io::Error::other(e.to_string())))?;
1533        trace_startup_phase(path, "paged_pager_open", pager_open_started);
1534
1535        let header = pager
1536            .header()
1537            .map_err(|error| StoreError::Io(std::io::Error::other(error.to_string())))?;
1538        if config.resident_memory_bytes_max.is_none() && header.requires_disk_residency() {
1539            return Err(StoreError::Io(std::io::Error::new(std::io::ErrorKind::Unsupported,
1540                "this database was prepared for disk residency; reopen with the disk-resident storage profile")));
1541        }
1542
1543        let wal_path = reddb_file::layout::unified_wal_path(path);
1544        // Explicit transactions and WAL-only vault versions need committed
1545        // batches in every durability mode, including Strict's inline fsync.
1546        let commit = Some(Arc::new(
1547            StoreCommitCoordinator::open(wal_path, config.durability_mode, config.group_commit)
1548                .map_err(StoreError::Io)?,
1549        ));
1550
1551        let store = Self {
1552            config,
1553            resident: reddb_engine::storage::resident_store::ResidentStore::new(),
1554            format_version: AtomicU32::new(STORE_VERSION_V11),
1555            pager: Some(Arc::new(pager)),
1556            db_path: Some(path.to_path_buf()),
1557            operational_manifest: parking_lot::Mutex::new(None),
1558            btree_indices: RwLock::new(HashMap::new()),
1559            key_indices: RwLock::new(HashMap::new()),
1560            secondary_index_specs: RwLock::new(HashMap::new()),
1561            count_builds: RwLock::new(HashMap::new()),
1562            count_projection_seeds: parking_lot::Mutex::new(HashMap::new()),
1563            queue_dedup_cursors: parking_lot::Mutex::new(HashMap::new()),
1564            recovery_xid_summary: RwLock::new(None),
1565            checkpoint_versions: RwLock::new(HashMap::new()),
1566            checkpoint_durable_rows: RwLock::new(super::checkpoint_delta::DurableRows::default()),
1567            retired_btrees: parking_lot::Mutex::new(Vec::new()),
1568            ownership_catalog_epoch: AtomicU64::new(0),
1569            ownership_active_epoch: AtomicU64::new(0),
1570            detached_native_blob_pages: parking_lot::Mutex::new(Default::default()),
1571            commit,
1572            replayed_turbo_inserts: parking_lot::Mutex::new(HashMap::new()),
1573            replayed_probabilistic_deltas: parking_lot::Mutex::new(Vec::new()),
1574            result_cache_epoch: Arc::new(result_cache_epoch::ResultCacheEpochState::default()),
1575            snapshot_manager: std::sync::OnceLock::new(),
1576            embedded_commit_session: std::sync::OnceLock::new(),
1577            #[cfg(test)]
1578            test_fail_deferred_wal_append: std::sync::atomic::AtomicBool::new(false),
1579            #[cfg(test)]
1580            test_versioned_update_after_old: parking_lot::Mutex::new(None),
1581            #[cfg(test)]
1582            test_fail_versioned_update_phase: std::sync::atomic::AtomicU8::new(0),
1583            #[cfg(test)]
1584            test_require_deferred_publication_fence: std::sync::atomic::AtomicBool::new(false),
1585            vault_publication_lock: parking_lot::ReentrantMutex::new(()),
1586            row_publications: Default::default(),
1587            pending_vault_versions: RwLock::new(std::collections::HashSet::new()),
1588        };
1589
1590        // An interrupted structural edit may have reached home pages without
1591        // publishing its catalog. Restore those pages before following roots.
1592        let checkpoint_recovery_started = std::time::Instant::now();
1593        if let Some(commit) = &store.commit {
1594            commit
1595                .restore_checkpoint_page_images(&store)
1596                .map_err(StoreError::Io)?;
1597        }
1598        trace_startup_phase(
1599            path,
1600            "paged_checkpoint_recovery",
1601            checkpoint_recovery_started,
1602        );
1603        // Load existing data from pages if database exists
1604        let page_load_started = std::time::Instant::now();
1605        let catalog_load = store.load_from_pages(retain_canonical_system_config, catalog_plan)?;
1606        trace_startup_phase(path, "paged_catalog_and_roots", page_load_started);
1607        let wal_replay_started = std::time::Instant::now();
1608        if let Some(commit) = &store.commit {
1609            commit
1610                .replay_into(&store, boot_wal)
1611                .map_err(StoreError::Io)?;
1612        }
1613        store.load_result_cache_epoch()?;
1614        let normalized_config_keys = store.normalize_red_config_keys()?;
1615        if normalized_config_keys > 0 {
1616            tracing::warn!(
1617                migrated = normalized_config_keys,
1618                "normalized legacy red.config keys before boot config consumers"
1619            );
1620        }
1621        trace_startup_phase(path, "paged_wal_replay", wal_replay_started);
1622        if catalog_load.migrated {
1623            // Publish only the renamed catalog keys. Existing row, key and
1624            // secondary-index roots remain attached and are not rebuilt.
1625            store.mark_paged_registry_dirty();
1626            store.flush_paged_state()?;
1627        }
1628        let manifest_recovery_started = std::time::Instant::now();
1629        store.recover_operational_manifest(&catalog_load.collisions)?;
1630        if store.config.resident_memory_bytes_max.is_some() {
1631            let missing_key_index = {
1632                let trees = store.btree_indices.read();
1633                let keys = store.key_indices.read();
1634                trees.iter().any(|(name, tree)| {
1635                    !keys
1636                        .get(name)
1637                        .is_some_and(|index| index.source.ptr_eq(&Arc::downgrade(tree)))
1638                })
1639            };
1640            if missing_key_index {
1641                store.persist()?;
1642            }
1643        }
1644        trace_startup_phase(
1645            path,
1646            "paged_manifest_and_derived_roots",
1647            manifest_recovery_started,
1648        );
1649
1650        Ok(store)
1651    }
1652
1653    /// Replay changes the authoritative managers first. Build replacement trees
1654    /// only for changed collections, leaving every published tree untouched until
1655    /// all replacement pages are durable. Retain the WAL for auxiliary recovery.
1656    pub(crate) fn reconcile_replayed_btrees(&self) -> Result<(), StoreError> {
1657        let Some(pager) = &self.pager else {
1658            return Ok(());
1659        };
1660        let collections = self.resident.collections.read();
1661        let mut indices = self.btree_indices.write();
1662        let previous_versions = self.checkpoint_versions.read();
1663        let raw_replay =
1664            self.snapshot_manager.get().is_none() && self.pending_vault_versions.read().is_empty();
1665        for (name, manager) in collections.iter() {
1666            let mut reusable = None;
1667            if raw_replay {
1668                if let (Some(previous), Some(tree)) = (
1669                    previous_versions.get(name),
1670                    indices.get(name).filter(|tree| tree.has_page_proofs()),
1671                ) {
1672                    let mut current = CheckpointVersion {
1673                        manager: Arc::downgrade(manager),
1674                        segments: manager.checkpoint_revisions(),
1675                        visibility_revision: 0,
1676                        untracked_visibility_revision: 0,
1677                        excluded_xids: Arc::default(),
1678                        pending_vault: Arc::default(),
1679                        format_version: self.format_version(),
1680                        deferred_rows: Some(Arc::default()),
1681                        has_unprojected_rows: false,
1682                    };
1683                    if previous.manager.ptr_eq(&current.manager)
1684                        && previous.format_version == current.format_version
1685                    {
1686                        if previous.segments == current.segments
1687                            && !previous.has_unprojected_rows
1688                            && checkpoint_visibility_matches(previous, &current)
1689                        {
1690                            reusable = Some(Arc::clone(tree));
1691                        } else {
1692                            // The replay ownership epoch protects in-place
1693                            // changes with the same undo/publication protocol
1694                            // used by normal sparse checkpoints. Missing WAL
1695                            // proof, old formats and exhausted journals retain
1696                            // the full comparison/rebuild path below.
1697                            reusable = self.sparse_checkpoint_tree(
1698                                name,
1699                                manager,
1700                                tree,
1701                                previous,
1702                                &mut current,
1703                            )?;
1704                        }
1705                    }
1706                }
1707            }
1708            let tree = match reusable {
1709                Some(tree) => tree,
1710                None => {
1711                    self.checkpoint_tree(pager, manager, indices.get(name), |entity, metadata| {
1712                        #[cfg(test)]
1713                        REPLAY_SERIALIZED_ROWS.with(|count| count.set(count.get() + 1));
1714                        Some(Self::serialize_entity_record(
1715                            entity,
1716                            metadata.as_ref(),
1717                            self.format_version(),
1718                        ))
1719                    })?
1720                }
1721            };
1722            if indices
1723                .get(name)
1724                .is_some_and(|previous| Arc::ptr_eq(previous, &tree))
1725            {
1726                continue;
1727            }
1728            if let Some(previous) = indices.insert(name.clone(), tree) {
1729                self.retire_btree(previous);
1730            }
1731            self.mark_paged_registry_dirty();
1732            #[cfg(test)]
1733            if std::env::var_os("REDDB_EXPERIMENTAL_EXIT_DURING_REPLAY_REBUILD").is_some() {
1734                if std::env::var_os("REDDB_EXPERIMENTAL_REPLAY_NO_SYNC").is_none() {
1735                    pager.sync().map_err(|error| {
1736                        StoreError::Io(std::io::Error::other(error.to_string()))
1737                    })?;
1738                }
1739                eprintln!("experimental replay rebuild exit before manifest");
1740                std::process::exit(182);
1741            }
1742        }
1743        drop(previous_versions);
1744        drop(indices);
1745        drop(collections);
1746        self.flush_paged_state()?;
1747        #[cfg(test)]
1748        if std::env::var_os("REDDB_EXPERIMENTAL_EXIT_AFTER_REPLAY_PUBLICATION").is_some() {
1749            eprintln!("experimental replay exit after manifest");
1750            std::process::exit(182);
1751        }
1752        // Opening has no concurrent writers. Replay has now durably reconciled
1753        // these raw trees; do not immediately rebuild them at the first flush.
1754        if self.snapshot_manager.get().is_none() && self.pending_vault_versions.read().is_empty() {
1755            let collections = self.resident.collections.read();
1756            let mut versions = self.checkpoint_versions.write();
1757            for (name, manager) in collections.iter() {
1758                versions.insert(
1759                    name.clone(),
1760                    CheckpointVersion {
1761                        manager: Arc::downgrade(manager),
1762                        segments: manager.checkpoint_revisions(),
1763                        visibility_revision: 0,
1764                        untracked_visibility_revision: 0,
1765                        excluded_xids: Arc::default(),
1766                        pending_vault: Arc::default(),
1767                        format_version: self.format_version(),
1768                        deferred_rows: Some(Arc::default()),
1769                        has_unprojected_rows: false,
1770                    },
1771                );
1772            }
1773        }
1774        Ok(())
1775    }
1776
1777    fn legacy_pending_collection_drops(&self) -> Result<Option<Vec<String>>, StoreError> {
1778        match self.try_get_config("storage.legacy_manifest_imported")? {
1779            Some(Value::Boolean(true)) => return Ok(None),
1780            None => {}
1781            Some(_) => {
1782                return Err(StoreError::Serialization(
1783                    "invalid legacy manifest import marker".into(),
1784                ))
1785            }
1786        }
1787        let Some(path) = &self.db_path else {
1788            return Ok(None);
1789        };
1790        self.operational_manifest_for_path(path)
1791            .read_only_pending_drops_if_present()
1792            .map_err(|err| storage_integrity("manifest", "current", None, err.to_string()))
1793    }
1794
1795    fn recover_operational_manifest(
1796        &self,
1797        collisions: &[SystemCollectionCollision],
1798    ) -> Result<(), StoreError> {
1799        let Some(pending) = self.legacy_pending_collection_drops()? else {
1800            return Ok(());
1801        };
1802        // Consume legacy drop intents once, without rewriting their manifest.
1803        // WAL replay restores both the drops and this marker after a crash.
1804        for name in pending {
1805            if !collisions.iter().any(|collision| collision.loser == name)
1806                && self.get_collection(&name).is_some()
1807            {
1808                self.drop_collection(&name)?;
1809            }
1810        }
1811        self.try_set_config_tree(
1812            "storage.legacy_manifest_imported",
1813            &crate::serde_json::Value::Bool(true),
1814        )?;
1815        Ok(())
1816    }
1817
1818    /// Read the commit boundary before following potentially interrupted trees.
1819    pub(super) fn published_ownership_epoch(&self) -> Result<u64, StoreError> {
1820        let Some(pager) = &self.pager else {
1821            return Ok(0);
1822        };
1823        let Some(content) = read_meta_payload(pager)? else {
1824            return Ok(0);
1825        };
1826        reddb_file::native_store::decode_native_paged_catalog_ownership_epoch(&content)
1827            .map(|epoch| epoch.unwrap_or(0))
1828            .map_err(|error| StoreError::Serialization(error.to_string()))
1829    }
1830
1831    /// Read the B-tree indices and reconstruct collections from pages.
1832    fn load_from_pages(
1833        &self,
1834        retain_canonical_system_config: bool,
1835        catalog_plan: Option<&crate::storage::compact::catalog_aliases::CanonicalCatalogPlan>,
1836    ) -> Result<CatalogLoadOutcome, StoreError> {
1837        let pager = match &self.pager {
1838            Some(p) => p,
1839            None => return Ok(CatalogLoadOutcome::default()), // No pager, nothing to load
1840        };
1841
1842        // Get page count
1843        let page_count = pager.page_count().map_err(|e| {
1844            StoreError::Io(std::io::Error::other(format!(
1845                "failed to read page count: {}",
1846                e
1847            )))
1848        })?;
1849        if page_count <= 1 {
1850            // Empty database (only header page)
1851            return Ok(CatalogLoadOutcome::default());
1852        }
1853
1854        // Read metadata starting from page 1 (collections registry). The
1855        // helper transparently follows the `RDM3` overflow chain when the
1856        // metadata blob spans multiple pages and falls back to the legacy
1857        // `<data>-meta` shadow when page 1 itself is corrupted.
1858        if let Some(content_vec) = read_meta_payload(pager)? {
1859            let content: &[u8] = &content_vec;
1860            let header = pager
1861                .header()
1862                .map_err(|error| StoreError::Io(std::io::Error::other(error.to_string())))?;
1863            let prepared = header.requires_disk_residency();
1864            let proofs = reddb_file::native_store::decode_native_paged_catalog_tree_proofs(content)
1865                .map_err(|error| StoreError::Serialization(error.to_string()))?;
1866            if proofs.is_some()
1867                && (header.required_capabilities
1868                    & reddb_file::file_format::FILE_CAPABILITY_TREE_PROOFS
1869                    == 0
1870                    || pager
1871                        .manifest_reference()
1872                        .map_err(|error| StoreError::Io(std::io::Error::other(error.to_string())))?
1873                        .page_id
1874                        == 0)
1875            {
1876                return Err(page_integrity(
1877                    1,
1878                    None,
1879                    "tree proofs require an independently sealed catalog and reader capability",
1880                ));
1881            }
1882            if proofs.as_ref().is_some_and(|proofs| {
1883                proofs.iter().any(|(&root, proof)| {
1884                    root >= header.page_count || proof.reference.page_id >= header.page_count
1885                })
1886            }) {
1887                return Err(page_integrity(
1888                    1,
1889                    None,
1890                    "tree proof points outside allocation",
1891                ));
1892            }
1893            let resume = if prepared {
1894                reddb_file::native_resume::decode_catalog_resume(content)
1895                    .map_err(|error| StoreError::Serialization(error.to_string()))?
1896            } else {
1897                None
1898            };
1899            let mut outcome = CatalogLoadOutcome::default();
1900            let mut ignored_collections = std::collections::HashSet::new();
1901            let recovery_config_identities = resume.as_ref().map_or([false; 2], |resume| {
1902                [
1903                    resume.collections.contains_key("red_config"),
1904                    resume.collections.contains_key("red.config"),
1905                ]
1906            });
1907            if retain_canonical_system_config
1908                && !recovery_config_identities.iter().any(|present| *present)
1909            {
1910                return Err(page_integrity(
1911                    1,
1912                    None,
1913                    "canonical config recovery requires a resume identity",
1914                ));
1915            }
1916            if let Some(resume) = &resume {
1917                for &(legacy, canonical) in
1918                    reddb_engine::storage::system_collection_names::LEGACY_NAMES
1919                {
1920                    let (Some(legacy_state), Some(canonical_state)) = (
1921                        resume.collections.get(legacy),
1922                        resume.collections.get(canonical),
1923                    ) else {
1924                        continue;
1925                    };
1926                    let merged_resume = merge_shared_alias_resume(legacy_state, canonical_state);
1927                    let shared_physical_state = merged_resume.is_some();
1928                    let resume_counter_drift = legacy_state != canonical_state;
1929                    let (survivor, loser) = match (
1930                        legacy_state.entities == 0,
1931                        canonical_state.entities == 0,
1932                    ) {
1933                        _ if retain_canonical_system_config && legacy == "red_config" => {
1934                            (canonical, legacy)
1935                        }
1936                        _ if catalog_plan.is_some_and(|plan| plan.retires(legacy, canonical)) => {
1937                            (canonical, legacy)
1938                        }
1939                        (false, true) => (legacy, canonical),
1940                        (true, false) | (true, true) => (canonical, legacy),
1941                        (false, false) if shared_physical_state => (canonical, legacy),
1942                        (false, false) => {
1943                            let (
1944                                data_root_match,
1945                                key_index_root_match,
1946                                secondary_index_state_match,
1947                            ) = shared_alias_resume_matches(legacy_state, canonical_state);
1948                            return Err(page_integrity(
1949                                1,
1950                                None,
1951                                format!(
1952                                    "catalog contains both populated legacy collection '{legacy}' and canonical collection '{canonical}' (data_root_match={data_root_match}, key_index_root_match={key_index_root_match}, secondary_index_state_match={secondary_index_state_match})"
1953                                ),
1954                            ));
1955                        }
1956                    };
1957                    ignored_collections.insert(loser.to_string());
1958                    outcome.collisions.push(SystemCollectionCollision {
1959                        survivor: survivor.to_string(),
1960                        loser: loser.to_string(),
1961                        canonical: canonical.to_string(),
1962                        shared_physical_state,
1963                        resume_counter_drift,
1964                        merged_resume,
1965                    });
1966                }
1967                let live_roots = resume
1968                    .collections
1969                    .iter()
1970                    .filter(|(name, _)| !ignored_collections.contains(name.as_str()))
1971                    .flat_map(|(_, state)| {
1972                        std::iter::once(state.root_page)
1973                            .chain(state.key_index_root)
1974                            .chain(
1975                                state
1976                                    .secondary_indices
1977                                    .values()
1978                                    .map(|index| index.root_page),
1979                            )
1980                    })
1981                    .filter(|root| *root != 0)
1982                    .collect::<std::collections::HashSet<_>>();
1983                for collision in &outcome.collisions {
1984                    if collision.shared_physical_state {
1985                        continue;
1986                    }
1987                    let loser_state = resume
1988                        .collections
1989                        .get(&collision.loser)
1990                        .expect("collision loser is present");
1991                    if std::iter::once(loser_state.root_page)
1992                        .chain(loser_state.key_index_root)
1993                        .chain(
1994                            loser_state
1995                                .secondary_indices
1996                                .values()
1997                                .map(|index| index.root_page),
1998                        )
1999                        .filter(|root| *root != 0)
2000                        .any(|root| live_roots.contains(&root))
2001                    {
2002                        return Err(page_integrity(
2003                            1,
2004                            None,
2005                            format!(
2006                                "empty collection '{}' aliases a live collection root",
2007                                collision.loser
2008                            ),
2009                        ));
2010                    }
2011                }
2012                reddb_engine::storage::system_collection_names::migration_plan(
2013                    resume
2014                        .collections
2015                        .keys()
2016                        .map(String::as_str)
2017                        .filter(|name| !ignored_collections.contains(*name)),
2018                )
2019                .map_err(|(legacy, canonical)| {
2020                    page_integrity(
2021                        1,
2022                        None,
2023                        format!(
2024                            "catalog contains both legacy collection '{legacy}' and canonical collection '{canonical}'"
2025                        ),
2026                    )
2027                })?;
2028                for state in resume.collections.values() {
2029                    if !state.secondary_indices.is_empty()
2030                        && header.version
2031                            < reddb_file::file_format::PAGE_SECONDARY_INDEX_FILE_VERSION
2032                    {
2033                        return Err(page_integrity(
2034                            1,
2035                            None,
2036                            "secondary roots require the file format fence",
2037                        ));
2038                    }
2039                    for root in std::iter::once(state.root_page)
2040                        .chain(state.key_index_root)
2041                        .chain(
2042                            state
2043                                .secondary_indices
2044                                .values()
2045                                .map(|index| index.root_page),
2046                        )
2047                    {
2048                        if root >= page_count {
2049                            return Err(page_integrity(
2050                                root,
2051                                None,
2052                                "published root exceeds the database page count",
2053                            ));
2054                        }
2055                    }
2056                }
2057                self.resident
2058                    .next_entity_id
2059                    .fetch_max(resume.entity_id_next, Ordering::SeqCst);
2060            }
2061            *self.recovery_xid_summary.write() = resume.as_ref().and_then(|resume| {
2062                resume.xid_high_water_mark.map(|high_water_mark| {
2063                    super::recovery_xid::RecoveryXidSummary {
2064                        high_water_mark,
2065                        roots: resume
2066                            .collections
2067                            .iter()
2068                            .filter(|(name, _)| !ignored_collections.contains(name.as_str()))
2069                            .map(|(name, state)| {
2070                                (
2071                                    reddb_engine::storage::system_collection_names::canonical_or_self(name)
2072                                        .to_owned(),
2073                                    state.root_page,
2074                                )
2075                            })
2076                            .collect(),
2077                    }
2078                })
2079            });
2080            if content.len() >= 4 {
2081                let mut pos = 0;
2082                let mut format_version = STORE_VERSION_V1;
2083
2084                let collection_count = if let Some(header) =
2085                    reddb_file::decode_native_paged_metadata_header(content)
2086                        .map_err(|err| StoreError::Serialization(err.to_string()))?
2087                {
2088                    format_version = header.format_version;
2089                    pos += reddb_file::METADATA_HEADER_BYTES;
2090                    header.collection_count as usize
2091                } else {
2092                    let count = u32::from_le_bytes([
2093                        content[pos],
2094                        content[pos + 1],
2095                        content[pos + 2],
2096                        content[pos + 3],
2097                    ]) as usize;
2098                    pos += 4;
2099                    count
2100                };
2101
2102                self.set_format_version(format_version);
2103
2104                if pos > content.len() {
2105                    return Ok(CatalogLoadOutcome::default());
2106                }
2107
2108                if resume.is_none() {
2109                    // Old native catalogs have no advisory entity counters. A
2110                    // zero root is the only proof that an alias owns no rows;
2111                    // two nonzero roots still require explicit reconciliation.
2112                    let mut roots = std::collections::HashMap::new();
2113                    let mut root_pos = pos;
2114                    for _ in 0..collection_count {
2115                        let root =
2116                            reddb_file::decode_native_paged_collection_root(content, &mut root_pos)
2117                                .map_err(|err| page_integrity(1, None, err.to_string()))?;
2118                        if roots.insert(root.collection, root.root_page).is_some() {
2119                            return Err(page_integrity(
2120                                1,
2121                                None,
2122                                "duplicate raw collection identity",
2123                            ));
2124                        }
2125                    }
2126                    for &(legacy, canonical) in
2127                        reddb_engine::storage::system_collection_names::LEGACY_NAMES
2128                    {
2129                        let (Some(&legacy_root), Some(&canonical_root)) =
2130                            (roots.get(legacy), roots.get(canonical))
2131                        else {
2132                            continue;
2133                        };
2134                        let (survivor, loser) = match (legacy_root == 0, canonical_root == 0) {
2135                            (false, true) => (legacy, canonical),
2136                            (true, _) => (canonical, legacy),
2137                            (false, false) => continue,
2138                        };
2139                        ignored_collections.insert(loser.to_owned());
2140                        outcome.collisions.push(SystemCollectionCollision {
2141                            survivor: survivor.to_owned(),
2142                            loser: loser.to_owned(),
2143                            canonical: canonical.to_owned(),
2144                            shared_physical_state: false,
2145                            resume_counter_drift: false,
2146                            merged_resume: None,
2147                        });
2148                    }
2149                }
2150                let mut source_names = std::collections::HashMap::<String, String>::new();
2151                let mut migrated_catalog = !outcome.collisions.is_empty();
2152                let mut empty_duplicate_roots = std::collections::HashSet::new();
2153                let mut recovery_config_roots = [0u8; 2];
2154                // Older catalogs have no resume state, so the bounded loader
2155                // must already decode every physical entity to restore IDs.
2156                // Reuse that complete pass for the transaction floor too.
2157                let mut scanned_xid_high_water_mark = 0u64;
2158                let mut count_seed_bytes_remaining = self
2159                    .config
2160                    .resident_memory_bytes_max
2161                    .and_then(|limit| usize::try_from(limit).ok())
2162                    .unwrap_or(0)
2163                    / 2;
2164
2165                // Read collection names and their B-tree root pages
2166                for _ in 0..collection_count {
2167                    let root = reddb_file::decode_native_paged_collection_root(content, &mut pos)
2168                        .map_err(|err| page_integrity(1, None, err.to_string()))?;
2169                    // Root page ID for this collection's B-tree
2170                    let root_page = root.root_page;
2171                    let stored_name = root.collection;
2172                    if retain_canonical_system_config {
2173                        let config_index = match stored_name.as_str() {
2174                            "red_config" => Some(0),
2175                            "red.config" => Some(1),
2176                            _ => None,
2177                        };
2178                        if let Some(index) = config_index {
2179                            recovery_config_roots[index] =
2180                                recovery_config_roots[index].saturating_add(1);
2181                            let Some(state) = resume
2182                                .as_ref()
2183                                .and_then(|resume| resume.collections.get(&stored_name))
2184                            else {
2185                                return Err(page_integrity(
2186                                    1,
2187                                    None,
2188                                    "config collection has no matching resume identity",
2189                                ));
2190                            };
2191                            if state.root_page != root_page {
2192                                return Err(page_integrity(
2193                                    1,
2194                                    None,
2195                                    "config collection root differs from resume catalog",
2196                                ));
2197                            }
2198                        }
2199                    }
2200                    if ignored_collections.contains(&stored_name) {
2201                        if resume.is_none() && root_page == 0 {
2202                            continue;
2203                        }
2204                        let shared_physical_state = outcome
2205                            .collisions
2206                            .iter()
2207                            .find(|collision| collision.loser == stored_name)
2208                            .is_some_and(|collision| collision.shared_physical_state);
2209                        if shared_physical_state {
2210                            continue;
2211                        }
2212                        let state = resume
2213                            .as_ref()
2214                            .and_then(|resume| resume.collections.get(&stored_name))
2215                            .expect("ignored collection was proven by catalog resume");
2216                        for retired_root in std::iter::once(state.root_page)
2217                            .chain(state.key_index_root)
2218                            .chain(
2219                                state
2220                                    .secondary_indices
2221                                    .values()
2222                                    .map(|index| index.root_page),
2223                            )
2224                            .filter(|root| *root != 0)
2225                        {
2226                            empty_duplicate_roots.insert(retired_root);
2227                        }
2228                        continue;
2229                    }
2230                    let name = reddb_engine::storage::system_collection_names::canonical_or_self(
2231                        &stored_name,
2232                    )
2233                    .to_owned();
2234                    if let Some(previous) = source_names.insert(name.clone(), stored_name.clone()) {
2235                        if previous != stored_name {
2236                            return Err(page_integrity(
2237                                1,
2238                                None,
2239                                format!(
2240                                    "catalog contains both legacy collection '{previous}' and canonical collection '{name}'"
2241                                ),
2242                            ));
2243                        }
2244                    }
2245                    migrated_catalog |= name != stored_name;
2246
2247                    // Hydrate the collection in memory only. Loading must
2248                    // not emit WAL entries or rewrite the on-disk registry
2249                    // before the existing B-tree roots are attached.
2250                    let _ = self.create_collection_in_memory(&name);
2251
2252                    let shared_alias = outcome.collisions.iter().find(|collision| {
2253                        collision.survivor == stored_name && collision.shared_physical_state
2254                    });
2255                    let counters = shared_alias
2256                        .and_then(|collision| collision.merged_resume.as_ref())
2257                        .or_else(|| {
2258                            resume
2259                                .as_ref()
2260                                .and_then(|resume| resume.collections.get(&stored_name))
2261                        });
2262                    let manager = self.get_collection(&name);
2263                    let mut count_seed =
2264                        if counters.is_none() && self.commit.is_some() && !self.is_read_only() {
2265                            let schema =
2266                                manager.as_ref().and_then(|manager| manager.column_schema());
2267                            super::count_index::CountProjectionSeedCollector::new(
2268                                schema,
2269                                count_seed_bytes_remaining,
2270                            )
2271                        } else {
2272                            None
2273                        };
2274                    if let (Some(counters), Some(manager)) = (counters, self.get_collection(&name))
2275                    {
2276                        manager.restore_identity_counters(
2277                            counters.entity_id_next,
2278                            counters.row_id_next,
2279                        );
2280                        manager.restore_sequence_id_next(counters.sequence_id_next);
2281                        let expiry_entries = counters
2282                            .metadata_expiries
2283                            .iter()
2284                            .map(|entry| (entry.deadline_ms, EntityId::new(entry.entity_id)))
2285                            .collect::<Vec<_>>();
2286                        manager.restore_metadata_expiry_projection(
2287                            &expiry_entries,
2288                            resume
2289                                .as_ref()
2290                                .is_some_and(|resume| resume.metadata_expiry_prepared),
2291                        );
2292                    }
2293                    // Load B-tree with root page if it exists
2294                    if root_page > 0 || counters.is_some() {
2295                        let btree = restore_published_tree(pager, root_page, proofs.as_ref())?;
2296
2297                        // Load all entities from B-tree into the collection
2298                        let mut cursor = btree.cursor_first().map_err(|err| {
2299                            page_integrity(root_page, Some(name.clone()), err.to_string())
2300                        })?;
2301                        let hydrate_read_only =
2302                            self.is_read_only() && self.config.resident_memory_bytes_max.is_none();
2303                        let mut persisted_entities = if hydrate_read_only {
2304                            0
2305                        } else {
2306                            counters.map_or(0, |state| state.entities)
2307                        };
2308                        while counters.is_none() || hydrate_read_only {
2309                            let next = cursor
2310                                .next_with_budget(
2311                                    self.config
2312                                        .resident_memory_bytes_max
2313                                        .and_then(|limit| usize::try_from(limit).ok())
2314                                        .unwrap_or(usize::MAX),
2315                                )
2316                                .map_err(|err| {
2317                                    page_integrity(root_page, Some(name.clone()), err.to_string())
2318                                })?;
2319                            let Some((key, value)) = next else {
2320                                break;
2321                            };
2322                            persisted_entities += 1;
2323                            // Deserialize entity from value bytes
2324                            let key_bytes: [u8; 8] = key.as_slice().try_into().map_err(|_| {
2325                                page_integrity(
2326                                    root_page,
2327                                    Some(name.clone()),
2328                                    "physical entity key must contain eight bytes",
2329                                )
2330                            })?;
2331                            let record = reddb_engine::storage::collection_read::EncodedRecord {
2332                                id: EntityId::new(u64::from_be_bytes(key_bytes)),
2333                                bytes: value,
2334                            };
2335                            let decoded = if let Some(limit) = self.config.resident_memory_bytes_max
2336                            {
2337                                record.decode_with_budget(
2338                                    self.format_version(),
2339                                    usize::try_from(limit).unwrap_or(usize::MAX),
2340                                )
2341                            } else {
2342                                record.decode(self.format_version())
2343                            };
2344                            let (entity, metadata) = decoded.map_err(|err| {
2345                                page_integrity(root_page, Some(name.clone()), err.to_string())
2346                            })?;
2347                            scanned_xid_high_water_mark =
2348                                scanned_xid_high_water_mark.max(entity.max_transaction_id());
2349                            let discard_count_seed = if let Some(seed) = count_seed.as_mut() {
2350                                match seed.push(&entity) {
2351                                    Ok(complete) => !complete,
2352                                    Err(error) => {
2353                                        return Err(page_integrity(
2354                                            root_page,
2355                                            Some(name.clone()),
2356                                            error.to_string(),
2357                                        ));
2358                                    }
2359                                }
2360                            } else {
2361                                false
2362                            };
2363                            if discard_count_seed {
2364                                count_seed = None;
2365                            }
2366                            if let Some(m) = &manager {
2367                                let id = entity.id;
2368                                if let EntityKind::TableRow { row_id, .. } = &entity.kind {
2369                                    m.register_row_id(*row_id);
2370                                }
2371                                if self.config.resident_memory_bytes_max.is_some() {
2372                                    m.restore_metadata_expiry(&entity, metadata.as_ref());
2373                                    self.register_entity_id(id);
2374                                    m.restore_sequence_id_next(
2375                                        entity.sequence_id.saturating_add(1),
2376                                    );
2377                                    m.restore_identity_counters(
2378                                        id.raw().saturating_add(1),
2379                                        m.identity_counters().1,
2380                                    );
2381                                    continue;
2382                                }
2383                                self.resident.context_index.index_entity(&name, &entity);
2384                                m.restore_snapshot_entity(entity.clone())?;
2385                                if let Some(metadata) = metadata {
2386                                    let _ = m.set_metadata(id, metadata);
2387                                }
2388                                self.register_entity_id(id);
2389                                if self.config.auto_index_refs {
2390                                    self.index_cross_refs(&entity, &name)?;
2391                                }
2392                            }
2393                        }
2394
2395                        if let Some(seed) = count_seed.take() {
2396                            count_seed_bytes_remaining =
2397                                count_seed_bytes_remaining.saturating_sub(seed.bytes());
2398                            self.remember_count_projection_seed(&name, root_page, seed);
2399                        }
2400
2401                        // Store the B-tree for future lookups
2402                        let btree = Arc::new(btree);
2403                        if shared_alias.is_some_and(|collision| collision.resume_counter_drift) {
2404                            persisted_entities = u64::try_from(btree.count().map_err(|err| {
2405                                page_integrity(root_page, Some(name.clone()), err.to_string())
2406                            })?)
2407                            .map_err(|_| {
2408                                page_integrity(
2409                                    root_page,
2410                                    Some(name.clone()),
2411                                    "shared collection entity count exceeds u64",
2412                                )
2413                            })?;
2414                        }
2415                        let key_tree = counters
2416                            .and_then(|state| state.key_index_root)
2417                            .map(|root| {
2418                                restore_published_tree(pager, root, proofs.as_ref()).map(Arc::new)
2419                            })
2420                            .transpose()?;
2421                        if let Some(counters) = counters {
2422                            self.key_indices.write().insert(
2423                                name.clone(),
2424                                PublishedKeyIndex {
2425                                    indexed: key_tree.is_some(),
2426                                    secondary: counters.secondary_indices.iter().map(|(name, index)| {
2427                                        Ok((name.clone(), super::secondary_index::PublishedSecondaryIndex {
2428                                            identity: super::secondary_index::SecondaryIndexIdentity {
2429                                                codec_version: index.codec_version,
2430                                                definition: index.definition,
2431                                            },
2432                                            tree: Arc::new(restore_published_tree(pager, index.root_page, proofs.as_ref())?),
2433                                        }))
2434                                    }).collect::<Result<_, StoreError>>()?,
2435                                    source: Arc::downgrade(&btree),
2436                                    tree: match &key_tree { Some(tree) => Arc::clone(tree), None => Arc::new(restore_published_tree(pager, 0, proofs.as_ref())?) },
2437                                },
2438                            );
2439                        }
2440                        if let (Some(manager), Some(limit)) =
2441                            (&manager, self.config.resident_memory_bytes_max)
2442                        {
2443                            manager.install_backing(
2444                                Arc::new(
2445                                    match key_tree {
2446                                        Some(keys) => reddb_engine::storage::collection_read::PagedCollection::with_key_index(Arc::clone(&btree), keys),
2447                                        None => reddb_engine::storage::collection_read::PagedCollection::new(Arc::clone(&btree)),
2448                                    },
2449                                ),
2450                                btree.root_page_id(),
2451                                format_version,
2452                                usize::try_from(limit).unwrap_or(usize::MAX),
2453                                persisted_entities,
2454                            );
2455                        }
2456                        self.btree_indices.write().insert(name.clone(), btree);
2457                        if format_version == STORE_VERSION_V11 {
2458                            if let Some(manager) = &manager {
2459                                self.checkpoint_versions.write().insert(
2460                                    name,
2461                                    CheckpointVersion {
2462                                        manager: Arc::downgrade(manager),
2463                                        segments: manager.checkpoint_revisions(),
2464                                        visibility_revision: 0,
2465                                        untracked_visibility_revision: 0,
2466                                        excluded_xids: Arc::default(),
2467                                        pending_vault: Arc::default(),
2468                                        format_version,
2469                                        deferred_rows: Some(Arc::default()),
2470                                        has_unprojected_rows: false,
2471                                    },
2472                                );
2473                            }
2474                        }
2475                    }
2476                }
2477
2478                let expected_config_roots = recovery_config_identities.map(u8::from);
2479                if retain_canonical_system_config && recovery_config_roots != expected_config_roots
2480                {
2481                    return Err(page_integrity(
2482                        1,
2483                        None,
2484                        "canonical config recovery requires exactly one root for each resume identity",
2485                    ));
2486                }
2487                if retain_canonical_system_config
2488                    && (format_version < STORE_VERSION_V2 || pos + 4 > content.len())
2489                {
2490                    return Err(page_integrity(
2491                        1,
2492                        None,
2493                        "canonical config recovery requires a complete cross-reference catalog",
2494                    ));
2495                }
2496                if format_version >= STORE_VERSION_V2 && pos + 4 <= content.len() {
2497                    let cross_ref_count = u32::from_le_bytes([
2498                        content[pos],
2499                        content[pos + 1],
2500                        content[pos + 2],
2501                        content[pos + 3],
2502                    ]) as usize;
2503                    pos += 4;
2504
2505                    for _ in 0..cross_ref_count {
2506                        let cross_ref =
2507                            match reddb_file::decode_native_paged_cross_ref(content, &mut pos) {
2508                                Ok(cross_ref) => cross_ref,
2509                                Err(error) if retain_canonical_system_config => {
2510                                    return Err(page_integrity(1, None, error.to_string()));
2511                                }
2512                                Err(_) => break,
2513                            };
2514                        if retain_canonical_system_config
2515                            && cross_ref.target_collection == "red_config"
2516                        {
2517                            return Err(page_integrity(
2518                                1,
2519                                None,
2520                                "legacy red_config has cross-references and cannot be retired",
2521                            ));
2522                        }
2523                        let source_id = EntityId::new(cross_ref.source_id);
2524                        let target_id = EntityId::new(cross_ref.target_id);
2525                        let ref_type = RefType::from_byte(cross_ref.ref_type);
2526                        let target_collection =
2527                            reddb_engine::storage::system_collection_names::canonical_owned(
2528                                cross_ref.target_collection,
2529                            );
2530
2531                        self.resident
2532                            .cross_refs
2533                            .write()
2534                            .entry(source_id)
2535                            .or_default()
2536                            .push((target_id, ref_type, target_collection.clone()));
2537
2538                        if let Some((collection, mut entity)) = self.get_any(source_id) {
2539                            let exists = entity.cross_refs().iter().any(|xref| {
2540                                xref.target == target_id
2541                                    && xref.ref_type == ref_type
2542                                    && xref.target_collection == target_collection
2543                            });
2544                            if !exists {
2545                                entity.cross_refs_mut().push(CrossRef::new(
2546                                    source_id,
2547                                    target_id,
2548                                    target_collection.clone(),
2549                                    ref_type,
2550                                ));
2551                                if let Some(manager) = self.get_collection(&collection) {
2552                                    let _ = manager.update(entity);
2553                                }
2554                            }
2555                        }
2556                    }
2557                }
2558                let retired = reddb_file::decode_native_paged_retired_trees(content, &mut pos)
2559                    .map_err(|e| StoreError::Serialization(e.to_string()))?;
2560                let already_retired = retired
2561                    .iter()
2562                    .copied()
2563                    .collect::<std::collections::HashSet<_>>();
2564                let mut live_roots: std::collections::HashSet<_> = self
2565                    .btree_indices
2566                    .read()
2567                    .values()
2568                    .map(|tree| tree.root_page_id())
2569                    .collect();
2570                live_roots.extend(
2571                    self.key_indices
2572                        .read()
2573                        .values()
2574                        .flat_map(PublishedKeyIndex::trees)
2575                        .map(|tree| tree.root_page_id()),
2576                );
2577                for root in retired {
2578                    if live_roots.contains(&root)
2579                        || root
2580                            >= pager
2581                                .page_count()
2582                                .map_err(|e| StoreError::Io(std::io::Error::other(e.to_string())))?
2583                    {
2584                        return Err(page_integrity(
2585                            root,
2586                            None,
2587                            "retired root aliases a live or missing page",
2588                        ));
2589                    }
2590                    self.retired_btrees.lock().push(RetiredBTree {
2591                        tree: Arc::new(restore_published_tree(pager, root, proofs.as_ref())?),
2592                        published: true,
2593                    });
2594                }
2595                for root in empty_duplicate_roots
2596                    .iter()
2597                    .copied()
2598                    .filter(|root| !already_retired.contains(root))
2599                {
2600                    self.retire_btree(Arc::new(restore_published_tree(
2601                        pager,
2602                        root,
2603                        proofs.as_ref(),
2604                    )?));
2605                }
2606                if proofs.as_ref().is_some_and(|proofs| {
2607                    proofs.iter().any(|(root, proof)| {
2608                        let owns = |id| {
2609                            live_roots.contains(id)
2610                                || already_retired.contains(id)
2611                                || empty_duplicate_roots.contains(id)
2612                        };
2613                        !owns(root) || owns(&proof.reference.page_id)
2614                    })
2615                }) {
2616                    return Err(page_integrity(
2617                        1,
2618                        None,
2619                        "unowned or aliased tree generation proof",
2620                    ));
2621                }
2622                let deferred = reddb_file::decode_native_paged_deferred_pages(content, &mut pos)
2623                    .map_err(|e| StoreError::Serialization(e.to_string()))?;
2624                if deferred
2625                    .iter()
2626                    .any(|id| live_roots.contains(id) || empty_duplicate_roots.contains(id))
2627                {
2628                    return Err(page_integrity(1, None, "deferred page aliases a live root"));
2629                }
2630                pager
2631                    .restore_deferred_reclamation(&deferred)
2632                    .map_err(|e| StoreError::Io(std::io::Error::other(e.to_string())))?;
2633                let ownership_epoch =
2634                    reddb_file::native_store::decode_native_paged_ownership_epoch(
2635                        content, &mut pos,
2636                    )
2637                    .map_err(|e| StoreError::Serialization(e.to_string()))?;
2638                self.ownership_catalog_epoch
2639                    .store(ownership_epoch.unwrap_or(0), Ordering::Release);
2640                if migrated_catalog {
2641                    self.mark_paged_registry_dirty();
2642                }
2643                if self.format_version() < STORE_VERSION_V11 {
2644                    self.set_format_version(STORE_VERSION_V11);
2645                }
2646                if self.config.resident_memory_bytes_max.is_some()
2647                    && resume.is_none()
2648                    && !migrated_catalog
2649                    && !retain_canonical_system_config
2650                {
2651                    self.remember_startup_xid_high_water_mark(scanned_xid_high_water_mark);
2652                }
2653                outcome.migrated = migrated_catalog;
2654                return Ok(outcome);
2655            }
2656        }
2657
2658        if retain_canonical_system_config {
2659            return Err(page_integrity(
2660                1,
2661                None,
2662                "canonical config recovery requires a complete catalog",
2663            ));
2664        }
2665        if self.format_version() < STORE_VERSION_V11 {
2666            self.set_format_version(STORE_VERSION_V11);
2667        }
2668
2669        Ok(CatalogLoadOutcome::default())
2670    }
2671
2672    /// Deserialize an entity from binary bytes
2673    pub(crate) fn deserialize_entity(
2674        data: &[u8],
2675        format_version: u32,
2676    ) -> Result<UnifiedEntity, StoreError> {
2677        let mut pos = 0;
2678        Self::read_entity_binary(data, &mut pos, format_version)
2679            .map_err(|e| StoreError::Serialization(e.to_string()))
2680    }
2681
2682    /// Serialize an entity to binary bytes
2683    pub(crate) fn serialize_entity(entity: &UnifiedEntity, format_version: u32) -> Vec<u8> {
2684        // Pre-allocate ~256 bytes to cover the typical 15-column
2685        // typed row without any Vec growth. Bulk insert calls this
2686        // millions of times per bench run; saving 2-3 reallocs per
2687        // entity amortises.
2688        let mut buf = Vec::with_capacity(256);
2689        Self::write_entity_binary(&mut buf, entity, format_version);
2690        buf
2691    }
2692
2693    pub(crate) fn serialize_entity_record(
2694        entity: &UnifiedEntity,
2695        metadata: Option<&Metadata>,
2696        format_version: u32,
2697    ) -> Vec<u8> {
2698        reddb_engine::storage::metadata_codec::serialize_entity_record(
2699            entity,
2700            metadata,
2701            format_version,
2702        )
2703    }
2704
2705    pub(crate) fn deserialize_entity_record(
2706        data: &[u8],
2707        format_version: u32,
2708    ) -> Result<(UnifiedEntity, Option<Metadata>), StoreError> {
2709        reddb_engine::storage::metadata_codec::deserialize_entity_record(data, format_version)
2710            .map_err(StoreError::Serialization)
2711    }
2712
2713    /// Persist all data to page-based storage
2714    ///
2715    /// Writes all entities to B-tree pages and flushes to disk.
2716    /// This provides ACID durability guarantees.
2717    pub fn persist(&self) -> Result<(), StoreError> {
2718        self.persist_inner(false)
2719    }
2720
2721    pub(crate) fn persist_after_runtime_snapshots(&self) -> Result<(), StoreError> {
2722        self.persist_inner(true)
2723    }
2724
2725    fn persist_inner(&self, runtime_snapshots_durable: bool) -> Result<(), StoreError> {
2726        if self.is_read_only() {
2727            return Ok(());
2728        }
2729        self.ensure_pager_writeback_available()?;
2730        let _vault_guard = self.vault_publication_lock.lock();
2731        let pending_vault_versions = self.pending_vault_versions.read();
2732        let pager = match &self.pager {
2733            Some(p) => p,
2734            None => {
2735                // No pager attached - use binary file fallback if path available
2736                if let Some(path) = &self.db_path {
2737                    return self
2738                        .save_to_file(path)
2739                        .map_err(|e| StoreError::Serialization(e.to_string()));
2740                }
2741                return Err(StoreError::Io(std::io::Error::other(
2742                    "No pager or path configured for persistence",
2743                )));
2744            }
2745        };
2746        // Prevent unrelated allocations or frees from being attributed to
2747        // this checkpoint's ownership epoch.
2748        let _ownership_guard = pager.lock_ownership_mutations();
2749
2750        match pager.read_manifest_page() {
2751            Ok(_) => {}
2752            Err(PagerError::PageNotFound(_)) => {
2753                let meta_page = pager
2754                    .allocate_page(crate::storage::engine::PageType::Header)
2755                    .map_err(|e| StoreError::Io(std::io::Error::other(e.to_string())))?;
2756                pager
2757                    .write_page(meta_page.page_id(), meta_page)
2758                    .map_err(|e| StoreError::Io(std::io::Error::other(e.to_string())))?;
2759            }
2760            Err(e) => {
2761                return Err(StoreError::Io(std::io::Error::other(e.to_string())));
2762            }
2763        }
2764
2765        if let Some(commit) = &self.commit {
2766            commit.force_sync().map_err(StoreError::Io)?;
2767        }
2768
2769        struct InterruptedOwnership<'a> {
2770            pager: &'a Pager,
2771            active: bool,
2772        }
2773        impl Drop for InterruptedOwnership<'_> {
2774            fn drop(&mut self) {
2775                if self.active {
2776                    self.pager.fence_interrupted_ownership();
2777                }
2778            }
2779        }
2780        let mut ownership = InterruptedOwnership {
2781            pager,
2782            active: self.commit.is_some(),
2783        };
2784        let epoch = if let Some(commit) = &self.commit {
2785            let epoch = commit
2786                .next_page_ownership_epoch(self)
2787                .map_err(StoreError::Io)?;
2788            commit
2789                .begin_page_ownership_epoch(self, epoch)
2790                .map_err(StoreError::Io)?;
2791            Some(epoch)
2792        } else {
2793            None
2794        };
2795
2796        let (visibility_revision, excluded, untracked_visibility_revision) =
2797            self.snapshot_manager.get().map_or_else(
2798                || (0, Default::default(), 0),
2799                |manager| manager.checkpoint_projection_state(),
2800            );
2801        let hidden_graph_ids = self
2802            .resident
2803            .snapshot_excluded_graph_ids(&pending_vault_versions, &excluded);
2804        let collections = self.resident.collections.read();
2805        let mut btree_indices = self.btree_indices.write();
2806
2807        // Collect collection names and their B-tree root pages
2808        let mut collection_roots: Vec<(String, u32)> = Vec::new();
2809
2810        // Build unpublished replacements. Deleting from the current trees here
2811        // would destroy the last checkpoint if a write fails before publication.
2812        let mut rebuilt_indices = HashMap::with_capacity(collections.len());
2813        let previous_versions = self.checkpoint_versions.read();
2814        let mut checkpoint_versions = HashMap::with_capacity(collections.len());
2815        let pending_vault = Arc::new(pending_vault_versions.clone());
2816        let excluded = Arc::new(excluded);
2817        for (name, manager) in collections.iter() {
2818            let mut version = CheckpointVersion {
2819                manager: Arc::downgrade(manager),
2820                segments: manager.checkpoint_revisions(),
2821                visibility_revision,
2822                untracked_visibility_revision,
2823                excluded_xids: Arc::clone(&excluded),
2824                pending_vault: Arc::clone(&pending_vault),
2825                format_version: self.format_version(),
2826                deferred_rows: Some(Arc::default()),
2827                has_unprojected_rows: false,
2828            };
2829            if let (Some(previous), Some(tree)) = (
2830                previous_versions.get(name),
2831                btree_indices
2832                    .get(name)
2833                    .filter(|tree| tree.has_page_proofs()),
2834            ) {
2835                if hidden_graph_ids.is_empty()
2836                    && previous.manager.ptr_eq(&version.manager)
2837                    && previous.segments == version.segments
2838                    && previous.format_version == version.format_version
2839                    && !previous.has_unprojected_rows
2840                    && checkpoint_visibility_matches(previous, &version)
2841                {
2842                    version.deferred_rows = previous.deferred_rows.clone();
2843                    collection_roots.push((name.clone(), tree.root_page_id()));
2844                    rebuilt_indices.insert(name.clone(), Arc::clone(tree));
2845                    checkpoint_versions.insert(name.clone(), version);
2846                    continue;
2847                }
2848            }
2849            if let (Some(previous), Some(tree)) = (
2850                previous_versions.get(name),
2851                btree_indices
2852                    .get(name)
2853                    .filter(|tree| tree.has_page_proofs()),
2854            ) {
2855                if hidden_graph_ids.is_empty()
2856                    && previous.manager.ptr_eq(&version.manager)
2857                    && previous.format_version == version.format_version
2858                {
2859                    if let Some(tree) =
2860                        self.sparse_checkpoint_tree(name, manager, tree, previous, &mut version)?
2861                    {
2862                        collection_roots.push((name.clone(), tree.root_page_id()));
2863                        rebuilt_indices.insert(name.clone(), tree);
2864                        checkpoint_versions.insert(name.clone(), version);
2865                        continue;
2866                    }
2867                }
2868            }
2869            let btree = self.checkpoint_tree(
2870                pager,
2871                manager,
2872                btree_indices.get(name),
2873                |entity, metadata| {
2874                    #[cfg(test)]
2875                    CHECKPOINT_SERIALIZED_ROWS.with(|count| count.set(count.get() + 1));
2876                    let hidden = pending_vault_versions.contains(&entity.id)
2877                        || excluded.contains(&entity.xmin);
2878                    let normalize_xmax = excluded.contains(&entity.xmax);
2879                    let normalize_branch = entity.branch_version().is_some_and(|branch| {
2880                        branch
2881                            .deletions
2882                            .iter()
2883                            .any(|(_, xid)| excluded.contains(xid))
2884                    });
2885                    let hide_refs = entity.cross_refs().iter().any(|reference| {
2886                        hidden_graph_ids.contains(&reference.source)
2887                            || hidden_graph_ids.contains(&reference.target)
2888                    });
2889                    if hidden || normalize_xmax || normalize_branch || hide_refs {
2890                        version.remember_deferred_row(entity.id);
2891                    }
2892                    if hidden {
2893                        return None;
2894                    }
2895                    let mut normalized = std::borrow::Cow::Borrowed(entity);
2896                    if normalize_xmax {
2897                        normalized.to_mut().xmax = 0;
2898                    }
2899                    if normalize_branch {
2900                        normalized
2901                            .to_mut()
2902                            .retain_branch_deletions(|xid| !excluded.contains(&xid));
2903                    }
2904                    if hide_refs {
2905                        normalized.to_mut().cross_refs_mut().retain(|reference| {
2906                            !hidden_graph_ids.contains(&reference.source)
2907                                && !hidden_graph_ids.contains(&reference.target)
2908                        });
2909                    }
2910                    Some(Self::serialize_entity_record(
2911                        &normalized,
2912                        metadata.as_ref(),
2913                        self.format_version(),
2914                    ))
2915                },
2916            )?;
2917            checkpoint_versions.insert(name.clone(), version);
2918
2919            collection_roots.push((name.clone(), btree.root_page_id()));
2920            rebuilt_indices.insert(name.clone(), btree);
2921        }
2922
2923        // Write collection metadata to page 1
2924        let mut meta_data = Vec::with_capacity(4096);
2925
2926        let format_version = self.format_version().max(STORE_VERSION_V11);
2927        self.set_format_version(format_version);
2928
2929        reddb_file::encode_native_paged_metadata_header(
2930            &mut meta_data,
2931            reddb_file::NativePagedMetadataHeader {
2932                format_version,
2933                collection_count: collection_roots.len() as u32,
2934            },
2935        );
2936
2937        // Write each collection's name and B-tree root page
2938        for (name, root_page) in &collection_roots {
2939            reddb_file::encode_native_paged_collection_root(&mut meta_data, name, *root_page);
2940        }
2941
2942        // Write cross-reference metadata
2943        let cross_refs = self.resident.cross_refs.read();
2944        let total_refs: usize = cross_refs
2945            .iter()
2946            .map(|(source, refs)| {
2947                if hidden_graph_ids.contains(source) {
2948                    0
2949                } else {
2950                    refs.iter()
2951                        .filter(|(target, _, _)| !hidden_graph_ids.contains(target))
2952                        .count()
2953                }
2954            })
2955            .sum();
2956        meta_data.extend_from_slice(&(total_refs as u32).to_le_bytes());
2957        for (source_id, refs) in cross_refs.iter() {
2958            if hidden_graph_ids.contains(source_id) {
2959                continue;
2960            }
2961            for (target_id, ref_type, collection) in refs {
2962                if hidden_graph_ids.contains(target_id) {
2963                    continue;
2964                }
2965                reddb_file::encode_native_paged_cross_ref(
2966                    &mut meta_data,
2967                    source_id.raw(),
2968                    target_id.raw(),
2969                    ref_type.to_byte(),
2970                    collection,
2971                );
2972            }
2973        }
2974
2975        for (name, previous) in btree_indices.iter() {
2976            if !rebuilt_indices
2977                .get(name)
2978                .is_some_and(|tree| Arc::ptr_eq(tree, previous))
2979            {
2980                self.retire_btree(Arc::clone(previous));
2981            }
2982        }
2983        #[cfg(test)]
2984        if std::env::var_os("REDDB_EXPERIMENTAL_EXIT_DURING_CHECKPOINT_REBUILD").is_some() {
2985            pager
2986                .sync()
2987                .map_err(|error| StoreError::Io(std::io::Error::other(error.to_string())))?;
2988            eprintln!("experimental checkpoint exit before manifest");
2989            std::process::exit(183);
2990        }
2991        let rebuilt_keys = self.checkpoint_key_indices(pager, &rebuilt_indices)?;
2992        #[cfg(test)]
2993        if std::env::var_os("REDDB_TEST_EXIT_AFTER_SECONDARY_INDEX_BUILD").is_some() {
2994            pager
2995                .sync()
2996                .map_err(|error| StoreError::Io(std::io::Error::other(error.to_string())))?;
2997            std::process::exit(196);
2998        }
2999        self.publish_reclaimable_trees(pager, &mut meta_data, &rebuilt_indices, &rebuilt_keys)?;
3000
3001        #[cfg(test)]
3002        if std::env::var_os("REDDB_TEST_EXIT_AFTER_UNDO_CATALOG_PUBLICATION").is_some() {
3003            std::process::exit(195);
3004        }
3005
3006        // Only the now-durable catalog may replace the live tree handles. The
3007        // previous generation is retained in the manifest retirement ledger.
3008        *btree_indices = rebuilt_indices;
3009        *self.key_indices.write() = rebuilt_keys;
3010        drop(previous_versions);
3011        *self.checkpoint_versions.write() = checkpoint_versions;
3012
3013        if let Some(commit) = &self.commit {
3014            commit
3015                .finish_page_ownership_epoch(self, epoch.expect("WAL ownership epoch"))
3016                .map_err(StoreError::Io)?;
3017            ownership.active = false;
3018            if runtime_snapshots_durable {
3019                commit
3020                    .truncate_after_runtime_checkpoint()
3021                    .map_err(StoreError::Io)?;
3022            } else {
3023                commit.truncate().map_err(StoreError::Io)?;
3024            }
3025        }
3026        *self.checkpoint_durable_rows.write() = Default::default();
3027
3028        if let Some(limit) = self.config.resident_memory_bytes_max {
3029            let mut versions = self.checkpoint_versions.write();
3030            for (name, manager) in collections.iter() {
3031                if versions
3032                    .get(name)
3033                    .is_some_and(|version| !version.has_unprojected_rows)
3034                {
3035                    if let Some(tree) = btree_indices.get(name) {
3036                        manager.install_backing(
3037                            Arc::new(
3038                                {
3039                                    let keys = self.key_indices.read();
3040                                    let index = keys.get(name).expect("invariant: prepared checkpoint classifies its key index");
3041                                    if index.indexed {
3042                                        reddb_engine::storage::collection_read::PagedCollection::with_key_index(Arc::clone(tree), Arc::clone(&index.tree))
3043                                    } else {
3044                                        reddb_engine::storage::collection_read::PagedCollection::new(Arc::clone(tree))
3045                                    }
3046                                },
3047                            ),
3048                            tree.root_page_id(),
3049                            self.format_version(),
3050                            usize::try_from(limit).unwrap_or(usize::MAX),
3051                            manager.count() as u64,
3052                        );
3053                        if let Some(version) = versions.get_mut(name) {
3054                            version.segments = manager.checkpoint_revisions();
3055                        }
3056                    }
3057                }
3058            }
3059        }
3060
3061        Ok(())
3062    }
3063
3064    /// Check if the store is using page-based persistence
3065    pub fn is_paged(&self) -> bool {
3066        self.pager.is_some()
3067    }
3068
3069    /// Current root page for a collection's primary B-tree, if one has
3070    /// been materialized in this store.
3071    pub(crate) fn collection_root_page(&self, collection: &str) -> Option<u32> {
3072        self.btree_indices
3073            .read()
3074            .get(Self::canonical_collection(collection))
3075            .map(|btree| btree.root_page_id())
3076            .filter(|root| *root != 0)
3077    }
3078
3079    /// Keep the journal replay baseline for the lifetime of this store, including
3080    /// persistent resident layouts whose paged `db_path` is absent.
3081    pub(crate) fn operational_manifest_for_path(
3082        &self,
3083        path: &Path,
3084    ) -> reddb_file::OperationalManifest {
3085        let mut cached = self.operational_manifest.lock();
3086        if let Some((current, manifest)) = cached.as_ref() {
3087            if current == path {
3088                return manifest.clone();
3089            }
3090        }
3091        let manifest = reddb_file::OperationalManifest::for_db_path(path);
3092        *cached = Some((path.to_owned(), manifest.clone()));
3093        manifest
3094    }
3095
3096    /// Get the database file path (if using paged mode)
3097    pub fn db_path(&self) -> Option<&Path> {
3098        self.db_path.as_deref()
3099    }
3100}
3101
3102#[cfg(test)]
3103mod checkpoint_work_tests {
3104    use super::*;
3105    use reddb_file::PAGED_PAGE_SIZE as PAGE_SIZE;
3106
3107    struct SystemNameProjection;
3108
3109    impl super::super::secondary_index::SecondaryIndexProjector for SystemNameProjection {
3110        fn project(
3111            &self,
3112            entity: &UnifiedEntity,
3113            key: &mut [u8; super::super::secondary_index::SECONDARY_KEY_BYTES_MAX],
3114        ) -> Result<Option<usize>, StoreError> {
3115            key[..8].copy_from_slice(&entity.id.raw().to_be_bytes());
3116            Ok(Some(8))
3117        }
3118    }
3119
3120    #[cfg(target_os = "linux")]
3121    #[test]
3122    fn interrupted_inline_sparse_checkpoint_replays_durable_rows() {
3123        const CHILD: &str = "REDDB_TEST_INLINE_SPARSE_PATH";
3124        if let Some(path) = std::env::var_os(CHILD) {
3125            let insert = std::env::var_os("REDDB_TEST_INLINE_SPARSE_INSERT").is_some();
3126            let multi = std::env::var_os("REDDB_TEST_INLINE_SPARSE_MULTI").is_some();
3127            let store = UnifiedStore::open(path).expect("open store");
3128            let initial_count = if multi { 600 } else { 1 };
3129            for id in 1..=initial_count {
3130                store.insert_auto("rows", row(id, 1)).expect("seed");
3131            }
3132            store.persist().expect("baseline");
3133            let manager = store.get_collection("rows").expect("collection");
3134            let ids: Vec<u64> = if multi {
3135                (1..=initial_count).collect()
3136            } else {
3137                vec![if insert { 2 } else { 1 }]
3138            };
3139            let mut actions = Vec::new();
3140            for id in ids {
3141                if insert {
3142                    manager.insert(row(id, 9)).expect("insert version");
3143                } else {
3144                    manager.update(row(id, 9)).expect("update version");
3145                }
3146                let entity = manager.get(EntityId::new(id)).expect("changed entity");
3147                actions.push(StoreWalAction::upsert_entity(
3148                    "rows",
3149                    &entity,
3150                    None,
3151                    store.format_version(),
3152                ));
3153            }
3154            let references = store
3155                .commit
3156                .as_ref()
3157                .expect("coordinator")
3158                .append_checkpoint_actions(&actions)
3159                .expect("durable WAL");
3160            store.remember_checkpoint_rows(actions, references);
3161            store.persist().expect("exit after sparse write");
3162            panic!("inline sparse crash hook was not reached");
3163        }
3164        for (insert, multi) in [(false, false), (true, false), (false, true)] {
3165            let directory = tempfile::tempdir().expect("temporary store");
3166            let path = directory.path().join("data.rdb");
3167            let mut command =
3168                std::process::Command::new(std::env::current_exe().expect("test executable"));
3169            command
3170                .args(["--exact", "storage::unified::store::impl_pages::checkpoint_work_tests::interrupted_inline_sparse_checkpoint_replays_durable_rows", "--nocapture"])
3171                .env(CHILD, &path)
3172                .env("REDDB_TEST_EXIT_AFTER_INLINE_SPARSE_WRITE", "1");
3173            if insert {
3174                command.env("REDDB_TEST_INLINE_SPARSE_INSERT", "1");
3175            }
3176            if multi {
3177                command.env("REDDB_TEST_INLINE_SPARSE_MULTI", "1");
3178            }
3179            let output = command.output().expect("crash child");
3180            assert_eq!(
3181                output.status.code(),
3182                Some(190),
3183                "insert={insert} multi={multi}: {}",
3184                String::from_utf8_lossy(&output.stderr)
3185            );
3186            let recovered = UnifiedStore::open(&path).expect("recover");
3187            let ids: Vec<u64> = if multi {
3188                (1..=600).collect()
3189            } else {
3190                vec![if insert { 2 } else { 1 }]
3191            };
3192            for id in ids {
3193                assert_eq!(
3194                    recovered
3195                        .get("rows", EntityId::new(id))
3196                        .expect("recovered row")
3197                        .data
3198                        .as_row()
3199                        .expect("row")
3200                        .columns,
3201                    vec![Value::Integer(9)]
3202                );
3203            }
3204            assert_eq!(
3205                recovered.get_collection("rows").expect("rows").count(),
3206                if multi {
3207                    600
3208                } else if insert {
3209                    2
3210                } else {
3211                    1
3212                }
3213            );
3214        }
3215    }
3216
3217    #[test]
3218    fn catalog_ownership_epoch_survives_reopen_and_later_publication() {
3219        let directory = tempfile::tempdir().expect("temporary store");
3220        let path = directory.path().join("data.rdb");
3221        {
3222            let store = UnifiedStore::open(&path).expect("open store");
3223            store.create_collection("rows").expect("create collection");
3224            store.persist().expect("publish ownership epoch");
3225            assert_eq!(store.ownership_catalog_epoch.load(Ordering::Acquire), 1);
3226            store.persist().expect("publish next ownership epoch");
3227        }
3228        let reopened = UnifiedStore::open(&path).expect("reopen store");
3229        assert_eq!(reopened.ownership_catalog_epoch.load(Ordering::Acquire), 2);
3230    }
3231
3232    #[test]
3233    fn metadata_overflow_with_missing_continuation_fails_closed() {
3234        use crate::storage::engine::{Page, PageType, HEADER_SIZE};
3235
3236        let directory = tempfile::tempdir().expect("temporary store");
3237        let store = UnifiedStore::open(directory.path().join("data.rdb")).expect("paged store");
3238        let pager = store.pager.as_ref().unwrap();
3239        let valid_payload = vec![42; META_PAGE_CONTENT_CAP * 3];
3240        let (valid_manifest, _) = build_meta_page1_with_overflow(pager, &valid_payload, 1).unwrap();
3241        pager.write_page(1, valid_manifest).unwrap();
3242        assert!(!existing_overflow_chain(pager).unwrap().is_empty());
3243        assert_eq!(read_meta_payload(pager).unwrap().unwrap(), valid_payload);
3244
3245        let missing = pager.page_count().unwrap() + 100;
3246        let mut manifest = Page::new(PageType::Header, 1);
3247        reddb_file::encode_native_metadata_overflow_header(
3248            &mut manifest.as_bytes_mut()[HEADER_SIZE..],
3249            reddb_file::NativeMetadataOverflowHeader {
3250                format_version: STORE_VERSION_V11,
3251                total_payload_bytes: META_PAGE_CONTENT_CAP as u32 + 1,
3252                next_overflow_page_id: missing,
3253            },
3254        )
3255        .unwrap();
3256        pager.write_page(1, manifest).unwrap();
3257
3258        assert!(
3259            existing_overflow_chain(pager).is_err(),
3260            "a missing continuation must not be treated as an empty chain"
3261        );
3262
3263        let wrong_type = pager.allocate_page(PageType::BTreeLeaf).unwrap();
3264        let mut manifest = Page::new(PageType::Header, 1);
3265        reddb_file::encode_native_metadata_overflow_header(
3266            &mut manifest.as_bytes_mut()[HEADER_SIZE..],
3267            reddb_file::NativeMetadataOverflowHeader {
3268                format_version: STORE_VERSION_V11,
3269                total_payload_bytes: META_PAGE_CONTENT_CAP as u32 + 1,
3270                next_overflow_page_id: wrong_type.page_id(),
3271            },
3272        )
3273        .unwrap();
3274        pager.write_page(1, manifest).unwrap();
3275        assert!(
3276            existing_overflow_chain(pager).is_err(),
3277            "a live page must not be mistaken for metadata overflow"
3278        );
3279        assert!(
3280            read_meta_payload(pager).is_err(),
3281            "a live page must not be read as metadata overflow"
3282        );
3283
3284        let mut cycle = pager.allocate_page(PageType::Overflow).unwrap();
3285        let cycle_id = cycle.page_id();
3286        reddb_file::encode_native_metadata_overflow_continuation_header(
3287            &mut cycle.as_bytes_mut()[HEADER_SIZE..],
3288            reddb_file::NativeMetadataOverflowContinuationHeader {
3289                next_overflow_page_id: cycle_id,
3290                chunk_bytes: 1,
3291            },
3292        )
3293        .unwrap();
3294        pager.write_page(cycle_id, cycle).unwrap();
3295        let mut manifest = Page::new(PageType::Header, 1);
3296        reddb_file::encode_native_metadata_overflow_header(
3297            &mut manifest.as_bytes_mut()[HEADER_SIZE..],
3298            reddb_file::NativeMetadataOverflowHeader {
3299                format_version: STORE_VERSION_V11,
3300                total_payload_bytes: META_V3_FIRST_PAYLOAD_CAP as u32 + 3,
3301                next_overflow_page_id: cycle_id,
3302            },
3303        )
3304        .unwrap();
3305        pager.write_page(1, manifest).unwrap();
3306        assert!(
3307            read_meta_payload(pager).is_err(),
3308            "cyclic overflow must not be accepted"
3309        );
3310
3311        let mut manifest = Page::new(PageType::Header, 1);
3312        reddb_file::encode_native_metadata_overflow_header(
3313            &mut manifest.as_bytes_mut()[HEADER_SIZE..],
3314            reddb_file::NativeMetadataOverflowHeader {
3315                format_version: STORE_VERSION_V11,
3316                total_payload_bytes: META_PAGE_CONTENT_CAP as u32 + 1,
3317                next_overflow_page_id: 0,
3318            },
3319        )
3320        .unwrap();
3321        pager.write_page(1, manifest).unwrap();
3322        assert!(
3323            read_meta_payload(pager).is_err(),
3324            "a truncated metadata payload must not be accepted"
3325        );
3326    }
3327
3328    #[test]
3329    fn retired_tree_pages_remain_pinned_by_a_cursor() {
3330        let directory = tempfile::tempdir().expect("temporary store");
3331        let store = UnifiedStore::open(&directory.path().join("data.rdb")).expect("paged store");
3332        for id in 1..=100 {
3333            store.insert_auto("rows", row(id, 1)).expect("seed");
3334        }
3335        store.persist().expect("initial checkpoint");
3336        let tree = store
3337            .btree_indices
3338            .read()
3339            .get("rows")
3340            .cloned()
3341            .expect("tree");
3342        let mut cursor = tree.cursor_first().expect("cursor");
3343        drop(tree);
3344        let manager = store.get_collection("rows").expect("rows");
3345        for value in 2..=6 {
3346            manager.update(row(1, value)).expect("change");
3347            store.persist().expect("checkpoint with old reader");
3348        }
3349        let mut count = 0;
3350        while let Some((_, bytes)) = cursor.next().expect("pinned tree must stay readable") {
3351            let (entity, _) =
3352                UnifiedStore::deserialize_entity_record(&bytes, store.format_version())
3353                    .expect("decode");
3354            assert_eq!(
3355                entity.data.as_row().expect("row").columns,
3356                [Value::Integer(1)]
3357            );
3358            count += 1;
3359        }
3360        assert_eq!(count, 100);
3361        drop(cursor);
3362        store.persist().expect("reclaim after reader release");
3363        assert!(store.retired_btrees.lock().is_empty());
3364    }
3365
3366    #[test]
3367    fn checkpoint_payload_buffer_is_bounded_for_a_large_collection() {
3368        let directory = tempfile::tempdir().expect("temporary store");
3369        let store = UnifiedStore::open(&directory.path().join("data.rdb")).expect("paged store");
3370        store.create_collection("rows").expect("collection");
3371        let manager = store.get_collection("rows").expect("rows");
3372        for id in (1..=1200).rev() {
3373            manager
3374                .insert(UnifiedEntity::table_row(
3375                    EntityId::new(id),
3376                    "rows",
3377                    id,
3378                    vec![Value::text("x".repeat(4096))],
3379                ))
3380                .expect("seed");
3381        }
3382        CHECKPOINT_BUFFERED_BYTES.with(|peak| peak.set(0));
3383        store.persist().expect("bounded checkpoint");
3384        assert!(CHECKPOINT_BUFFERED_BYTES.with(|peak| peak.get()) <= CHECKPOINT_BATCH_BYTES);
3385        assert!(CHECKPOINT_BUFFERED_BYTES.with(|peak| peak.get()) > 0);
3386        assert_eq!(store.get_collection("rows").expect("rows").count(), 1200);
3387    }
3388
3389    #[test]
3390    fn unchanged_checkpoint_does_not_serialize_collection_rows() {
3391        let directory = tempfile::tempdir().expect("temporary store");
3392        let store = UnifiedStore::open(&directory.path().join("data.rdb")).expect("paged store");
3393        store.create_collection("rows").expect("collection");
3394        for id in 1..=40 {
3395            store
3396                .insert(
3397                    "rows",
3398                    UnifiedEntity::table_row(
3399                        EntityId::new(id),
3400                        "rows",
3401                        id,
3402                        vec![Value::Integer(id as i64)],
3403                    ),
3404                )
3405                .expect("seed row");
3406        }
3407        store.persist().expect("initial checkpoint");
3408        CHECKPOINT_SERIALIZED_ROWS.with(|count| count.set(0));
3409        store.persist().expect("unchanged checkpoint");
3410        assert_eq!(
3411            CHECKPOINT_SERIALIZED_ROWS.with(|count| count.get()),
3412            0,
3413            "unchanged collections must not hold the writer fence while serializing every row"
3414        );
3415        drop(store);
3416        let reopened = UnifiedStore::open(&directory.path().join("data.rdb")).expect("reopen");
3417        CHECKPOINT_SERIALIZED_ROWS.with(|count| count.set(0));
3418        reopened.persist().expect("unchanged recovered generation");
3419        assert_eq!(CHECKPOINT_SERIALIZED_ROWS.with(|count| count.get()), 0);
3420    }
3421
3422    #[test]
3423    fn sparse_wal_replay_does_not_reencode_complete_collections() {
3424        let directory = tempfile::tempdir().expect("temporary store");
3425        let path = directory.path().join("data.rdb");
3426        let store = UnifiedStore::open(&path).expect("paged store");
3427        for (name, first) in [("rows", 1), ("cold", 129)] {
3428            store.create_collection(name).expect("collection");
3429            for id in first..first + 128 {
3430                store
3431                    .insert(
3432                        name,
3433                        UnifiedEntity::table_row(
3434                            EntityId::new(id),
3435                            name,
3436                            id,
3437                            vec![Value::text(format!("{id}:{}", "x".repeat(8192)))],
3438                        ),
3439                    )
3440                    .expect("seed");
3441            }
3442        }
3443        store.persist().expect("durable baseline");
3444        let action = super::super::commit::StoreWalAction::upsert_entity(
3445            "rows",
3446            &row(1, 42),
3447            None,
3448            STORE_VERSION_V11,
3449        );
3450        // The acknowledged WAL image has not reached its home B-tree yet.
3451        store
3452            .commit
3453            .as_ref()
3454            .unwrap()
3455            .append_actions(&[action])
3456            .unwrap();
3457        drop(store);
3458
3459        REPLAY_SERIALIZED_ROWS.with(|count| count.set(0));
3460        let recovered = UnifiedStore::open(&path).expect("replay committed change");
3461        assert_eq!(
3462            recovered
3463                .get("rows", EntityId::new(1))
3464                .unwrap()
3465                .data
3466                .as_row()
3467                .unwrap()
3468                .columns,
3469            vec![Value::Integer(42)]
3470        );
3471        for name in ["rows", "cold"] {
3472            assert_eq!(recovered.get_collection(name).unwrap().count(), 128);
3473        }
3474        assert_eq!(
3475            REPLAY_SERIALIZED_ROWS.with(|count| count.get()),
3476            0,
3477            "one WAL-backed row must not reencode its whole collection or unrelated cold rows"
3478        );
3479        for (name, first) in [("rows", 2), ("cold", 129)] {
3480            let end = if name == "rows" { 129 } else { 257 };
3481            for id in first..end {
3482                assert_eq!(
3483                    recovered
3484                        .get(name, EntityId::new(id))
3485                        .unwrap()
3486                        .data
3487                        .as_row()
3488                        .unwrap()
3489                        .columns,
3490                    vec![Value::text(format!("{id}:{}", "x".repeat(8192)))],
3491                );
3492            }
3493        }
3494
3495        drop(recovered);
3496        let reopened = UnifiedStore::open(&path).expect("durable sparse replay");
3497        assert_eq!(
3498            reopened
3499                .get("rows", EntityId::new(1))
3500                .unwrap()
3501                .data
3502                .as_row()
3503                .unwrap()
3504                .columns,
3505            vec![Value::Integer(42)]
3506        );
3507    }
3508
3509    fn replay_test_payload(bytes: usize) -> String {
3510        let mut state = 0x1234_5678_u32;
3511        (0..bytes)
3512            .map(|_| {
3513                state ^= state << 13;
3514                state ^= state >> 17;
3515                state ^= state << 5;
3516                char::from(b'!' + (state % 90) as u8)
3517            })
3518            .collect()
3519    }
3520
3521    #[test]
3522    fn wal_replay_with_exhausted_row_proof_keeps_full_validation() {
3523        let directory = tempfile::tempdir().unwrap();
3524        let path = directory.path().join("data.rdb");
3525        let config = || UnifiedStoreConfig::default().with_page_cache_slots(4);
3526        let store = UnifiedStore::open_with_config(&path, config()).unwrap();
3527        for id in 1..=16 {
3528            store.insert_auto("rows", row(id, 1)).unwrap();
3529        }
3530        store.persist().unwrap();
3531        // Larger than the retained-image allowance, so replay must fall back.
3532        let payload = replay_test_payload(64 * 1024);
3533        let changed =
3534            UnifiedEntity::table_row(EntityId::new(1), "rows", 1, vec![Value::text(payload)]);
3535        let action = StoreWalAction::upsert_entity("rows", &changed, None, store.format_version());
3536        store
3537            .commit
3538            .as_ref()
3539            .unwrap()
3540            .append_actions(&[action])
3541            .unwrap();
3542        drop(store);
3543        REPLAY_SERIALIZED_ROWS.with(|count| count.set(0));
3544        let recovered = UnifiedStore::open_with_config(&path, config()).unwrap();
3545        assert!(REPLAY_SERIALIZED_ROWS.with(|count| count.get()) >= 16);
3546        assert_eq!(
3547            recovered
3548                .get("rows", EntityId::new(1))
3549                .unwrap()
3550                .data
3551                .as_row()
3552                .unwrap()
3553                .columns,
3554            changed.data.as_row().unwrap().columns
3555        );
3556        drop(recovered);
3557        let reopened = UnifiedStore::open_with_config(&path, config()).unwrap();
3558        assert_eq!(reopened.get_collection("rows").unwrap().count(), 16);
3559        assert_eq!(
3560            reopened
3561                .get("rows", EntityId::new(1))
3562                .unwrap()
3563                .data
3564                .as_row()
3565                .unwrap()
3566                .columns,
3567            changed.data.as_row().unwrap().columns
3568        );
3569    }
3570
3571    #[cfg(target_os = "linux")]
3572    #[test]
3573    fn interrupted_sparse_replay_preserves_rows_and_bounds_growth() {
3574        const CHILD: &str = "REDDB_TEST_SPARSE_REPLAY_PATH";
3575        if let Some(path) = std::env::var_os(CHILD) {
3576            let _store = UnifiedStore::open(path).unwrap();
3577            panic!("replay crash hook was not reached");
3578        }
3579        for overflow in [false, true] {
3580            for (hook, code) in [
3581                ("REDDB_TEST_EXIT_AFTER_SPARSE_BATCH", 193),
3582                ("REDDB_EXPERIMENTAL_EXIT_AFTER_REPLAY_PUBLICATION", 182),
3583            ] {
3584                let directory = tempfile::tempdir().unwrap();
3585                let path = directory.path().join("data.rdb");
3586                let store = UnifiedStore::open(&path).unwrap();
3587                for id in 1..=128 {
3588                    store.insert_auto("rows", row(id, 1)).unwrap();
3589                }
3590                store.persist().unwrap();
3591                let changed = if overflow {
3592                    UnifiedEntity::table_row(
3593                        EntityId::new(1),
3594                        "rows",
3595                        1,
3596                        vec![Value::text(replay_test_payload(16 * 1024))],
3597                    )
3598                } else {
3599                    row(1, 42)
3600                };
3601                let action =
3602                    StoreWalAction::upsert_entity("rows", &changed, None, store.format_version());
3603                store
3604                    .commit
3605                    .as_ref()
3606                    .unwrap()
3607                    .append_actions(&[action])
3608                    .unwrap();
3609                drop(store);
3610                let mut first_size = 0;
3611                for cycle in 0..12 {
3612                    let output = std::process::Command::new(std::env::current_exe().unwrap())
3613                        .args(["--exact", "storage::unified::store::impl_pages::checkpoint_work_tests::interrupted_sparse_replay_preserves_rows_and_bounds_growth", "--nocapture"])
3614                        .env(CHILD, &path).env(hook, "1").output().unwrap();
3615                    assert_eq!(
3616                        output.status.code(),
3617                        Some(code),
3618                        "overflow={overflow} hook={hook} cycle={cycle}: {}",
3619                        String::from_utf8_lossy(&output.stderr)
3620                    );
3621                    let size = std::fs::metadata(&path).unwrap().len();
3622                    if cycle == 0 {
3623                        first_size = size;
3624                    }
3625                    assert!(
3626                        size <= first_size + 2 * crate::storage::engine::PAGE_SIZE as u64,
3627                        "repeated recovery must reuse reservations: {first_size} -> {size}"
3628                    );
3629                }
3630                for _ in 0..2 {
3631                    let recovered = UnifiedStore::open(&path).unwrap();
3632                    assert_eq!(recovered.get_collection("rows").unwrap().count(), 128);
3633                    for id in 1..=128 {
3634                        let expected = if id == 1 { changed.clone() } else { row(id, 1) };
3635                        assert_eq!(
3636                            recovered
3637                                .get("rows", EntityId::new(id))
3638                                .unwrap()
3639                                .data
3640                                .as_row()
3641                                .unwrap()
3642                                .columns,
3643                            expected.data.as_row().unwrap().columns
3644                        );
3645                    }
3646                }
3647            }
3648        }
3649    }
3650
3651    #[test]
3652    fn paged_reopen_preserves_stored_sequence_identity() {
3653        let directory = tempfile::tempdir().expect("temporary store");
3654        let path = directory.path().join("data.rdb");
3655        let store = UnifiedStore::open(&path).expect("paged store");
3656        store.create_collection("rows").expect("collection");
3657        for id in [30, 10, 20] {
3658            store.insert("rows", row(id, id as i64)).expect("insert");
3659        }
3660        store.persist().expect("checkpoint");
3661        let sequences = [10, 20, 30].map(|id| {
3662            store
3663                .get("rows", EntityId::new(id))
3664                .expect("row")
3665                .sequence_id
3666        });
3667        drop(store);
3668        let reopened = UnifiedStore::open(&path).expect("reopen");
3669        let recovered = [10, 20, 30].map(|id| {
3670            reopened
3671                .get("rows", EntityId::new(id))
3672                .expect("row")
3673                .sequence_id
3674        });
3675        assert_eq!(
3676            recovered, sequences,
3677            "opening a checkpoint must not rewrite stored row identities"
3678        );
3679    }
3680
3681    #[test]
3682    fn auto_insert_persists_the_assigned_sequence_identity() {
3683        let directory = tempfile::tempdir().expect("temporary store");
3684        let store = UnifiedStore::open(&directory.path().join("data.rdb")).expect("paged store");
3685        for id in 1u64..=3 {
3686            store
3687                .insert_auto("rows", row(id, id as i64))
3688                .expect("insert");
3689        }
3690        let manager = store.get_collection("rows").expect("rows");
3691        let tree = store
3692            .btree_indices
3693            .read()
3694            .get("rows")
3695            .cloned()
3696            .expect("tree");
3697        for id in 1u64..=3 {
3698            let bytes = tree
3699                .get(&id.to_be_bytes())
3700                .expect("lookup")
3701                .expect("stored row");
3702            let (persisted, _) =
3703                UnifiedStore::deserialize_entity_record(&bytes, store.format_version())
3704                    .expect("decode");
3705            assert_eq!(
3706                persisted.sequence_id,
3707                manager
3708                    .get(EntityId::new(id))
3709                    .expect("live row")
3710                    .sequence_id
3711            );
3712        }
3713    }
3714
3715    #[test]
3716    fn completed_inline_insert_reuses_history_but_not_untracked_edits() {
3717        let directory = tempfile::tempdir().expect("temporary store");
3718        let path = directory.path().join("data.rdb");
3719        let store = UnifiedStore::open(&path).expect("paged store");
3720        store.insert_auto("rows", row(1, 1)).expect("seed");
3721        store.persist().expect("checkpoint");
3722        store.insert_auto("rows", row(2, 2)).expect("append");
3723        CHECKPOINT_SERIALIZED_ROWS.with(|count| count.set(0));
3724        store.persist().expect("inline append checkpoint");
3725        assert_eq!(CHECKPOINT_SERIALIZED_ROWS.with(|count| count.get()), 0);
3726        store
3727            .get_collection("rows")
3728            .expect("rows")
3729            .update(row(1, 99))
3730            .expect("untracked edit");
3731        store.insert_auto("rows", row(3, 3)).expect("next append");
3732        CHECKPOINT_SERIALIZED_ROWS.with(|count| count.set(0));
3733        store.persist().expect("untracked edit checkpoint");
3734        assert!(CHECKPOINT_SERIALIZED_ROWS.with(|count| count.get()) > 0);
3735        drop(store);
3736        let reopened = UnifiedStore::open(&path).expect("reopen");
3737        assert_eq!(
3738            reopened
3739                .get("rows", EntityId::new(1))
3740                .expect("row")
3741                .data
3742                .as_row()
3743                .expect("table")
3744                .columns,
3745            vec![Value::Integer(99)]
3746        );
3747    }
3748
3749    #[test]
3750    fn replayed_trees_are_reusable_after_durable_reconciliation() {
3751        let directory = tempfile::tempdir().expect("temporary store");
3752        let path = directory.path().join("data.rdb");
3753        let store = UnifiedStore::open(&path).expect("paged store");
3754        store.insert_auto("rows", row(1, 1)).expect("seed");
3755        store.persist().expect("checkpoint");
3756        store
3757            .insert_auto("rows", row(2, 2))
3758            .expect("WAL-backed append");
3759        drop(store);
3760        let reopened = UnifiedStore::open(&path).expect("replay");
3761        assert!(reopened.get("rows", EntityId::new(2)).is_some());
3762        CHECKPOINT_SERIALIZED_ROWS.with(|count| count.set(0));
3763        reopened.persist().expect("checkpoint reconciled tree");
3764        assert_eq!(CHECKPOINT_SERIALIZED_ROWS.with(|count| count.get()), 0);
3765    }
3766
3767    #[test]
3768    fn legacy_system_catalog_and_wal_migrate_without_cold_boot_rebuild() {
3769        use crate::storage::wal::{WalRecord, WalWriter};
3770
3771        let directory = tempfile::tempdir().expect("temporary store");
3772        let path = directory.path().join("data.rdb");
3773        let config = UnifiedStoreConfig {
3774            page_cache_slots: Some(32),
3775            resident_memory_bytes_max: Some(2 * 1024 * 1024),
3776            ..UnifiedStoreConfig::default()
3777        };
3778        let index_identity = super::super::secondary_index::SecondaryIndexIdentity {
3779            codec_version: 1,
3780            definition: [91; 32],
3781        };
3782        let baseline_index_root;
3783        {
3784            let store = UnifiedStore::open_with_config(&path, config.clone()).expect("paged store");
3785            store
3786                .create_collection("red_config")
3787                .expect("system collection");
3788            store
3789                .insert_auto(
3790                    "red_config",
3791                    UnifiedEntity::table_row(
3792                        EntityId::new(1),
3793                        "red_config",
3794                        1,
3795                        vec![Value::text("checkpoint")],
3796                    ),
3797                )
3798                .expect("legacy-compatible insert");
3799            store
3800                .register_secondary_index(
3801                    "red_config",
3802                    "by_id",
3803                    index_identity.clone(),
3804                    Arc::new(SystemNameProjection),
3805                )
3806                .expect("secondary index");
3807            store.persist().expect("baseline checkpoint");
3808            baseline_index_root = store
3809                .secondary_index("red.config", "by_id", &index_identity)
3810                .expect("published secondary index")
3811                .root_page_id();
3812        }
3813
3814        // Recreate a pre-migration catalog and operational manifest. The two
3815        // spellings are deliberately equal-length so the test can preserve
3816        // every root and checksum-protected page layout except the name.
3817        {
3818            let pager = Pager::open(
3819                &path,
3820                PagerConfig {
3821                    disk_residency: true,
3822                    ..PagerConfig::default()
3823                },
3824            )
3825            .expect("open pager");
3826            let mut page = pager.read_page(1).expect("catalog page");
3827            let bytes = page.as_bytes_mut();
3828            let canonical = b"red.config";
3829            let legacy = b"red_config";
3830            let mut replacements = 0;
3831            for offset in 0..=bytes.len() - canonical.len() {
3832                if &bytes[offset..offset + canonical.len()] == canonical {
3833                    bytes[offset..offset + legacy.len()].copy_from_slice(legacy);
3834                    replacements += 1;
3835                }
3836            }
3837            assert!(replacements >= 1, "catalog must contain the canonical name");
3838            pager.write_page(1, page).expect("write legacy catalog");
3839            pager.sync().expect("sync legacy catalog");
3840        }
3841        crate::storage::operational_manifest::OperationalManifest::for_db_path(&path)
3842            .rename_collections(&[("red.config", "red_config")])
3843            .expect("restore legacy operational name");
3844
3845        // Append an acknowledged transaction written by a legacy binary. Its
3846        // payload must replay into the canonical manager during migration.
3847        let wal_upsert = reddb_file::encode_store_wal_action_frame(
3848            &reddb_file::StoreWalActionFrame::UpsertEntityRecord {
3849                collection: "red_config".to_string(),
3850                record: UnifiedStore::serialize_entity_record(
3851                    &UnifiedEntity::table_row(
3852                        EntityId::new(2),
3853                        "red_config",
3854                        2,
3855                        vec![Value::text("legacy WAL")],
3856                    ),
3857                    None,
3858                    STORE_VERSION_CURRENT,
3859                ),
3860            },
3861        )
3862        .expect("legacy WAL action");
3863        let wal_delete = reddb_file::encode_store_wal_action_frame(
3864            &reddb_file::StoreWalActionFrame::DeleteEntityRecord {
3865                collection: "red_config".to_string(),
3866                entity_id: EntityId::new(1).raw(),
3867            },
3868        )
3869        .expect("legacy WAL delete");
3870        let wal_path = reddb_file::layout::unified_wal_path(&path);
3871        {
3872            let mut writer = WalWriter::open(&wal_path).expect("open WAL");
3873            writer
3874                .append(&WalRecord::TxCommitBatch {
3875                    tx_id: 77,
3876                    actions: vec![wal_upsert, wal_delete],
3877                })
3878                .expect("append legacy transaction");
3879            writer.sync().expect("sync legacy transaction");
3880        }
3881
3882        let (root_after_recovery, pages_after_recovery) = {
3883            let recovered =
3884                UnifiedStore::open_with_config(&path, config.clone()).expect("migrate and recover");
3885            assert_eq!(recovered.list_collections(), vec!["red.config".to_string()]);
3886            assert!(
3887                recovered.get("red.config", EntityId::new(1)).is_none(),
3888                "legacy WAL deletes must target the canonical collection"
3889            );
3890            assert_eq!(
3891                recovered
3892                    .get("red.config", EntityId::new(2))
3893                    .expect("WAL row")
3894                    .kind
3895                    .collection(),
3896                "red.config"
3897            );
3898            let root = recovered
3899                .collection_root_page("red.config")
3900                .expect("published canonical root");
3901            assert_eq!(
3902                recovered
3903                    .secondary_index("red.config", "by_id", &index_identity)
3904                    .expect("recovered secondary index")
3905                    .root_page_id(),
3906                baseline_index_root,
3907                "name migration must reuse the secondary-index root"
3908            );
3909            let pages = recovered
3910                .pager()
3911                .expect("pager")
3912                .page_count()
3913                .expect("page count");
3914            (root, pages)
3915        };
3916
3917        // Simulate a crash/import boundary where the catalog rename is
3918        // durable but the operational manifest still has the legacy key.
3919        crate::storage::operational_manifest::OperationalManifest::for_db_path(&path)
3920            .rename_collections(&[("red.config", "red_config")])
3921            .expect("restore stale manifest identity");
3922
3923        CHECKPOINT_SERIALIZED_ROWS.with(|count| count.set(0));
3924        let cold = UnifiedStore::open_with_config(&path, config).expect("second cold boot");
3925        assert_eq!(
3926            cold.collection_root_page("red.config"),
3927            Some(root_after_recovery),
3928            "an idempotent cold boot must reuse the published data root"
3929        );
3930        assert_eq!(
3931            cold.pager()
3932                .expect("pager")
3933                .page_count()
3934                .expect("page count"),
3935            pages_after_recovery,
3936            "cold boot must not allocate replacement index trees"
3937        );
3938        assert_eq!(CHECKPOINT_SERIALIZED_ROWS.with(|count| count.get()), 0);
3939        assert_eq!(
3940            cold.secondary_index("red.config", "by_id", &index_identity)
3941                .expect("secondary index after cold boot")
3942                .root_page_id(),
3943            baseline_index_root
3944        );
3945        assert!(
3946            crate::storage::operational_manifest::OperationalManifest::for_db_path(&path)
3947                .recover_or_bootstrap(&["red.config".to_string()])
3948                .expect("canonical manifest")
3949                .is_empty()
3950        );
3951    }
3952
3953    struct DuplicateSystemCatalogFixture {
3954        _directory: tempfile::TempDir,
3955        path: std::path::PathBuf,
3956        config: UnifiedStoreConfig,
3957        pages_before: u32,
3958        legacy_root: Option<u32>,
3959        legacy_key_root: Option<u32>,
3960        legacy_secondary_root: Option<u32>,
3961        index_identity: super::super::secondary_index::SecondaryIndexIdentity,
3962    }
3963
3964    fn duplicate_system_catalog_fixture(
3965        legacy_populated: bool,
3966        canonical_populated: bool,
3967        index_legacy: bool,
3968    ) -> DuplicateSystemCatalogFixture {
3969        duplicate_system_catalog_fixture_with_ref(
3970            legacy_populated,
3971            canonical_populated,
3972            index_legacy,
3973            false,
3974        )
3975    }
3976
3977    fn duplicate_system_catalog_fixture_with_ref(
3978        legacy_populated: bool,
3979        canonical_populated: bool,
3980        index_legacy: bool,
3981        cross_reference_legacy: bool,
3982    ) -> DuplicateSystemCatalogFixture {
3983        duplicate_system_catalog_fixture_with_key(
3984            legacy_populated,
3985            canonical_populated,
3986            index_legacy,
3987            cross_reference_legacy,
3988            None,
3989        )
3990    }
3991
3992    fn config_recovery_row(id: u64, key: &str, value: i64) -> UnifiedEntity {
3993        UnifiedEntity::new(
3994            EntityId::new(id),
3995            EntityKind::TableRow {
3996                table: Arc::from("red.config"),
3997                row_id: id,
3998            },
3999            EntityData::Row(crate::storage::RowData::with_names(
4000                vec![Value::text(key), Value::Integer(value)],
4001                vec!["key".into(), "value".into()],
4002            )),
4003        )
4004    }
4005
4006    fn legacy_config_recovery_row(id: u64, key: &str, value: i64) -> UnifiedEntity {
4007        UnifiedEntity::new(
4008            EntityId::new(id),
4009            EntityKind::TableRow {
4010                table: Arc::from("red_config"),
4011                row_id: id,
4012            },
4013            EntityData::Row(crate::storage::RowData::with_names(
4014                vec![Value::text(key), Value::Integer(value)],
4015                vec!["key".into(), "value".into()],
4016            )),
4017        )
4018    }
4019
4020    fn authorize_fixture_dwb(path: &Path, frame: &[u8]) {
4021        use reddb_file::{file_format as f, PAGED_SUPERBLOCK_SLOT_SIZE};
4022        use std::io::{Seek, SeekFrom, Write};
4023
4024        let bytes = std::fs::read(path).expect("fixture headers");
4025        let selected = reddb_file::select_paged_superblock(&bytes).expect("valid superblock");
4026        let start = selected.copy_index * PAGED_SUPERBLOCK_SLOT_SIZE;
4027        let mut image = [0; PAGED_SUPERBLOCK_SLOT_SIZE];
4028        image.copy_from_slice(&bytes[start..start + PAGED_SUPERBLOCK_SLOT_SIZE]);
4029        f::set_database_header_dwb_publication(
4030            &mut image,
4031            Some(f::DwbPublication {
4032                phase: f::DwbPhase::Ready,
4033                frame_bytes: u32::try_from(frame.len()).expect("bounded fixture frame"),
4034                digest: f::dwb_frame_digest(frame),
4035            }),
4036        )
4037        .expect("known-current DWB authority");
4038        let mut file = std::fs::OpenOptions::new()
4039            .write(true)
4040            .open(path)
4041            .expect("fixture header writer");
4042        for step in 1..=reddb_file::PAGED_SUPERBLOCK_SLOT_COUNT {
4043            let copy = (selected.copy_index + step) % reddb_file::PAGED_SUPERBLOCK_SLOT_COUNT;
4044            reddb_file::seal_paged_superblock_slot(
4045                &mut image,
4046                copy,
4047                selected.generation + step as u64,
4048            )
4049            .expect("fixture header seal");
4050            file.seek(SeekFrom::Start(reddb_file::paged_superblock_slot_offset(
4051                copy,
4052            )))
4053            .expect("fixture header offset");
4054            file.write_all(&image).expect("persist DWB authority");
4055            file.sync_all().expect("durable DWB authority");
4056        }
4057    }
4058
4059    fn duplicate_system_catalog_fixture_with_key(
4060        legacy_populated: bool,
4061        canonical_populated: bool,
4062        index_legacy: bool,
4063        cross_reference_legacy: bool,
4064        config_key: Option<&str>,
4065    ) -> DuplicateSystemCatalogFixture {
4066        let config_keys = config_key.into_iter().collect::<Vec<_>>();
4067        duplicate_system_catalog_fixture_with_keys(
4068            legacy_populated,
4069            canonical_populated,
4070            index_legacy,
4071            cross_reference_legacy,
4072            &config_keys,
4073        )
4074    }
4075
4076    fn duplicate_system_catalog_fixture_with_keys(
4077        legacy_populated: bool,
4078        canonical_populated: bool,
4079        index_legacy: bool,
4080        cross_reference_legacy: bool,
4081        config_keys: &[&str],
4082    ) -> DuplicateSystemCatalogFixture {
4083        duplicate_system_catalog_fixture_with_aliases(
4084            legacy_populated,
4085            canonical_populated,
4086            index_legacy,
4087            cross_reference_legacy,
4088            config_keys,
4089            None,
4090        )
4091    }
4092
4093    fn duplicate_system_catalog_fixture_with_aliases(
4094        legacy_populated: bool,
4095        canonical_populated: bool,
4096        index_legacy: bool,
4097        cross_reference_legacy: bool,
4098        config_keys: &[&str],
4099        alias_case: Option<&str>,
4100    ) -> DuplicateSystemCatalogFixture {
4101        system_catalog_fixture_with_aliases(
4102            legacy_populated,
4103            canonical_populated,
4104            index_legacy,
4105            cross_reference_legacy,
4106            config_keys,
4107            alias_case,
4108            true,
4109        )
4110    }
4111
4112    fn single_legacy_system_catalog_fixture_with_keys(
4113        config_keys: &[&str],
4114    ) -> DuplicateSystemCatalogFixture {
4115        system_catalog_fixture_with_aliases(true, false, false, false, config_keys, None, false)
4116    }
4117
4118    fn single_empty_legacy_system_catalog_fixture() -> DuplicateSystemCatalogFixture {
4119        system_catalog_fixture_with_aliases(false, false, false, false, &[], None, false)
4120    }
4121
4122    fn system_catalog_fixture_with_aliases(
4123        legacy_populated: bool,
4124        canonical_populated: bool,
4125        index_legacy: bool,
4126        cross_reference_legacy: bool,
4127        config_keys: &[&str],
4128        alias_case: Option<&str>,
4129        canonical_identity: bool,
4130    ) -> DuplicateSystemCatalogFixture {
4131        assert!(canonical_identity || !canonical_populated);
4132        let directory = tempfile::tempdir().expect("temporary store");
4133        let path = directory.path().join("data.rdb");
4134        let config = UnifiedStoreConfig {
4135            page_cache_slots: Some(32),
4136            resident_memory_bytes_max: Some(2 * 1024 * 1024),
4137            ..UnifiedStoreConfig::default()
4138        };
4139        let index_identity = super::super::secondary_index::SecondaryIndexIdentity {
4140            codec_version: 1,
4141            definition: [73; 32],
4142        };
4143        let (legacy_root, legacy_key_root, legacy_secondary_root);
4144        {
4145            let store = UnifiedStore::open_with_config(&path, config.clone()).expect("paged store");
4146            store
4147                .create_collection("cfg_legacy")
4148                .expect("legacy source collection");
4149            if legacy_populated {
4150                if config_keys.is_empty() {
4151                    store
4152                        .insert_auto(
4153                            "cfg_legacy",
4154                            UnifiedEntity::table_row(
4155                                EntityId::new(1),
4156                                "red_config",
4157                                1,
4158                                vec![Value::text("legacy")],
4159                            ),
4160                        )
4161                        .expect("legacy row");
4162                } else {
4163                    for (offset, key) in config_keys.iter().enumerate() {
4164                        let id = u64::try_from(offset).unwrap() + 1;
4165                        let entity = if canonical_identity {
4166                            config_recovery_row(id, key, i64::try_from(id).unwrap())
4167                        } else {
4168                            legacy_config_recovery_row(id, key, i64::try_from(id).unwrap())
4169                        };
4170                        store
4171                            .insert_auto("cfg_legacy", entity)
4172                            .expect("legacy config row");
4173                    }
4174                }
4175            }
4176            if canonical_identity {
4177                store
4178                    .create_collection("cfg.canon_")
4179                    .expect("canonical source collection");
4180            }
4181            if canonical_identity && canonical_populated {
4182                if config_keys.is_empty() {
4183                    store
4184                        .insert_auto(
4185                            "cfg.canon_",
4186                            UnifiedEntity::table_row(
4187                                EntityId::new(2),
4188                                "red.config",
4189                                2,
4190                                vec![Value::text("canonical")],
4191                            ),
4192                        )
4193                        .expect("canonical row");
4194                } else {
4195                    let base = u64::try_from(config_keys.len()).unwrap() + 1;
4196                    for (offset, key) in config_keys.iter().enumerate() {
4197                        let id = base + u64::try_from(offset).unwrap();
4198                        store
4199                            .insert_auto(
4200                                "cfg.canon_",
4201                                config_recovery_row(id, key, i64::try_from(id).unwrap()),
4202                            )
4203                            .expect("canonical config row");
4204                    }
4205                }
4206            }
4207            if cross_reference_legacy {
4208                store
4209                    .add_cross_ref(
4210                        "cfg.canon_",
4211                        EntityId::new(2),
4212                        "cfg_legacy",
4213                        EntityId::new(1),
4214                        RefType::RowToNode,
4215                        1.0,
4216                    )
4217                    .expect("reference to legacy config");
4218            }
4219            if index_legacy {
4220                store
4221                    .register_secondary_index(
4222                        "cfg_legacy",
4223                        "by_id",
4224                        index_identity.clone(),
4225                        Arc::new(SystemNameProjection),
4226                    )
4227                    .expect("legacy secondary index");
4228            }
4229
4230            if let Some(case) = alias_case {
4231                use crate::storage::{EntityData, RowData};
4232                let pairs = [
4233                    (
4234                        "old_stats",
4235                        "new.query.stats_catalog",
4236                        "red_stats",
4237                        "red.query.stats_catalog",
4238                    ),
4239                    (
4240                        "old_index_registry",
4241                        "new.index.registry",
4242                        "red_index_registry",
4243                        "red.index.registry",
4244                    ),
4245                    (
4246                        "old_queue_meta",
4247                        "new.queue.meta",
4248                        "red_queue_meta",
4249                        "red.queue.meta",
4250                    ),
4251                    (
4252                        "old_materialized_view_defs",
4253                        "new.materialized_view.definitions",
4254                        "red_materialized_view_defs",
4255                        "red.materialized_view.definitions",
4256                    ),
4257                ];
4258                for (pair_index, (old, new, legacy, canonical)) in pairs.iter().enumerate() {
4259                    for (is_old, name, table) in [(true, *old, *legacy), (false, *new, *canonical)]
4260                    {
4261                        store.create_collection(name).unwrap();
4262                        let text = |s: &str| Value::text(s);
4263                        let mut fields = match pair_index {
4264                            0 => vec![
4265                                ("kind", text("table")),
4266                                ("table", text("users")),
4267                                ("row_count", Value::Integer(if is_old { 1 } else { 2 })),
4268                                ("avg_row_size", Value::Integer(20)),
4269                                ("page_count", Value::Integer(1)),
4270                            ],
4271                            1 => vec![
4272                                ("collection", text("users")),
4273                                (
4274                                    "name",
4275                                    text(if is_old {
4276                                        "legacy_only"
4277                                    } else {
4278                                        "canonical_only"
4279                                    }),
4280                                ),
4281                                ("columns", text("id")),
4282                                ("method", text("hash")),
4283                                ("resolution", Value::Integer(0)),
4284                                ("unique", Value::Boolean(false)),
4285                                ("dropped", Value::Boolean(false)),
4286                            ],
4287                            2 => vec![
4288                                ("kind", text("queue_group")),
4289                                ("queue", text("jobs")),
4290                                ("group", text("workers")),
4291                                (
4292                                    "created_at_ns",
4293                                    Value::UnsignedInteger(if is_old { 1 } else { 2 }),
4294                                ),
4295                            ],
4296                            _ => vec![
4297                                ("name", text("view")),
4298                                ("refresh_every_ms", Value::UnsignedInteger(1000)),
4299                                ("retention_duration_ms", Value::UnsignedInteger(0)),
4300                                ("source_collections", text("users")),
4301                                (
4302                                    "source_sql",
4303                                    text(if case == "view-conflict" && !is_old {
4304                                        "SELECT 2"
4305                                    } else {
4306                                        "SELECT 1"
4307                                    }),
4308                                ),
4309                            ],
4310                        };
4311                        if case == "queue-delivery" && pair_index == 2 && is_old {
4312                            fields[0].1 = text("queue_pending");
4313                        }
4314                        let id = if is_old {
4315                            10000 + pair_index as u64
4316                        } else {
4317                            100 + pair_index as u64
4318                        };
4319                        let entity = UnifiedEntity::new(
4320                            EntityId::new(id),
4321                            EntityKind::TableRow {
4322                                table: Arc::from(table),
4323                                row_id: id,
4324                            },
4325                            EntityData::Row(RowData {
4326                                columns: vec![],
4327                                named: Some(
4328                                    fields.into_iter().map(|(k, v)| (k.to_owned(), v)).collect(),
4329                                ),
4330                                schema: None,
4331                            }),
4332                        );
4333                        store.insert_auto(name, entity).unwrap();
4334                    }
4335                }
4336            }
4337            store.persist().expect("checkpoint duplicate catalog");
4338            if !canonical_identity && legacy_populated && !config_keys.is_empty() {
4339                let tree = store
4340                    .btree_indices
4341                    .read()
4342                    .get("cfg_legacy")
4343                    .cloned()
4344                    .expect("legacy config tree");
4345                for (offset, key) in config_keys.iter().enumerate() {
4346                    let id = u64::try_from(offset).unwrap() + 1;
4347                    let entity = legacy_config_recovery_row(id, key, i64::try_from(id).unwrap());
4348                    let mut record =
4349                        UnifiedStore::serialize_entity_record(&entity, None, STORE_VERSION_CURRENT);
4350                    let canonical = b"red.config";
4351                    let legacy = b"red_config";
4352                    let mut replacements = 0;
4353                    for offset in 0..=record.len() - canonical.len() {
4354                        if &record[offset..offset + canonical.len()] == canonical {
4355                            record[offset..offset + legacy.len()].copy_from_slice(legacy);
4356                            replacements += 1;
4357                        }
4358                    }
4359                    assert_eq!(
4360                        replacements, 1,
4361                        "persisted fixture row must carry one historical table identity"
4362                    );
4363                    tree.upsert(&id.to_be_bytes(), &record)
4364                        .expect("write authenticated historical config row");
4365                }
4366                store
4367                    .flush_paged_registry()
4368                    .expect("publish historical config generation");
4369            }
4370            legacy_root = store.collection_root_page("cfg_legacy");
4371            legacy_key_root = store
4372                .key_indices
4373                .read()
4374                .get("cfg_legacy")
4375                .map(|index| index.tree.root_page_id());
4376            legacy_secondary_root = index_legacy.then(|| {
4377                store
4378                    .secondary_index("cfg_legacy", "by_id", &index_identity)
4379                    .expect("legacy secondary root")
4380                    .root_page_id()
4381            });
4382        }
4383
4384        // Recreate the production shape: a populated legacy collection and an
4385        // empty canonical collection published by an intermediate binary.
4386        // Every spelling has the same byte length, so roots and checksummed
4387        // page layouts stay unchanged while only the catalog identities move.
4388        {
4389            let pager = Pager::open(
4390                &path,
4391                PagerConfig {
4392                    disk_residency: true,
4393                    ..PagerConfig::default()
4394                },
4395            )
4396            .expect("open pager");
4397            let mut metadata = read_meta_payload(&pager)
4398                .expect("read catalog payload")
4399                .expect("published catalog payload");
4400            let bytes = metadata.as_mut_slice();
4401            let mut names = vec![("cfg_legacy", "red_config")];
4402            if canonical_identity {
4403                names.push(("cfg.canon_", "red.config"));
4404            }
4405            if alias_case.is_some() {
4406                names.extend([
4407                    ("old_stats", "red_stats"),
4408                    ("new.query.stats_catalog", "red.query.stats_catalog"),
4409                    ("old_index_registry", "red_index_registry"),
4410                    ("new.index.registry", "red.index.registry"),
4411                    ("old_queue_meta", "red_queue_meta"),
4412                    ("new.queue.meta", "red.queue.meta"),
4413                    ("old_materialized_view_defs", "red_materialized_view_defs"),
4414                    (
4415                        "new.materialized_view.definitions",
4416                        "red.materialized_view.definitions",
4417                    ),
4418                ]);
4419            }
4420            for (source, destination) in names {
4421                let (source, destination) = (source.as_bytes(), destination.as_bytes());
4422                let mut replacements = 0;
4423                for offset in 0..=bytes.len() - source.len() {
4424                    if &bytes[offset..offset + source.len()] == source {
4425                        bytes[offset..offset + destination.len()].copy_from_slice(destination);
4426                        replacements += 1;
4427                    }
4428                }
4429                assert!(replacements >= 1, "catalog must contain the source name");
4430            }
4431            publish_paged_metadata(&pager, &metadata).expect("write duplicate catalog");
4432            pager.sync().expect("sync duplicate catalog");
4433            if alias_case.is_some() {
4434                let mut metadata = read_meta_payload(&pager).unwrap().unwrap();
4435                let resume = reddb_file::native_resume::decode_catalog_resume(&metadata)
4436                    .unwrap()
4437                    .unwrap();
4438                let trailer = metadata
4439                    .windows(4)
4440                    .rposition(|bytes| bytes == b"RDRS")
4441                    .unwrap();
4442                metadata.truncate(trailer);
4443                reddb_file::native_resume::encode_catalog_resume(&mut metadata, &resume).unwrap();
4444                publish_paged_metadata(&pager, &metadata).unwrap();
4445                pager.sync().unwrap();
4446            }
4447        }
4448        let mut config_names = vec![("cfg_legacy", "red_config")];
4449        if canonical_identity {
4450            config_names.push(("cfg.canon_", "red.config"));
4451        }
4452        crate::storage::operational_manifest::OperationalManifest::for_db_path(&path)
4453            .rename_collections(&config_names)
4454            .expect("publish duplicate operational names");
4455
4456        if alias_case.is_some() {
4457            crate::storage::operational_manifest::OperationalManifest::for_db_path(&path)
4458                .rename_collections(&[
4459                    ("old_stats", "red_stats"),
4460                    ("new.query.stats_catalog", "red.query.stats_catalog"),
4461                    ("old_index_registry", "red_index_registry"),
4462                    ("new.index.registry", "red.index.registry"),
4463                    ("old_queue_meta", "red_queue_meta"),
4464                    ("new.queue.meta", "red.queue.meta"),
4465                    ("old_materialized_view_defs", "red_materialized_view_defs"),
4466                    (
4467                        "new.materialized_view.definitions",
4468                        "red.materialized_view.definitions",
4469                    ),
4470                ])
4471                .unwrap();
4472        }
4473
4474        let pages_before = Pager::open(
4475            &path,
4476            PagerConfig {
4477                disk_residency: true,
4478                ..PagerConfig::default()
4479            },
4480        )
4481        .expect("inspect pager")
4482        .page_count()
4483        .expect("page count");
4484        DuplicateSystemCatalogFixture {
4485            _directory: directory,
4486            path,
4487            config,
4488            pages_before,
4489            legacy_root,
4490            legacy_key_root,
4491            legacy_secondary_root,
4492            index_identity,
4493        }
4494    }
4495
4496    fn remove_fixture_catalog_resume(fixture: &DuplicateSystemCatalogFixture) {
4497        let pager = Pager::open(
4498            &fixture.path,
4499            PagerConfig {
4500                disk_residency: true,
4501                ..PagerConfig::default()
4502            },
4503        )
4504        .expect("fixture pager");
4505        let mut metadata = read_meta_payload(&pager).unwrap().unwrap();
4506        assert!(reddb_file::native_resume::decode_catalog_resume(&metadata)
4507            .unwrap()
4508            .is_some());
4509        let trailer = metadata
4510            .windows(4)
4511            .rposition(|bytes| bytes == b"RDRS")
4512            .unwrap();
4513        metadata.truncate(trailer);
4514        publish_paged_metadata(&pager, &metadata).unwrap();
4515        pager.sync().unwrap();
4516        assert!(reddb_file::native_resume::decode_catalog_resume(
4517            &read_meta_payload(&pager).unwrap().unwrap()
4518        )
4519        .unwrap()
4520        .is_none());
4521    }
4522
4523    #[test]
4524    fn no_resume_catalog_keeps_populated_config_beside_zero_root_alias() {
4525        for (legacy_populated, canonical_populated) in
4526            [(true, false), (false, true), (false, false)]
4527        {
4528            let fixture =
4529                duplicate_system_catalog_fixture(legacy_populated, canonical_populated, false);
4530            remove_fixture_catalog_resume(&fixture);
4531            let store = UnifiedStore::open_with_config(&fixture.path, fixture.config.clone())
4532                .expect("resolve physically empty alias without resume counters");
4533            assert_eq!(store.list_collections(), vec!["red.config".to_owned()]);
4534            assert_eq!(
4535                store.get("red.config", EntityId::new(1)).is_some(),
4536                legacy_populated
4537            );
4538            assert_eq!(
4539                store.get("red.config", EntityId::new(2)).is_some(),
4540                canonical_populated
4541            );
4542            if legacy_populated {
4543                assert_eq!(
4544                    store.collection_root_page("red.config"),
4545                    fixture.legacy_root
4546                );
4547            }
4548            store.persist().expect("checkpoint canonical catalog");
4549            drop(store);
4550            let cold = UnifiedStore::open_with_config(&fixture.path, fixture.config)
4551                .expect("cold reopen canonical catalog");
4552            assert_eq!(cold.list_collections(), vec!["red.config".to_owned()]);
4553            assert_eq!(
4554                cold.get("red.config", EntityId::new(1)).is_some(),
4555                legacy_populated
4556            );
4557            assert_eq!(
4558                cold.get("red.config", EntityId::new(2)).is_some(),
4559                canonical_populated
4560            );
4561        }
4562    }
4563
4564    #[test]
4565    fn no_resume_catalog_refuses_two_populated_config_roots() {
4566        let fixture = duplicate_system_catalog_fixture(true, true, false);
4567        remove_fixture_catalog_resume(&fixture);
4568        let error = UnifiedStore::open_with_config(&fixture.path, fixture.config)
4569            .err()
4570            .expect("two populated roots require reconciliation");
4571        assert!(error.to_string().contains("both legacy collection"));
4572    }
4573
4574    fn alias_duplicate_system_catalog_state_with(
4575        fixture: &DuplicateSystemCatalogFixture,
4576        mutate_canonical: impl FnOnce(&mut reddb_file::native_resume::CollectionResume),
4577    ) {
4578        let pager = Pager::open(
4579            &fixture.path,
4580            PagerConfig {
4581                disk_residency: true,
4582                ..PagerConfig::default()
4583            },
4584        )
4585        .expect("open duplicate catalog pager");
4586        let mut metadata = read_meta_payload(&pager)
4587            .expect("read duplicate catalog")
4588            .expect("published duplicate catalog");
4589        let mut resume = reddb_file::native_resume::decode_catalog_resume(&metadata)
4590            .expect("decode duplicate resume")
4591            .expect("prepared duplicate resume");
4592        let legacy_state = resume
4593            .collections
4594            .get("red_config")
4595            .expect("legacy resume state")
4596            .clone();
4597        let mut canonical_state = legacy_state.clone();
4598        mutate_canonical(&mut canonical_state);
4599        resume
4600            .collections
4601            .insert("red.config".to_string(), canonical_state);
4602
4603        let header = reddb_file::decode_native_paged_metadata_header(&metadata)
4604            .expect("decode metadata header")
4605            .expect("native metadata header");
4606        let mut position = reddb_file::METADATA_HEADER_BYTES;
4607        let mut replaced_root = false;
4608        for _ in 0..header.collection_count {
4609            let root = reddb_file::decode_native_paged_collection_root(&metadata, &mut position)
4610                .expect("decode collection root");
4611            if root.collection == "red.config" {
4612                metadata[position - 4..position]
4613                    .copy_from_slice(&legacy_state.root_page.to_le_bytes());
4614                replaced_root = true;
4615            }
4616        }
4617        assert!(replaced_root, "canonical catalog root must be present");
4618
4619        let trailer = metadata
4620            .windows(4)
4621            .rposition(|bytes| bytes == b"RDRS")
4622            .expect("catalog resume trailer");
4623        metadata.truncate(trailer);
4624        reddb_file::native_resume::encode_catalog_resume(&mut metadata, &resume)
4625            .expect("encode aliased resume");
4626        publish_paged_metadata(&pager, &metadata).expect("publish aliased catalog");
4627    }
4628
4629    fn alias_duplicate_system_catalog_state(fixture: &DuplicateSystemCatalogFixture) {
4630        alias_duplicate_system_catalog_state_with(fixture, |_| {});
4631    }
4632
4633    #[test]
4634    fn populated_legacy_system_collection_replaces_empty_canonical_duplicate() {
4635        let fixture = duplicate_system_catalog_fixture(true, false, true);
4636        let recovered = UnifiedStore::open_with_config(&fixture.path, fixture.config.clone())
4637            .expect("resolve empty duplicate");
4638        assert_eq!(recovered.list_collections(), vec!["red.config".to_string()]);
4639        assert_eq!(
4640            recovered
4641                .get("red.config", EntityId::new(1))
4642                .expect("preserved legacy row")
4643                .kind
4644                .collection(),
4645            "red.config"
4646        );
4647        assert_eq!(
4648            recovered.collection_root_page("red.config"),
4649            fixture.legacy_root,
4650            "the populated data root must be reused"
4651        );
4652        assert_eq!(
4653            recovered
4654                .key_indices
4655                .read()
4656                .get("red.config")
4657                .map(|index| index.tree.root_page_id()),
4658            fixture.legacy_key_root,
4659            "the populated key-index root must be reused"
4660        );
4661        assert_eq!(
4662            recovered
4663                .secondary_index("red.config", "by_id", &fixture.index_identity)
4664                .expect("surviving secondary index")
4665                .root_page_id(),
4666            fixture
4667                .legacy_secondary_root
4668                .expect("fixture secondary root")
4669        );
4670        drop(recovered);
4671
4672        let cold = UnifiedStore::open_with_config(&fixture.path, fixture.config)
4673            .expect("idempotent cold boot");
4674        assert_eq!(cold.list_collections(), vec!["red.config".to_string()]);
4675        assert_eq!(
4676            cold.pager()
4677                .expect("pager")
4678                .page_count()
4679                .expect("page count"),
4680            fixture.pages_before,
4681            "resolving an empty duplicate must not extend the file"
4682        );
4683    }
4684
4685    #[test]
4686    fn populated_canonical_system_collection_replaces_empty_legacy_duplicate() {
4687        let fixture = duplicate_system_catalog_fixture(false, true, false);
4688        let recovered = UnifiedStore::open_with_config(&fixture.path, fixture.config)
4689            .expect("retain populated canonical duplicate");
4690        assert_eq!(recovered.list_collections(), vec!["red.config".to_string()]);
4691        assert_eq!(
4692            recovered
4693                .get("red.config", EntityId::new(2))
4694                .expect("preserved canonical row")
4695                .kind
4696                .collection(),
4697            "red.config"
4698        );
4699        assert!(recovered.get("red.config", EntityId::new(1)).is_none());
4700    }
4701
4702    #[test]
4703    fn empty_canonical_system_collection_replaces_empty_legacy_duplicate() {
4704        let fixture = duplicate_system_catalog_fixture(false, false, false);
4705        let recovered = UnifiedStore::open_with_config(&fixture.path, fixture.config)
4706            .expect("prefer canonical empty duplicate");
4707        assert_eq!(recovered.list_collections(), vec!["red.config".to_string()]);
4708        assert_eq!(
4709            recovered
4710                .get_collection("red.config")
4711                .expect("canonical collection")
4712                .count(),
4713            0
4714        );
4715    }
4716
4717    #[test]
4718    fn populated_system_collection_duplicates_still_fail_closed() {
4719        let fixture = duplicate_system_catalog_fixture(true, true, false);
4720        let error = match UnifiedStore::open_with_config(&fixture.path, fixture.config) {
4721            Ok(_) => panic!("two populated identities are ambiguous"),
4722            Err(error) => error,
4723        };
4724        assert!(
4725            error
4726                .to_string()
4727                .contains("both populated legacy collection 'red_config' and canonical collection 'red.config'"),
4728            "unexpected error: {error}"
4729        );
4730        assert!(
4731            error.to_string().contains(
4732                "data_root_match=false, key_index_root_match=true, secondary_index_state_match=true"
4733            ),
4734            "the error must identify which physical state diverged without exposing page IDs: {error}"
4735        );
4736    }
4737
4738    #[test]
4739    fn explicit_compaction_promotes_populated_legacy_system_config() {
4740        let reported_keys = super::super::config_keys::tests::REPORTED_DATABASE_KEYS;
4741        let fixture =
4742            duplicate_system_catalog_fixture_with_keys(true, false, false, false, reported_keys);
4743        let destination = fixture._directory.path().join("canonical-system-config");
4744
4745        let report = crate::storage::compact::compact_paged_canonical_system_config(
4746            &fixture.path,
4747            &destination,
4748        )
4749        .expect("rewrite duplicate system config");
4750        assert!(report.records > 0);
4751        assert_eq!(report.reconciled_system_collections, 1);
4752        assert_eq!(report.imported_system_records, reported_keys.len());
4753
4754        let rewritten_path = destination.join("data.rdb");
4755        let rewritten = UnifiedStore::open_with_config(&rewritten_path, fixture.config.clone())
4756            .expect("open canonical rewrite");
4757        assert_eq!(rewritten.list_collections(), vec!["red.config".to_string()]);
4758        for (offset, legacy) in reported_keys.iter().enumerate() {
4759            let canonical = legacy
4760                .strip_prefix("red.config.")
4761                .or_else(|| legacy.strip_prefix("red."))
4762                .unwrap_or(legacy);
4763            assert_eq!(
4764                rewritten.get_config(canonical),
4765                Some(Value::Integer(i64::try_from(offset).unwrap() + 1)),
4766                "the legacy config value must survive as {canonical}"
4767            );
4768        }
4769        assert_eq!(rewritten.legacy_red_config_key_count().unwrap(), 0);
4770        drop(rewritten);
4771
4772        let cold = UnifiedStore::open_with_config(&rewritten_path, fixture.config)
4773            .expect("cold reopen canonical rewrite");
4774        assert_eq!(cold.list_collections(), vec!["red.config".to_string()]);
4775        assert!(
4776            cold.get_collection("red.config")
4777                .expect("canonical collection")
4778                .count()
4779                >= reported_keys.len()
4780        );
4781        for (offset, legacy) in reported_keys.iter().enumerate() {
4782            let canonical = legacy
4783                .strip_prefix("red.config.")
4784                .or_else(|| legacy.strip_prefix("red."))
4785                .unwrap_or(legacy);
4786            assert_eq!(
4787                cold.get_config(canonical),
4788                Some(Value::Integer(i64::try_from(offset).unwrap() + 1)),
4789                "cold reopen lost {canonical}"
4790            );
4791        }
4792        assert!(
4793            crate::storage::operational_manifest::OperationalManifest::for_db_path(&rewritten_path)
4794                .recover_or_bootstrap(&["red.config".to_string()])
4795                .expect("canonical operational manifest")
4796                .is_empty()
4797        );
4798    }
4799
4800    #[test]
4801    fn explicit_compaction_refuses_two_populated_system_config_trees() {
4802        let fixture = duplicate_system_catalog_fixture(true, true, false);
4803        let destination = fixture._directory.path().join("ambiguous-system-config");
4804
4805        let error = crate::storage::compact::compact_paged_canonical_system_config(
4806            &fixture.path,
4807            &destination,
4808        )
4809        .expect_err("two populated config trees must not be discarded");
4810        assert!(error.to_string().contains("refusing lossy migration"));
4811        assert!(!destination.exists());
4812    }
4813
4814    #[test]
4815    fn canonical_system_config_recovery_promotes_single_legacy_root_with_pending_wal() {
4816        use crate::storage::wal::{WalRecord, WalWriter};
4817
4818        let fixture = single_legacy_system_catalog_fixture_with_keys(&["red.auth.require_auth"]);
4819        let wal = reddb_file::unified_wal_path(&fixture.path);
4820        let action = reddb_file::encode_store_wal_action_frame(
4821            &reddb_file::StoreWalActionFrame::CreateCollection {
4822                name: "pending_collection".into(),
4823            },
4824        )
4825        .expect("encode pending action");
4826        {
4827            let mut writer = WalWriter::open(&wal).expect("open WAL");
4828            writer
4829                .append(&WalRecord::TxCommitBatch {
4830                    tx_id: 999,
4831                    actions: vec![action],
4832                })
4833                .expect("append committed WAL");
4834            writer.sync().expect("sync WAL");
4835        }
4836        let original = std::fs::read(&fixture.path).expect("source bytes");
4837        let original_wal = std::fs::read(&wal).expect("WAL bytes");
4838        let destination = fixture
4839            ._directory
4840            .path()
4841            .join("recovered-single-legacy-config");
4842
4843        let report = crate::storage::compact::recover_paged_wal_canonical_system_config(
4844            &fixture.path,
4845            &destination,
4846        )
4847        .expect("recover pending WAL and promote the legacy config root");
4848
4849        assert_eq!(report.reconciled_system_collections, 1);
4850        assert_eq!(report.imported_system_records, 1);
4851        assert_eq!(std::fs::read(&fixture.path).unwrap(), original);
4852        assert_eq!(std::fs::read(&wal).unwrap(), original_wal);
4853        for _ in 0..2 {
4854            let recovered = UnifiedStore::open_with_config(
4855                destination.join("data.rdb"),
4856                fixture.config.clone(),
4857            )
4858            .expect("open recovered store");
4859            let mut collections = recovered.list_collections();
4860            collections.sort();
4861            assert_eq!(collections, ["pending_collection", "red.config"]);
4862            assert_eq!(
4863                recovered.get_config("auth.require_auth"),
4864                Some(Value::Integer(1))
4865            );
4866            let mut recovered_rows = 0;
4867            recovered
4868                .get_collection("red.config")
4869                .expect("canonical config collection")
4870                .try_for_each_entity(4 * 1024 * 1024, |entity| {
4871                    recovered_rows += 1;
4872                    assert_eq!(entity.kind.collection(), "red.config");
4873                    true
4874                })
4875                .expect("scan recovered config rows");
4876            assert_eq!(recovered_rows, 1);
4877            assert_eq!(recovered.legacy_red_config_key_count().unwrap(), 0);
4878        }
4879    }
4880
4881    #[test]
4882    fn canonical_system_config_recovery_promotes_empty_legacy_root() {
4883        use crate::storage::wal::{WalRecord, WalWriter};
4884
4885        let fixture = single_empty_legacy_system_catalog_fixture();
4886        let wal = reddb_file::unified_wal_path(&fixture.path);
4887        let action = reddb_file::encode_store_wal_action_frame(
4888            &reddb_file::StoreWalActionFrame::CreateCollection {
4889                name: "pending_collection".into(),
4890            },
4891        )
4892        .expect("encode pending action");
4893        {
4894            let mut writer = WalWriter::open(&wal).expect("open WAL");
4895            writer
4896                .append(&WalRecord::TxCommitBatch {
4897                    tx_id: 1000,
4898                    actions: vec![action],
4899                })
4900                .expect("append committed WAL");
4901            writer.sync().expect("sync WAL");
4902        }
4903        let destination = fixture
4904            ._directory
4905            .path()
4906            .join("recovered-empty-legacy-config");
4907
4908        let report = crate::storage::compact::recover_paged_wal_canonical_system_config(
4909            &fixture.path,
4910            &destination,
4911        )
4912        .expect("recover and promote the empty legacy config root");
4913
4914        assert_eq!(report.reconciled_system_collections, 1);
4915        assert_eq!(report.imported_system_records, 0);
4916        for _ in 0..2 {
4917            let recovered = UnifiedStore::open_with_config(
4918                destination.join("data.rdb"),
4919                fixture.config.clone(),
4920            )
4921            .expect("cold reopen recovered store");
4922            let mut collections = recovered.list_collections();
4923            collections.sort();
4924            assert_eq!(collections, ["pending_collection", "red.config"]);
4925            assert_eq!(
4926                recovered
4927                    .get_collection("red.config")
4928                    .expect("canonical config root")
4929                    .count(),
4930                0
4931            );
4932        }
4933    }
4934
4935    #[test]
4936    fn canonical_system_config_compaction_promotes_empty_legacy_root() {
4937        let fixture = single_empty_legacy_system_catalog_fixture();
4938        let destination = fixture
4939            ._directory
4940            .path()
4941            .join("compacted-empty-legacy-config");
4942
4943        let report = crate::storage::compact::compact_paged_canonical_system_config(
4944            &fixture.path,
4945            &destination,
4946        )
4947        .expect("compact and promote the empty legacy config root");
4948
4949        assert_eq!(report.reconciled_system_collections, 1);
4950        assert_eq!(report.imported_system_records, 0);
4951        let compacted =
4952            UnifiedStore::open_with_config(destination.join("data.rdb"), fixture.config.clone())
4953                .expect("cold reopen compacted store");
4954        assert_eq!(compacted.list_collections(), ["red.config"]);
4955        assert_eq!(
4956            compacted
4957                .get_collection("red.config")
4958                .expect("canonical config root")
4959                .count(),
4960            0
4961        );
4962    }
4963
4964    #[test]
4965    fn canonical_system_config_recovery_replays_pending_unrelated_wal() {
4966        use crate::storage::wal::{WalRecord, WalWriter};
4967
4968        let fixture = duplicate_system_catalog_fixture(true, true, true);
4969        let wal = reddb_file::unified_wal_path(&fixture.path);
4970        let action = reddb_file::encode_store_wal_action_frame(
4971            &reddb_file::StoreWalActionFrame::CreateCollection {
4972                name: "pending_collection".into(),
4973            },
4974        )
4975        .expect("encode pending action");
4976        let canonical_update = reddb_file::encode_store_wal_action_frame(
4977            &reddb_file::StoreWalActionFrame::UpsertEntityRecord {
4978                collection: "red.config".into(),
4979                record: UnifiedStore::serialize_entity_record(
4980                    &UnifiedEntity::table_row(
4981                        EntityId::new(3),
4982                        "red.config",
4983                        3,
4984                        vec![Value::text("canonical WAL")],
4985                    ),
4986                    None,
4987                    STORE_VERSION_CURRENT,
4988                ),
4989            },
4990        )
4991        .expect("encode canonical update");
4992        {
4993            let mut writer = WalWriter::open(&wal).expect("open WAL");
4994            writer
4995                .append(&WalRecord::TxCommitBatch {
4996                    tx_id: 999,
4997                    actions: vec![action, canonical_update],
4998                })
4999                .expect("append committed WAL");
5000            writer.sync().expect("sync WAL");
5001        }
5002        let original = std::fs::read(&fixture.path).expect("source bytes");
5003        let original_wal = std::fs::read(&wal).expect("WAL bytes");
5004        let destination = fixture._directory.path().join("recovered-canonical-config");
5005
5006        let default_destination = fixture._directory.path().join("default-recovery");
5007        let error = crate::storage::compact::recover_paged_wal(&fixture.path, &default_destination)
5008            .expect_err("ordinary recovery must reject divergent identities");
5009        assert!(error
5010            .to_string()
5011            .contains("both populated legacy collection"));
5012
5013        crate::storage::compact::recover_paged_wal_canonical_system_config(
5014            &fixture.path,
5015            &destination,
5016        )
5017        .expect("recover pending WAL and retain canonical config");
5018
5019        assert_eq!(std::fs::read(&fixture.path).unwrap(), original);
5020        assert_eq!(std::fs::read(&wal).unwrap(), original_wal);
5021        for _ in 0..2 {
5022            let recovered = UnifiedStore::open_with_config(
5023                destination.join("data.rdb"),
5024                fixture.config.clone(),
5025            )
5026            .expect("open recovered store");
5027            assert!(recovered.get_collection("pending_collection").is_some());
5028            assert!(recovered.get("red.config", EntityId::new(1)).is_none());
5029            assert!(recovered.get("red.config", EntityId::new(2)).is_some());
5030            assert!(recovered.get("red.config", EntityId::new(3)).is_some());
5031        }
5032    }
5033
5034    #[test]
5035    fn canonical_system_catalog_recovery_reconciles_all_metadata_and_keeps_missing_index() {
5036        use crate::storage::wal::{WalRecord, WalWriter};
5037        for case in [
5038            "valid",
5039            "view-conflict",
5040            "queue-delivery",
5041            "legacy-wal",
5042            "canonical-wal",
5043        ] {
5044            let fixture = duplicate_system_catalog_fixture_with_aliases(
5045                true,
5046                true,
5047                true,
5048                false,
5049                &["system.pid"],
5050                Some(case),
5051            );
5052            let wal = reddb_file::unified_wal_path(&fixture.path);
5053            {
5054                let mut writer = WalWriter::open(&wal).unwrap();
5055                let name = match case {
5056                    "legacy-wal" => "red_index_registry",
5057                    "canonical-wal" => "red.index.registry",
5058                    _ => "durable_rows",
5059                };
5060                writer
5061                    .append(&WalRecord::TxCommitBatch {
5062                        tx_id: 1001,
5063                        actions: vec![reddb_file::encode_store_wal_action_frame(
5064                            &reddb_file::StoreWalActionFrame::CreateCollection {
5065                                name: name.into(),
5066                            },
5067                        )
5068                        .unwrap()],
5069                    })
5070                    .unwrap();
5071                writer.sync().unwrap();
5072            }
5073            let original = std::fs::read(&fixture.path).unwrap();
5074            let original_wal = std::fs::read(&wal).unwrap();
5075            let baseline = UnifiedStore::open_with_config_for_recovery(
5076                &fixture.path,
5077                fixture.config.clone(),
5078                true,
5079                None,
5080                None,
5081            )
5082            .err()
5083            .expect("unproved aliases must still fail");
5084            assert!(baseline.to_string().contains("red_stats"), "{baseline}");
5085            assert_eq!(std::fs::read(&fixture.path).unwrap(), original);
5086            let destination = fixture._directory.path().join("catalog-recovered");
5087            let result = crate::storage::compact::recover_paged_wal_canonical_system_config(
5088                &fixture.path,
5089                &destination,
5090            );
5091            assert_eq!(std::fs::read(&fixture.path).unwrap(), original);
5092            assert_eq!(std::fs::read(&wal).unwrap(), original_wal);
5093            if case != "valid" {
5094                let error = result.expect_err(case).to_string();
5095                let expected = match case {
5096                    "view-conflict" => "conflicting canonical definition",
5097                    "queue-delivery" => "unsupported legacy metadata",
5098                    _ => "WAL changes metadata",
5099                };
5100                assert!(error.contains(expected), "{case}: {error}");
5101                assert!(!destination.join("data.rdb").exists());
5102                continue;
5103            }
5104            let report = result.expect("recover all supported aliases together");
5105            assert_eq!(report.reconciled_system_collections, 4);
5106            assert_eq!(report.imported_system_records, 1);
5107            for _ in 0..2 {
5108                let store = UnifiedStore::open_with_config(
5109                    destination.join("data.rdb"),
5110                    fixture.config.clone(),
5111                )
5112                .unwrap();
5113                assert!(store.get_collection("durable_rows").is_some());
5114                let mut imported = Vec::new();
5115                store
5116                    .get_collection("red.index.registry")
5117                    .unwrap()
5118                    .try_for_each_entity(4 * 1024 * 1024, |entity| {
5119                        if entity.data.as_row().and_then(|r| r.get_field("name"))
5120                            == Some(&Value::text("legacy_only"))
5121                        {
5122                            imported.push(entity.clone());
5123                        }
5124                        true
5125                    })
5126                    .unwrap();
5127                assert_eq!(imported.len(), 1, "legacy-only index must survive");
5128                assert!(
5129                    imported[0].id.raw() > 10003,
5130                    "all retired identities must be reserved first"
5131                );
5132                assert!(
5133                    matches!(imported[0].kind, EntityKind::TableRow{row_id,..} if row_id>10001)
5134                );
5135                assert_eq!(imported[0].logical_id(), imported[0].id);
5136                assert!(store
5137                    .get("red.index.registry", EntityId::new(10001))
5138                    .is_none());
5139                assert!(store
5140                    .get("red.index.registry", EntityId::new(101))
5141                    .is_some());
5142                assert!(store
5143                    .get("red.query.stats_catalog", EntityId::new(100))
5144                    .is_some());
5145                assert!(store
5146                    .get("red.query.stats_catalog", EntityId::new(10000))
5147                    .is_none());
5148                assert_eq!(store.get_collection("red.queue.meta").unwrap().count(), 1);
5149                assert_eq!(
5150                    store
5151                        .get_collection("red.materialized_view.definitions")
5152                        .unwrap()
5153                        .count(),
5154                    1
5155                );
5156                assert!(
5157                    store
5158                        .get_collection("red.index.registry")
5159                        .unwrap()
5160                        .identity_counters()
5161                        .0
5162                        > 10001
5163                );
5164            }
5165        }
5166    }
5167
5168    #[test]
5169    fn canonical_system_config_recovery_retires_boot_wal_without_losing_other_actions() {
5170        use crate::storage::wal::{WalRecord, WalWriter};
5171        use reddb_file::StoreWalActionFrame as Action;
5172        for pending_dwb in [false, true] {
5173            let fixture = duplicate_system_catalog_fixture_with_key(
5174                true,
5175                true,
5176                true,
5177                false,
5178                Some("system.pid"),
5179            );
5180            let encode = |action| reddb_file::encode_store_wal_action_frame(&action).unwrap();
5181            let upsert = |id, value| {
5182                encode(Action::UpsertEntityRecord {
5183                    collection: "red_config".into(),
5184                    record: UnifiedStore::serialize_entity_record(
5185                        &config_recovery_row(id, "system.pid", value),
5186                        None,
5187                        STORE_VERSION_CURRENT,
5188                    ),
5189                })
5190            };
5191            let delete = |id| {
5192                encode(Action::DeleteEntityRecord {
5193                    collection: "red_config".into(),
5194                    entity_id: id,
5195                })
5196            };
5197            let wal = reddb_file::unified_wal_path(&fixture.path);
5198            {
5199                let mut writer = WalWriter::open(&wal).unwrap();
5200                writer
5201                    .append(&WalRecord::TxCommitBatch {
5202                        tx_id: 1001,
5203                        actions: vec![
5204                            upsert(10000, 3),
5205                            delete(1),
5206                            encode(Action::CreateCollection {
5207                                name: "durable_rows".into(),
5208                            }),
5209                        ],
5210                    })
5211                    .unwrap();
5212                writer
5213                    .append(&WalRecord::TxCommitBatch {
5214                        tx_id: 1002,
5215                        actions: vec![
5216                            upsert(10001, 4),
5217                            delete(10000),
5218                            encode(Action::UpsertEntityRecord {
5219                                collection: "durable_rows".into(),
5220                                record: UnifiedStore::serialize_entity_record(
5221                                    &UnifiedEntity::table_row(
5222                                        EntityId::new(3),
5223                                        "durable_rows",
5224                                        1,
5225                                        vec![Value::text("must survive")],
5226                                    ),
5227                                    None,
5228                                    STORE_VERSION_CURRENT,
5229                                ),
5230                            }),
5231                        ],
5232                    })
5233                    .unwrap();
5234                writer.sync().unwrap();
5235            }
5236            if pending_dwb {
5237                use std::io::{Seek, SeekFrom, Write};
5238                let bytes = std::fs::read(&fixture.path).unwrap();
5239                let root = fixture.legacy_root.unwrap();
5240                let offset = root as usize * PAGE_SIZE;
5241                let page: &[u8; PAGE_SIZE] = bytes[offset..offset + PAGE_SIZE].try_into().unwrap();
5242                let frame = reddb_file::encode_paged_dwb_frame([(root, page)]);
5243                let mut file = std::fs::OpenOptions::new()
5244                    .write(true)
5245                    .open(&fixture.path)
5246                    .unwrap();
5247                file.seek(SeekFrom::Start(3 * PAGE_SIZE as u64)).unwrap();
5248                file.write_all(&frame).unwrap();
5249                // Make raw-source identity inspection impossible: only the staged
5250                // DWB replay can restore the legacy configuration leaf.
5251                file.seek(SeekFrom::Start(offset as u64)).unwrap();
5252                file.write_all(&[0u8; PAGE_SIZE]).unwrap();
5253                file.sync_all().unwrap();
5254                authorize_fixture_dwb(&fixture.path, &frame);
5255            }
5256            let original = std::fs::read(&fixture.path).unwrap();
5257            let original_wal = std::fs::read(&wal).unwrap();
5258            let destination = fixture._directory.path().join("boot-config-recovered");
5259            let report = crate::storage::compact::recover_paged_wal_canonical_system_config(
5260                &fixture.path,
5261                &destination,
5262            )
5263            .expect("retire only proved boot metadata actions on the isolated copy");
5264            assert_eq!(report.retired_boot_system_wal_actions, 4);
5265            assert_eq!(std::fs::read(&fixture.path).unwrap(), original);
5266            assert_eq!(std::fs::read(&wal).unwrap(), original_wal);
5267            for _ in 0..2 {
5268                let recovered = UnifiedStore::open_with_config(
5269                    destination.join("data.rdb"),
5270                    fixture.config.clone(),
5271                )
5272                .unwrap();
5273                let manager = recovered.get_collection("red.config").unwrap();
5274                assert!(manager.identity_counters().0 > 10001);
5275                assert!(manager.identity_counters().1 > 10001);
5276                assert_eq!(recovered.get_config("system.pid"), Some(Value::Integer(2)));
5277                assert!(recovered.get("red.config", EntityId::new(1)).is_none());
5278                assert!(recovered.get("red.config", EntityId::new(10000)).is_none());
5279                assert!(recovered.get("red.config", EntityId::new(10001)).is_none());
5280                assert_eq!(
5281                    recovered
5282                        .get("durable_rows", EntityId::new(3))
5283                        .unwrap()
5284                        .data
5285                        .as_row()
5286                        .unwrap()
5287                        .columns,
5288                    vec![Value::text("must survive")]
5289                );
5290            }
5291        }
5292    }
5293
5294    #[test]
5295    fn canonical_system_config_recovery_requires_stable_boot_identity_evidence() {
5296        use crate::storage::wal::{WalRecord, WalWriter};
5297        let fixture =
5298            duplicate_system_catalog_fixture_with_key(true, true, false, false, Some("system.pid"));
5299        let original = std::fs::read(&fixture.path).unwrap();
5300        let wal = reddb_file::unified_wal_path(&fixture.path);
5301        {
5302            let mut writer = WalWriter::open(&wal).unwrap();
5303            writer
5304                .append(&WalRecord::TxCommitBatch {
5305                    tx_id: 1004,
5306                    actions: vec![reddb_file::encode_store_wal_action_frame(
5307                        &reddb_file::StoreWalActionFrame::DeleteEntityRecord {
5308                            collection: "red_config".into(),
5309                            entity_id: 1,
5310                        },
5311                    )
5312                    .unwrap()],
5313                })
5314                .unwrap();
5315            writer
5316                .append(&WalRecord::FullPageImage {
5317                    tx_id: 1004,
5318                    page_id: 1,
5319                    ckpt_epoch: 1,
5320                    data: original[PAGE_SIZE..2 * PAGE_SIZE].to_vec(),
5321                })
5322                .unwrap();
5323            writer.sync().unwrap();
5324        }
5325        let original_wal = std::fs::read(&wal).unwrap();
5326        let destination = fixture._directory.path().join("unproved-checkpoint");
5327        let error = crate::storage::compact::recover_paged_wal_canonical_system_config(
5328            &fixture.path,
5329            &destination,
5330        )
5331        .expect_err("undo images must be recovered before proving legacy identities");
5332        assert!(error
5333            .to_string()
5334            .contains("requires a stable checkpoint image"));
5335        assert!(!destination.exists());
5336        assert_eq!(std::fs::read(&fixture.path).unwrap(), original);
5337        assert_eq!(std::fs::read(&wal).unwrap(), original_wal);
5338    }
5339
5340    #[test]
5341    fn canonical_system_config_recovery_rejects_unproved_boot_wal() {
5342        use crate::storage::wal::{WalRecord, WalWriter};
5343        use reddb_file::StoreWalActionFrame as Action;
5344        for (case, checkpoint_key, wal_key, delete_id) in [
5345            ("unknown-delete", "system.pid", "system.pid", Some(90000)),
5346            (
5347                "user-key",
5348                "system.customer_setting",
5349                "system.customer_setting",
5350                None,
5351            ),
5352            (
5353                "identity-reuse",
5354                "red.config.customer_setting",
5355                "system.pid",
5356                None,
5357            ),
5358        ] {
5359            let fixture = duplicate_system_catalog_fixture_with_key(
5360                true,
5361                true,
5362                false,
5363                false,
5364                Some(checkpoint_key),
5365            );
5366            let action = if let Some(entity_id) = delete_id {
5367                Action::DeleteEntityRecord {
5368                    collection: "red_config".into(),
5369                    entity_id,
5370                }
5371            } else {
5372                Action::UpsertEntityRecord {
5373                    collection: "red_config".into(),
5374                    record: UnifiedStore::serialize_entity_record(
5375                        &config_recovery_row(1, wal_key, 99),
5376                        None,
5377                        STORE_VERSION_CURRENT,
5378                    ),
5379                }
5380            };
5381            let wal = reddb_file::unified_wal_path(&fixture.path);
5382            {
5383                let mut writer = WalWriter::open(&wal).unwrap();
5384                writer
5385                    .append(&WalRecord::TxCommitBatch {
5386                        tx_id: 1003,
5387                        actions: vec![reddb_file::encode_store_wal_action_frame(&action).unwrap()],
5388                    })
5389                    .unwrap();
5390                writer.sync().unwrap();
5391            }
5392            let original = std::fs::read(&fixture.path).unwrap();
5393            let original_wal = std::fs::read(&wal).unwrap();
5394            let destination = fixture._directory.path().join(case);
5395            let error = crate::storage::compact::recover_paged_wal_canonical_system_config(
5396                &fixture.path,
5397                &destination,
5398            )
5399            .expect_err(case);
5400            assert!(
5401                error
5402                    .to_string()
5403                    .contains("WAL references divergent legacy red_config"),
5404                "{case}: {error}"
5405            );
5406            assert!(!destination.exists());
5407            assert_eq!(std::fs::read(&fixture.path).unwrap(), original);
5408            assert_eq!(std::fs::read(&wal).unwrap(), original_wal);
5409        }
5410    }
5411
5412    #[test]
5413    fn canonical_system_config_recovery_rejects_legacy_wal_actions() {
5414        use crate::storage::wal::{WalRecord, WalWriter};
5415
5416        let fixture = duplicate_system_catalog_fixture(true, true, false);
5417        let wal = reddb_file::unified_wal_path(&fixture.path);
5418        let action = reddb_file::encode_store_wal_action_frame(
5419            &reddb_file::StoreWalActionFrame::DeleteEntityRecord {
5420                collection: "red_config".into(),
5421                entity_id: 1,
5422            },
5423        )
5424        .unwrap();
5425        {
5426            let mut writer = WalWriter::open(&wal).unwrap();
5427            writer
5428                .append(&WalRecord::TxCommitBatch {
5429                    tx_id: 999,
5430                    actions: vec![action],
5431                })
5432                .unwrap();
5433            writer.sync().unwrap();
5434        }
5435        let original = std::fs::read(&fixture.path).unwrap();
5436        let original_wal = std::fs::read(&wal).unwrap();
5437        let destination = fixture._directory.path().join("rejected-legacy-wal");
5438        let error = crate::storage::compact::recover_paged_wal_canonical_system_config(
5439            &fixture.path,
5440            &destination,
5441        )
5442        .expect_err("legacy WAL must not be replayed against the canonical tree");
5443        assert!(error
5444            .to_string()
5445            .contains("WAL references divergent legacy red_config"));
5446        assert!(!destination.exists());
5447        assert_eq!(std::fs::read(&fixture.path).unwrap(), original);
5448        assert_eq!(std::fs::read(&wal).unwrap(), original_wal);
5449    }
5450
5451    #[test]
5452    fn canonical_system_config_recovery_rejects_legacy_cross_references() {
5453        use crate::storage::wal::{WalRecord, WalWriter};
5454
5455        let fixture = duplicate_system_catalog_fixture_with_ref(true, true, false, true);
5456        let wal = reddb_file::unified_wal_path(&fixture.path);
5457        {
5458            let mut writer = WalWriter::open(&wal).unwrap();
5459            writer.append(&WalRecord::Begin { tx_id: 999 }).unwrap();
5460            writer.sync().unwrap();
5461        }
5462        let original = std::fs::read(&fixture.path).unwrap();
5463        let original_wal = std::fs::read(&wal).unwrap();
5464        let destination = fixture._directory.path().join("rejected-legacy-reference");
5465        let error = crate::storage::compact::recover_paged_wal_canonical_system_config(
5466            &fixture.path,
5467            &destination,
5468        )
5469        .expect_err("legacy references must not be retargeted silently");
5470        assert!(error
5471            .to_string()
5472            .contains("legacy red_config has cross-references"));
5473        assert!(!destination.join("data.rdb").exists());
5474        assert_eq!(std::fs::read(&fixture.path).unwrap(), original);
5475        assert_eq!(std::fs::read(&wal).unwrap(), original_wal);
5476    }
5477
5478    #[test]
5479    fn populated_system_collection_aliases_reconcile_without_rebuild() {
5480        let fixture = duplicate_system_catalog_fixture(true, false, true);
5481        alias_duplicate_system_catalog_state(&fixture);
5482
5483        let recovered = UnifiedStore::open_with_config(&fixture.path, fixture.config.clone())
5484            .expect("reconcile identical populated aliases");
5485        assert_eq!(recovered.list_collections(), vec!["red.config".to_string()]);
5486        assert_eq!(
5487            recovered
5488                .get("red.config", EntityId::new(1))
5489                .expect("preserved aliased row")
5490                .kind
5491                .collection(),
5492            "red.config"
5493        );
5494        assert_eq!(
5495            recovered.collection_root_page("red.config"),
5496            fixture.legacy_root,
5497            "the shared data root must be reused"
5498        );
5499        assert_eq!(
5500            recovered
5501                .key_indices
5502                .read()
5503                .get("red.config")
5504                .map(|index| index.tree.root_page_id()),
5505            fixture.legacy_key_root,
5506            "the shared key-index root must be reused"
5507        );
5508        assert_eq!(
5509            recovered
5510                .secondary_index("red.config", "by_id", &fixture.index_identity)
5511                .expect("shared secondary index")
5512                .root_page_id(),
5513            fixture
5514                .legacy_secondary_root
5515                .expect("fixture secondary root")
5516        );
5517        drop(recovered);
5518
5519        CHECKPOINT_SERIALIZED_ROWS.with(|count| count.set(0));
5520        let cold = UnifiedStore::open_with_config(&fixture.path, fixture.config)
5521            .expect("idempotent cold boot");
5522        assert_eq!(cold.list_collections(), vec!["red.config".to_string()]);
5523        assert_eq!(CHECKPOINT_SERIALIZED_ROWS.with(|count| count.get()), 0);
5524        assert_eq!(
5525            cold.pager()
5526                .expect("pager")
5527                .page_count()
5528                .expect("page count"),
5529            fixture.pages_before,
5530            "reconciling a physical alias must not extend the file"
5531        );
5532    }
5533
5534    #[test]
5535    fn populated_system_collection_aliases_merge_resume_counter_drift() {
5536        let fixture = duplicate_system_catalog_fixture(true, false, false);
5537        alias_duplicate_system_catalog_state_with(&fixture, |canonical| {
5538            canonical.entities += 7;
5539            canonical.entity_id_next += 100;
5540            canonical.row_id_next += 200;
5541            canonical.sequence_id_next += 300;
5542        });
5543
5544        let recovered = UnifiedStore::open_with_config(&fixture.path, fixture.config.clone())
5545            .expect("reconcile shared roots with conservative counters");
5546        let manager = recovered
5547            .get_collection("red.config")
5548            .expect("canonical collection");
5549        assert_eq!(manager.count(), 1, "the shared tree owns the exact count");
5550        assert_eq!(manager.identity_counters(), (102, 202));
5551        assert_eq!(manager.sequence_id_next(), 301);
5552        assert_eq!(
5553            recovered.collection_root_page("red.config"),
5554            fixture.legacy_root,
5555            "counter reconciliation must keep the shared data root"
5556        );
5557        drop(manager);
5558        drop(recovered);
5559
5560        CHECKPOINT_SERIALIZED_ROWS.with(|count| count.set(0));
5561        let cold = UnifiedStore::open_with_config(&fixture.path, fixture.config)
5562            .expect("idempotent cold boot after counter reconciliation");
5563        assert_eq!(cold.list_collections(), vec!["red.config".to_string()]);
5564        assert_eq!(CHECKPOINT_SERIALIZED_ROWS.with(|count| count.get()), 0);
5565        assert_eq!(
5566            cold.pager()
5567                .expect("pager")
5568                .page_count()
5569                .expect("page count"),
5570            fixture.pages_before,
5571            "counter reconciliation must not extend the file"
5572        );
5573    }
5574
5575    fn row(id: u64, value: i64) -> UnifiedEntity {
5576        UnifiedEntity::table_row(EntityId::new(id), "rows", id, vec![Value::Integer(value)])
5577    }
5578
5579    #[test]
5580    fn paged_checkpoint_excludes_uncommitted_graph_versions() {
5581        let directory = tempfile::tempdir().unwrap();
5582        let path = directory.path().join("graph.rdb");
5583        let store = UnifiedStore::open(&path).unwrap();
5584        let snapshots = Arc::new(crate::storage::transaction::snapshot::SnapshotManager::new());
5585        assert!(store.snapshot_manager.set(Arc::clone(&snapshots)).is_ok());
5586        store.create_collection("graph").unwrap();
5587        let node = UnifiedEntity::graph_node(EntityId::new(1), "a", "person", HashMap::new());
5588        let edge =
5589            UnifiedEntity::graph_edge(EntityId::new(2), "knows", "1", "1", 1.0, HashMap::new());
5590        store.insert("graph", node).unwrap();
5591        store.insert("graph", edge).unwrap();
5592        store.persist().unwrap();
5593
5594        let xid = snapshots.begin();
5595        let manager = store.get_collection("graph").unwrap();
5596        for id in [1, 2] {
5597            let mut entity = manager.get(EntityId::new(id)).unwrap();
5598            entity.xmax = xid;
5599            manager.update(entity).unwrap();
5600        }
5601        let mut node = UnifiedEntity::graph_node(EntityId::new(3), "b", "person", HashMap::new());
5602        let mut edge =
5603            UnifiedEntity::graph_edge(EntityId::new(4), "knows", "1", "3", 1.0, HashMap::new());
5604        node.xmin = xid;
5605        edge.xmin = xid;
5606        store.insert("graph", node).unwrap();
5607        store.insert("graph", edge).unwrap();
5608        store
5609            .add_cross_ref(
5610                "graph",
5611                EntityId::new(1),
5612                "graph",
5613                EntityId::new(4),
5614                RefType::RelatedTo,
5615                1.0,
5616            )
5617            .unwrap();
5618        store.persist().unwrap();
5619        snapshots.rollback(xid);
5620        store.persist().unwrap();
5621        drop(manager);
5622        drop(store);
5623
5624        let recovered = UnifiedStore::open(&path).unwrap();
5625        let manager = recovered.get_collection("graph").unwrap();
5626        assert_eq!(manager.get(EntityId::new(1)).unwrap().xmax, 0);
5627        assert_eq!(manager.get(EntityId::new(2)).unwrap().xmax, 0);
5628        assert!(manager.get(EntityId::new(3)).is_none());
5629        assert!(manager.get(EntityId::new(4)).is_none());
5630        assert!(!manager
5631            .get(EntityId::new(1))
5632            .unwrap()
5633            .cross_refs()
5634            .iter()
5635            .any(|reference| reference.target == EntityId::new(4)));
5636        assert!(!recovered
5637            .get_refs_from(EntityId::new(1))
5638            .iter()
5639            .any(|(target, _, _)| *target == EntityId::new(4)));
5640    }
5641
5642    #[test]
5643    fn paged_checkpoint_restores_graph_references_after_commit() {
5644        let directory = tempfile::tempdir().unwrap();
5645        let path = directory.path().join("graph.rdb");
5646        let store = UnifiedStore::open(&path).unwrap();
5647        let snapshots = Arc::new(crate::storage::transaction::snapshot::SnapshotManager::new());
5648        assert!(store.snapshot_manager.set(Arc::clone(&snapshots)).is_ok());
5649        store.create_collection("graph").unwrap();
5650        store
5651            .insert(
5652                "graph",
5653                UnifiedEntity::graph_node(EntityId::new(1), "a", "person", HashMap::new()),
5654            )
5655            .unwrap();
5656        store.persist().unwrap();
5657        let xid = snapshots.begin();
5658        let mut edge =
5659            UnifiedEntity::graph_edge(EntityId::new(2), "knows", "1", "1", 1.0, HashMap::new());
5660        edge.xmin = xid;
5661        store.insert("graph", edge).unwrap();
5662        store
5663            .add_cross_ref(
5664                "graph",
5665                EntityId::new(1),
5666                "graph",
5667                EntityId::new(2),
5668                RefType::RelatedTo,
5669                1.0,
5670            )
5671            .unwrap();
5672        store.persist().unwrap();
5673        snapshots.commit(xid);
5674        store.persist().unwrap();
5675        drop(store);
5676
5677        let recovered = UnifiedStore::open(&path).unwrap();
5678        let node = recovered.get("graph", EntityId::new(1)).unwrap();
5679        assert!(recovered.get("graph", EntityId::new(2)).is_some());
5680        assert!(node
5681            .cross_refs()
5682            .iter()
5683            .any(|reference| reference.target == EntityId::new(2)));
5684        assert!(recovered
5685            .get_refs_from(EntityId::new(1))
5686            .iter()
5687            .any(|(target, _, _)| *target == EntityId::new(2)));
5688    }
5689
5690    #[test]
5691    fn checkpoint_reuse_tracks_manager_updates_metadata_deletes_and_bulk_inserts() {
5692        let directory = tempfile::tempdir().expect("temporary store");
5693        let path = directory.path().join("data.rdb");
5694        let store = UnifiedStore::open(&path).expect("paged store");
5695        store.create_collection("rows").expect("collection");
5696        store.insert("rows", row(1, 1)).expect("seed");
5697        store.persist().expect("initial checkpoint");
5698        let manager = store.get_collection("rows").expect("rows");
5699        manager.update(row(1, 2)).expect("direct update");
5700        CHECKPOINT_SERIALIZED_ROWS.with(|count| count.set(0));
5701        store.persist().expect("updated row checkpoint");
5702        assert_eq!(CHECKPOINT_SERIALIZED_ROWS.with(|count| count.get()), 1);
5703        let mut metadata = Metadata::new();
5704        metadata.set("verified", MetadataValue::Bool(true));
5705        manager
5706            .set_metadata(EntityId::new(1), metadata)
5707            .expect("metadata");
5708        CHECKPOINT_SERIALIZED_ROWS.with(|count| count.set(0));
5709        store.persist().expect("metadata-only checkpoint");
5710        assert_eq!(CHECKPOINT_SERIALIZED_ROWS.with(|count| count.get()), 1);
5711        manager
5712            .bulk_insert(vec![row(2, 2), row(3, 3)])
5713            .expect("bulk insert");
5714        CHECKPOINT_SERIALIZED_ROWS.with(|count| count.set(0));
5715        store.persist().expect("bulk insert checkpoint");
5716        assert_eq!(CHECKPOINT_SERIALIZED_ROWS.with(|count| count.get()), 3);
5717        manager.delete(EntityId::new(2)).expect("delete");
5718        CHECKPOINT_SERIALIZED_ROWS.with(|count| count.set(0));
5719        store.persist().expect("changed checkpoint");
5720        assert_eq!(CHECKPOINT_SERIALIZED_ROWS.with(|count| count.get()), 2);
5721        drop(store);
5722        let recovered = UnifiedStore::open(&path).expect("recover changed rows");
5723        let manager = recovered.get_collection("rows").expect("rows");
5724        assert_eq!(manager.count(), 2);
5725        assert_eq!(
5726            manager
5727                .get(EntityId::new(1))
5728                .expect("row")
5729                .data
5730                .as_row()
5731                .expect("table")
5732                .columns,
5733            vec![Value::Integer(2)]
5734        );
5735        assert!(manager.get(EntityId::new(2)).is_none());
5736        assert!(manager
5737            .get_metadata(EntityId::new(1))
5738            .expect("metadata")
5739            .get("verified")
5740            .is_some());
5741    }
5742
5743    #[test]
5744    fn unrelated_visibility_changes_do_not_serialize_collection_rows() {
5745        let directory = tempfile::tempdir().expect("temporary store");
5746        let store = UnifiedStore::open(&directory.path().join("data.rdb")).expect("paged store");
5747        let snapshots = Arc::new(crate::storage::transaction::snapshot::SnapshotManager::new());
5748        assert!(store.snapshot_manager.set(Arc::clone(&snapshots)).is_ok());
5749        store.create_collection("rows").expect("collection");
5750        for id in 1..=40 {
5751            store.insert("rows", row(id, id as i64)).expect("seed");
5752        }
5753        store.persist().expect("initial checkpoint");
5754        let unrelated = snapshots.begin();
5755        snapshots.rollback(unrelated);
5756        CHECKPOINT_SERIALIZED_ROWS.with(|count| count.set(0));
5757        store.persist().expect("unrelated rollback checkpoint");
5758        assert_eq!(CHECKPOINT_SERIALIZED_ROWS.with(|count| count.get()), 0);
5759
5760        store
5761            .pending_vault_versions
5762            .write()
5763            .insert(EntityId::new(100));
5764        CHECKPOINT_SERIALIZED_ROWS.with(|count| count.set(0));
5765        store.persist().expect("unrelated vault checkpoint");
5766        assert_eq!(CHECKPOINT_SERIALIZED_ROWS.with(|count| count.get()), 0);
5767        store.pending_vault_versions.write().clear();
5768        CHECKPOINT_SERIALIZED_ROWS.with(|count| count.set(0));
5769        store
5770            .persist()
5771            .expect("unrelated vault publication checkpoint");
5772        assert_eq!(CHECKPOINT_SERIALIZED_ROWS.with(|count| count.get()), 0);
5773    }
5774
5775    #[test]
5776    fn low_level_rollback_of_a_non_active_xid_invalidates_sparse_visibility() {
5777        let directory = tempfile::tempdir().expect("temporary store");
5778        let path = directory.path().join("data.rdb");
5779        let store = UnifiedStore::open(&path).expect("paged store");
5780        let snapshots = Arc::new(crate::storage::transaction::snapshot::SnapshotManager::new());
5781        assert!(store.snapshot_manager.set(Arc::clone(&snapshots)).is_ok());
5782        store.create_collection("rows").expect("collection");
5783        let xid = snapshots.begin();
5784        let mut entity = row(1, 1);
5785        entity.xmin = xid;
5786        store.insert("rows", entity).expect("committed row");
5787        snapshots.commit(xid);
5788        store.persist().expect("committed baseline");
5789        snapshots.rollback(xid);
5790        store
5791            .persist()
5792            .expect("untracked outcome requires validation");
5793        drop(store);
5794        let recovered = UnifiedStore::open(&path).expect("recover");
5795        assert_eq!(recovered.get_collection("rows").expect("rows").count(), 0);
5796    }
5797
5798    #[test]
5799    fn exhausted_deferred_row_cache_keeps_full_validation() {
5800        let directory = tempfile::tempdir().expect("temporary store");
5801        let path = directory.path().join("data.rdb");
5802        let store = UnifiedStore::open(&path).expect("paged store");
5803        let snapshots = Arc::new(crate::storage::transaction::snapshot::SnapshotManager::new());
5804        assert!(store.snapshot_manager.set(Arc::clone(&snapshots)).is_ok());
5805        store.create_collection("rows").expect("collection");
5806        let xid = snapshots.begin();
5807        for id in 1..=1025 {
5808            let mut entity = row(id, id as i64);
5809            entity.xmin = xid;
5810            store.insert("rows", entity).expect("pending row");
5811        }
5812        store.persist().expect("exclude pending rows");
5813        assert!(store.checkpoint_versions.read()["rows"]
5814            .deferred_rows
5815            .is_none());
5816        snapshots.commit(xid);
5817        store
5818            .persist()
5819            .expect("publish committed rows conservatively");
5820        drop(store);
5821        let recovered = UnifiedStore::open(&path).expect("recover");
5822        assert_eq!(
5823            recovered.get_collection("rows").expect("rows").count(),
5824            1025
5825        );
5826    }
5827
5828    #[test]
5829    fn rollback_invalidates_reuse_without_a_segment_mutation() {
5830        let directory = tempfile::tempdir().expect("temporary store");
5831        let path = directory.path().join("data.rdb");
5832        let store = UnifiedStore::open(&path).expect("paged store");
5833        let snapshots = Arc::new(crate::storage::transaction::snapshot::SnapshotManager::new());
5834        assert!(store.snapshot_manager.set(Arc::clone(&snapshots)).is_ok());
5835        store.create_collection("rows").expect("collection");
5836        let xid = snapshots.begin();
5837        let mut pending = row(1, 1);
5838        pending.xmin = xid;
5839        store.insert("rows", pending).expect("version");
5840        store.persist().expect("before rollback");
5841        snapshots.rollback(xid);
5842        store.persist().expect("after rollback");
5843        drop(store);
5844        let recovered = UnifiedStore::open(&path).expect("recover");
5845        assert_eq!(recovered.get_collection("rows").expect("rows").count(), 0);
5846    }
5847
5848    #[test]
5849    fn rolled_back_delete_is_normalized_without_a_segment_mutation() {
5850        let directory = tempfile::tempdir().expect("temporary store");
5851        let path = directory.path().join("data.rdb");
5852        let store = UnifiedStore::open(&path).expect("paged store");
5853        let snapshots = Arc::new(crate::storage::transaction::snapshot::SnapshotManager::new());
5854        assert!(store.snapshot_manager.set(Arc::clone(&snapshots)).is_ok());
5855        store.create_collection("rows").expect("collection");
5856        let xid = snapshots.begin();
5857        let mut deleted = row(1, 1);
5858        deleted.xmax = xid;
5859        store.insert("rows", deleted).expect("version");
5860        store.persist().expect("before rollback");
5861        snapshots.rollback(xid);
5862        store.persist().expect("after rollback");
5863        drop(store);
5864        let recovered = UnifiedStore::open(&path).expect("recover");
5865        let manager = recovered.get_collection("rows").expect("rows");
5866        assert_eq!(manager.get(EntityId::new(1)).expect("retained row").xmax, 0);
5867    }
5868
5869    #[test]
5870    fn publishing_a_pending_vault_version_invalidates_reuse() {
5871        let directory = tempfile::tempdir().expect("temporary store");
5872        let path = directory.path().join("data.rdb");
5873        let store = UnifiedStore::open(&path).expect("paged store");
5874        store.create_collection("rows").expect("collection");
5875        store.insert("rows", row(1, 1)).expect("row");
5876        store
5877            .pending_vault_versions
5878            .write()
5879            .insert(EntityId::new(1));
5880        store.persist().expect("exclude pending vault version");
5881        store
5882            .pending_vault_versions
5883            .write()
5884            .remove(&EntityId::new(1));
5885        store.persist().expect("include published vault version");
5886        drop(store);
5887        let recovered = UnifiedStore::open(&path).expect("recover");
5888        assert_eq!(recovered.get_collection("rows").expect("rows").count(), 1);
5889    }
5890}