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