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