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