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}
14
15#[derive(Debug, Default)]
16struct CatalogLoadOutcome {
17    migrated: bool,
18    collisions: Vec<SystemCollectionCollision>,
19}
20
21/// Keep encoded payload memory proportional to a small batch, not a collection.
22/// A single large value retains the existing B-tree value-size contract.
23struct CheckpointTreeBuilder {
24    tree: Arc<BTree>,
25    records: Vec<(Vec<u8>, Vec<u8>)>,
26    bytes: usize,
27}
28
29impl CheckpointTreeBuilder {
30    fn new(pager: &Arc<Pager>) -> Self {
31        Self {
32            tree: Arc::new(BTree::new(Arc::clone(pager))),
33            records: Vec::new(),
34            bytes: 0,
35        }
36    }
37
38    fn push(&mut self, record: (Vec<u8>, Vec<u8>)) -> Result<(), StoreError> {
39        let bytes = record.0.len() + record.1.len();
40        if !self.records.is_empty()
41            && (self.bytes.saturating_add(bytes) > CHECKPOINT_BATCH_BYTES
42                || self.records.len() >= CHECKPOINT_BATCH_ROWS)
43        {
44            self.flush()?;
45        }
46        self.bytes += bytes;
47        self.records.push(record);
48        #[cfg(test)]
49        CHECKPOINT_BUFFERED_BYTES.with(|peak| peak.set(peak.get().max(self.bytes)));
50        Ok(())
51    }
52
53    fn flush(&mut self) -> Result<(), StoreError> {
54        self.tree
55            .bulk_insert_sorted(&self.records)
56            .map_err(|e| StoreError::Io(std::io::Error::other(e.to_string())))?;
57        self.records.clear();
58        self.bytes = 0;
59        Ok(())
60    }
61
62    fn copy_prefix(&mut self, previous: &BTree, count: usize) -> Result<(), StoreError> {
63        let mut cursor = previous
64            .cursor_first()
65            .map_err(|e| StoreError::Io(std::io::Error::other(e.to_string())))?;
66        for _ in 0..count {
67            let record = cursor
68                .next()
69                .map_err(|e| StoreError::Io(std::io::Error::other(e.to_string())))?
70                .ok_or_else(|| StoreError::Serialization("checkpoint prefix disappeared".into()))?;
71            self.push(record)?;
72        }
73        Ok(())
74    }
75}
76
77#[cfg(test)]
78thread_local! {
79    static CHECKPOINT_SERIALIZED_ROWS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
80    static REPLAY_SERIALIZED_ROWS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
81    static CHECKPOINT_BUFFERED_BYTES: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
82}
83
84fn checkpoint_visibility_matches(
85    previous: &CheckpointVersion,
86    current: &CheckpointVersion,
87) -> bool {
88    (previous.visibility_revision == current.visibility_revision
89        || previous.excluded_xids == current.excluded_xids)
90        && previous.pending_vault == current.pending_vault
91}
92
93// ── Pager-meta overflow chain (gh-477) ──────────────────────────────────────
94// When the serialized collection registry + cross-refs exceed a single page,
95// page 1 carries a native metadata-overflow header pointing at an overflow chain of
96// `PageType::Overflow` pages. Single-page metadata keeps the historical
97// bit-identical layout (`METADATA_MAGIC = "RDM2"` written directly at the
98// content offset).
99//
100// Page 1 (overflow form), starting at `HEADER_SIZE`:
101//   [0..4]   native metadata-overflow magic
102//   [4..8]   format_version (u32, mirrors inner payload version for debug)
103//   [8..12]  total_payload_bytes (u32)
104//   [12..16] next_overflow_page_id (u32, > 0)
105//   [16..]   first payload chunk (up to META_V3_FIRST_PAYLOAD_CAP bytes)
106//
107// Overflow continuation page, starting at `HEADER_SIZE`:
108//   [0..4]   next_overflow_page_id (u32, 0 if last)
109//   [4..8]   chunk_bytes (u32)
110//   [8..]    chunk payload (up to META_V3_OVERFLOW_PAYLOAD_CAP bytes)
111const META_PAGE_CONTENT_CAP: usize =
112    crate::storage::engine::PAGE_SIZE - crate::storage::engine::HEADER_SIZE;
113const META_V3_PAGE1_HEADER: usize = reddb_file::METADATA_OVERFLOW_HEADER_BYTES;
114const META_V3_OVERFLOW_HEADER: usize = reddb_file::METADATA_OVERFLOW_CONTINUATION_HEADER_BYTES;
115const META_V3_FIRST_PAYLOAD_CAP: usize = META_PAGE_CONTENT_CAP - META_V3_PAGE1_HEADER;
116const META_V3_OVERFLOW_PAYLOAD_CAP: usize = META_PAGE_CONTENT_CAP - META_V3_OVERFLOW_HEADER;
117
118fn existing_overflow_chain(pager: &Pager) -> Result<Vec<u32>, PagerError> {
119    let cs = crate::storage::engine::HEADER_SIZE;
120    let page = pager.read_page(1)?;
121    let bytes = page.as_bytes();
122    if bytes.len() < cs + META_V3_PAGE1_HEADER {
123        return Ok(Vec::new());
124    }
125    let Some(header) =
126        reddb_file::decode_native_metadata_overflow_header(&bytes[cs..]).map_err(|err| {
127            PagerError::InvalidDatabase(format!("invalid metadata overflow header: {err}"))
128        })?
129    else {
130        return Ok(Vec::new());
131    };
132    let mut next = header.next_overflow_page_id;
133    let mut pages = Vec::new();
134    let mut seen = std::collections::HashSet::new();
135    while next != 0 {
136        if !seen.insert(next) || pages.len() >= pager.page_count()? as usize {
137            return Err(PagerError::InvalidDatabase(
138                "cyclic metadata overflow chain".into(),
139            ));
140        }
141        let ov = pager.read_page(next)?;
142        if ov.page_type()? != crate::storage::engine::PageType::Overflow {
143            return Err(PagerError::InvalidDatabase(
144                "invalid metadata overflow page type".into(),
145            ));
146        }
147        let ob = ov.as_bytes();
148        let nn = reddb_file::decode_native_metadata_overflow_continuation_header(&ob[cs..])
149            .map_err(|err| PagerError::InvalidDatabase(err.to_string()))?
150            .next_overflow_page_id;
151        pages.push(next);
152        next = nn;
153    }
154    Ok(pages)
155}
156
157fn publish_paged_metadata(pager: &Pager, meta_data: &[u8]) -> Result<(), PagerError> {
158    let retired = existing_overflow_chain(pager)?;
159    if !retired.is_empty() {
160        pager.record_retirement_intent(&retired)?;
161    }
162    let page = build_meta_page1_with_overflow(pager, meta_data)?;
163    // Cache eviction order must not publish a root before its children.
164    pager.sync()?;
165    pager.write_page(1, page)?;
166    pager.sync()?;
167    for id in retired {
168        pager.free_page(id)?;
169    }
170    Ok(())
171}
172
173fn build_meta_page1_with_overflow(
174    pager: &Pager,
175    meta_data: &[u8],
176) -> Result<crate::storage::engine::Page, PagerError> {
177    use crate::storage::engine::{Page, PageType, HEADER_SIZE};
178
179    let mut page1 = Page::new(PageType::Header, 1);
180    let cs = HEADER_SIZE;
181
182    if meta_data.len() <= META_PAGE_CONTENT_CAP {
183        // Single-page: bit-identical to the historical layout.
184        let buf = page1.as_bytes_mut();
185        buf[cs..cs + meta_data.len()].copy_from_slice(meta_data);
186        return Ok(page1);
187    }
188
189    // Multi-page overflow form. Split the inner payload into the first chunk
190    // (held on page 1) followed by zero-or-more continuation chunks chained
191    // through `PageType::Overflow` pages.
192    let first_chunk = &meta_data[..META_V3_FIRST_PAYLOAD_CAP];
193    let mut tail = &meta_data[META_V3_FIRST_PAYLOAD_CAP..];
194    let mut chunks: Vec<&[u8]> = Vec::new();
195    while !tail.is_empty() {
196        let take = tail.len().min(META_V3_OVERFLOW_PAYLOAD_CAP);
197        chunks.push(&tail[..take]);
198        tail = &tail[take..];
199    }
200
201    let mut overflow_pages: Vec<Page> = Vec::with_capacity(chunks.len());
202    let mut overflow_ids: Vec<u32> = Vec::with_capacity(chunks.len());
203    for _ in 0..chunks.len() {
204        let pg = pager.allocate_page(PageType::Overflow)?;
205        overflow_ids.push(pg.page_id());
206        overflow_pages.push(pg);
207    }
208
209    for i in 0..chunks.len() {
210        let next = if i + 1 < chunks.len() {
211            overflow_ids[i + 1]
212        } else {
213            0u32
214        };
215        let len = chunks[i].len() as u32;
216        let buf = overflow_pages[i].as_bytes_mut();
217        reddb_file::encode_native_metadata_overflow_continuation_header(
218            &mut buf[cs..cs + META_V3_OVERFLOW_HEADER],
219            reddb_file::NativeMetadataOverflowContinuationHeader {
220                next_overflow_page_id: next,
221                chunk_bytes: len,
222            },
223        )
224        .map_err(|err| PagerError::InvalidDatabase(err.to_string()))?;
225        buf[cs + 8..cs + 8 + chunks[i].len()].copy_from_slice(chunks[i]);
226    }
227    for (idx, page) in overflow_pages.into_iter().enumerate() {
228        let id = overflow_ids[idx];
229        pager.write_page(id, page)?;
230    }
231
232    // Mirror the inner format_version for debug-friendly hex dumps.
233    let format_version = reddb_file::decode_native_paged_metadata_header(meta_data)
234        .ok()
235        .flatten()
236        .map_or(0, |header| header.format_version);
237
238    let buf = page1.as_bytes_mut();
239    reddb_file::encode_native_metadata_overflow_header(
240        &mut buf[cs..cs + META_V3_PAGE1_HEADER],
241        reddb_file::NativeMetadataOverflowHeader {
242            format_version,
243            total_payload_bytes: meta_data.len() as u32,
244            next_overflow_page_id: overflow_ids[0],
245        },
246    )
247    .map_err(|err| PagerError::InvalidDatabase(err.to_string()))?;
248    buf[cs + META_V3_PAGE1_HEADER..cs + META_V3_PAGE1_HEADER + first_chunk.len()]
249        .copy_from_slice(first_chunk);
250
251    Ok(page1)
252}
253
254fn storage_integrity(
255    zone: &str,
256    id: impl Into<String>,
257    collection: Option<String>,
258    detail: impl Into<String>,
259) -> StoreError {
260    StoreError::StorageIntegrity(crate::api::StorageIntegrityError::new(
261        zone, id, collection, detail,
262    ))
263}
264
265fn page_integrity(
266    page_id: u32,
267    collection: Option<String>,
268    detail: impl Into<String>,
269) -> StoreError {
270    storage_integrity("page", page_id.to_string(), collection, detail)
271}
272
273/// Assemble the full metadata payload from the internal manifest zone: page 1
274/// plus its overflow chain when the native overflow wrapper is present. Returns
275/// the bytes that the metadata parser would see starting from the content
276/// offset of page 1. Single-page metadata returns the raw page content
277/// (including trailing zero-pad), so the legacy parser sees the same bytes it
278/// always saw.
279///
280/// The zone is rooted by the superblock and checksummed per ADR 0074 §2: a
281/// failing checksum surfaces the didactic manifest-zone integrity error
282/// directly — there is no sidecar copy to fall back to since ADR 0038 §4
283/// phase 1 retired the shadows.
284fn read_meta_payload(pager: &Pager) -> Result<Option<Vec<u8>>, StoreError> {
285    let cs = crate::storage::engine::HEADER_SIZE;
286    let meta_page = pager.read_manifest_page().map_err(|err| {
287        storage_integrity(
288            "manifest",
289            "manifest",
290            None,
291            format!("internal manifest zone read failed ({err})"),
292        )
293    })?;
294    let bytes = meta_page.as_bytes();
295    if bytes.len() < cs + 4 {
296        return Ok(Some(bytes.get(cs..).unwrap_or(&[]).to_vec()));
297    }
298    let header = match reddb_file::decode_native_metadata_overflow_header(&bytes[cs..])
299        .map_err(|err| page_integrity(1, None, err.to_string()))?
300    {
301        Some(header) => header,
302        None => {
303            return Ok(Some(bytes[cs..].to_vec()));
304        }
305    };
306    if bytes.len() < cs + META_V3_PAGE1_HEADER {
307        return Err(page_integrity(
308            1,
309            None,
310            "metadata overflow header is truncated",
311        ));
312    }
313    let total = header.total_payload_bytes as usize;
314    let mut next = header.next_overflow_page_id;
315    let mut payload: Vec<u8> = Vec::with_capacity(total);
316    let first_take = total.min(META_V3_FIRST_PAYLOAD_CAP);
317    payload.extend_from_slice(
318        &bytes[cs + META_V3_PAGE1_HEADER..cs + META_V3_PAGE1_HEADER + first_take],
319    );
320    let mut seen = std::collections::HashSet::new();
321    while next != 0 && payload.len() < total {
322        if !seen.insert(next)
323            || seen.len()
324                >= pager
325                    .page_count()
326                    .map_err(|err| page_integrity(next, None, err.to_string()))?
327                    as usize
328        {
329            return Err(page_integrity(next, None, "cyclic metadata overflow chain"));
330        }
331        let ov = pager
332            .read_page(next)
333            .map_err(|err| page_integrity(next, None, err.to_string()))?;
334        if ov
335            .page_type()
336            .map_err(|err| page_integrity(next, None, err.to_string()))?
337            != crate::storage::engine::PageType::Overflow
338        {
339            return Err(page_integrity(
340                next,
341                None,
342                "invalid metadata overflow page type",
343            ));
344        }
345        let ob = ov.as_bytes();
346        if ob.len() < cs + META_V3_OVERFLOW_HEADER {
347            return Err(page_integrity(
348                next,
349                None,
350                "metadata overflow continuation is truncated",
351            ));
352        }
353        let continuation =
354            reddb_file::decode_native_metadata_overflow_continuation_header(&ob[cs..])
355                .map_err(|err| page_integrity(next, None, err.to_string()))?;
356        let nn = continuation.next_overflow_page_id;
357        let len = continuation.chunk_bytes as usize;
358        let remaining = total - payload.len();
359        let take = len.min(remaining).min(META_V3_OVERFLOW_PAYLOAD_CAP);
360        payload.extend_from_slice(
361            &ob[cs + META_V3_OVERFLOW_HEADER..cs + META_V3_OVERFLOW_HEADER + take],
362        );
363        next = nn;
364    }
365    if payload.len() != total {
366        return Err(page_integrity(
367            1,
368            None,
369            "metadata overflow chain is incomplete",
370        ));
371    }
372    Ok(Some(payload))
373}
374
375impl UnifiedStore {
376    /// Compare and encode one borrowed row at a time. Begin a new tree only at
377    /// the first difference, copying its already-verified prefix in bounded
378    /// batches. Unchanged trees require no replacement allocation.
379    fn checkpoint_tree(
380        &self,
381        pager: &Arc<Pager>,
382        manager: &SegmentManager,
383        previous: Option<&Arc<BTree>>,
384        mut encode: impl FnMut(&UnifiedEntity, Option<Metadata>) -> Option<Vec<u8>>,
385    ) -> Result<Arc<BTree>, StoreError> {
386        if let Some((base, overlay)) = manager.backing_overlay(&mut encode) {
387            use reddb_engine::storage::collection_read::{CollectionRead, MergedCollection};
388            let merged = MergedCollection::new(base.as_ref(), overlay);
389            let mut cursor = merged.cursor()?;
390            let mut builder = CheckpointTreeBuilder::new(pager);
391            while let Some(record) = cursor.next_record(
392                usize::try_from(self.config.resident_memory_bytes_max.unwrap_or(u64::MAX))
393                    .unwrap_or(usize::MAX),
394            )? {
395                builder.push((record.id.raw().to_be_bytes().to_vec(), record.bytes))?;
396            }
397            builder.flush()?;
398            return Ok(builder.tree);
399        }
400        let mut cursor = previous
401            .map(|tree| tree.cursor_first())
402            .transpose()
403            .map_err(|e| StoreError::Io(std::io::Error::other(e.to_string())))?;
404        let mut matched = 0;
405        let mut builder = None;
406        let mut failure = None;
407        manager.for_each_entity_with_metadata_in_id_order(|entity, metadata| {
408            let Some(value) = encode(entity, metadata) else {
409                return true;
410            };
411            let record = (entity.id.raw().to_be_bytes().to_vec(), value);
412            let result = (|| {
413                if builder.is_none() {
414                    if let Some(cursor) = cursor.as_mut() {
415                        if cursor
416                            .next()
417                            .map_err(|e| StoreError::Io(std::io::Error::other(e.to_string())))?
418                            .as_ref()
419                            == Some(&record)
420                        {
421                            matched += 1;
422                            return Ok(());
423                        }
424                    }
425                    let mut replacement = CheckpointTreeBuilder::new(pager);
426                    if let Some(previous) = previous {
427                        replacement.copy_prefix(previous, matched)?;
428                    }
429                    builder = Some(replacement);
430                }
431                builder
432                    .as_mut()
433                    .expect("replacement initialized")
434                    .push(record)
435            })();
436            if let Err(error) = result {
437                failure = Some(error);
438                return false;
439            }
440            true
441        });
442        if let Some(error) = failure {
443            return Err(error);
444        }
445        if builder.is_none() {
446            if let (Some(previous), Some(cursor)) = (previous, cursor.as_mut()) {
447                if cursor
448                    .next()
449                    .map_err(|e| StoreError::Io(std::io::Error::other(e.to_string())))?
450                    .is_none()
451                {
452                    return Ok(Arc::clone(previous));
453                }
454            }
455            let mut replacement = CheckpointTreeBuilder::new(pager);
456            if let Some(previous) = previous {
457                replacement.copy_prefix(previous, matched)?;
458            }
459            builder = Some(replacement);
460        }
461        let mut builder = builder.expect("replacement or reused tree");
462        builder.flush()?;
463        Ok(builder.tree)
464    }
465
466    /// Publication first, reuse second. The immediately preceding generation
467    /// stays in the durable ledger for one more publication. Cursors and cloned
468    /// tree handles defer retirement further without holding the writer fence.
469    fn publish_reclaimable_trees(
470        &self,
471        pager: &Pager,
472        metadata: &mut Vec<u8>,
473        live: &HashMap<String, Arc<BTree>>,
474        keys: &HashMap<String, PublishedKeyIndex>,
475    ) -> Result<(), StoreError> {
476        let mut next_xid_summary = None;
477        let mut retired = self.retired_btrees.lock();
478        let mut roots = Vec::new();
479        let mut reclaim = Vec::new();
480        let mut remove = std::collections::HashSet::new();
481        for (index, entry) in retired.iter().enumerate() {
482            if live
483                .values()
484                .chain(keys.values().flat_map(PublishedKeyIndex::trees))
485                .any(|tree| Arc::ptr_eq(tree, &entry.tree))
486            {
487                remove.insert(index);
488                continue;
489            }
490            if entry.published
491                && Arc::strong_count(&entry.tree) == 1
492                && !entry.tree.has_retirement_readers()
493            {
494                // Deliberately unsafe experimental control: keep the old
495                // root in the catalog while releasing its pages. Never enable
496                // this outside the isolated recovery test.
497                #[cfg(test)]
498                if std::env::var_os("REDDB_EXPERIMENTAL_RETAIN_RELEASED_ROOT").is_some() {
499                    roots.push(entry.tree.root_page_id());
500                }
501                reclaim.extend(
502                    entry
503                        .tree
504                        .retired_pages()
505                        .map_err(|e| StoreError::Io(std::io::Error::other(e.to_string())))?,
506                );
507                remove.insert(index);
508            } else if entry.tree.root_page_id() != 0 {
509                roots.push(entry.tree.root_page_id());
510            }
511        }
512        roots.sort_unstable();
513        roots.dedup();
514        reddb_file::encode_native_paged_retired_trees(metadata, &roots)
515            .map_err(|e| StoreError::Serialization(e.to_string()))?;
516        let (pending_pages, published_pages) = pager
517            .deferred_reclamation_pages()
518            .map_err(|e| StoreError::Io(std::io::Error::other(e.to_string())))?;
519        let readers = live
520            .values()
521            .chain(keys.values().flat_map(PublishedKeyIndex::trees))
522            .any(|tree| tree.has_retirement_readers())
523            || retired
524                .iter()
525                .any(|entry| entry.tree.has_retirement_readers());
526        let released_pages = if readers { Vec::new() } else { published_pages };
527        let released: std::collections::HashSet<_> = released_pages.iter().copied().collect();
528        let retained_pages: Vec<_> = pending_pages
529            .into_iter()
530            .filter(|id| !released.contains(id))
531            .collect();
532        reddb_file::encode_native_paged_deferred_pages(metadata, &retained_pages)
533            .map_err(|e| StoreError::Serialization(e.to_string()))?;
534        let active_epoch = self.ownership_active_epoch.load(Ordering::Acquire);
535        let catalog_epoch = if active_epoch == 0 {
536            self.ownership_catalog_epoch.load(Ordering::Acquire)
537        } else {
538            active_epoch
539        };
540        if catalog_epoch != 0 {
541            reddb_file::native_store::encode_native_paged_ownership_epoch(metadata, catalog_epoch)
542                .map_err(|e| StoreError::Serialization(e.to_string()))?;
543        }
544        if self.config.resident_memory_bytes_max.is_some() {
545            use reddb_file::native_resume::{
546                encode_catalog_resume, CatalogResume, CollectionResume,
547            };
548            let mut states = std::collections::BTreeMap::new();
549            let mut entity_id_next = self.resident.next_entity_id.load(Ordering::SeqCst);
550            let collections = self.resident.collections.read();
551            let publishes_rows = {
552                let summary = self.recovery_xid_summary.read();
553                live.iter().any(|(name, tree)| {
554                    let root = tree.root_page_id();
555                    root != 0
556                        && summary.as_ref().and_then(|state| state.roots.get(name)) != Some(&root)
557                })
558            };
559            let xid_high_water_mark =
560                self.base_xid_high_water_mark(&collections).map(|mut high| {
561                    // Ordinary metadata flushes retain the old immutable-base
562                    // bound; WAL overlays are observed at boot. Only replacement
563                    // data roots require a borrowed visit over resident headers.
564                    // The publication fence excludes concurrent header mutations.
565                    if publishes_rows {
566                        for manager in collections.values() {
567                            manager.for_each_entity(|entity| {
568                                high = high.max(entity.xmin).max(entity.xmax);
569                                true
570                            });
571                        }
572                    }
573                    if let Some(snapshots) = self.snapshot_manager.get() {
574                        high = high.max(snapshots.peek_next_xid().saturating_sub(1));
575                    }
576                    high
577                });
578            for (name, manager) in collections.iter() {
579                let tree = live.get(name);
580                let (entity_next, row_id_next) = manager.identity_counters();
581                entity_id_next = entity_id_next.max(entity_next);
582                let root_page = tree.map_or(0, |tree| tree.root_page_id());
583                let entities = if root_page == 0 {
584                    0
585                } else if let Some(count) = manager.backing_count_at_root(root_page) {
586                    count
587                } else {
588                    // Changed projections may exclude pending/aborted rows. Count
589                    // published physical keys, not the live manager's cardinality.
590                    let mut cursor = tree
591                        .expect("invariant: nonzero root has a tree")
592                        .cursor_first()
593                        .map_err(|error| {
594                            StoreError::Io(std::io::Error::other(error.to_string()))
595                        })?;
596                    let mut count = 0_u64;
597                    while cursor
598                        .skip_next()
599                        .map_err(|error| StoreError::Io(std::io::Error::other(error.to_string())))?
600                    {
601                        count += 1;
602                    }
603                    count
604                };
605                states.insert(
606                    name.clone(),
607                    CollectionResume {
608                        root_page,
609                        entities,
610                        entity_id_next: entity_next,
611                        row_id_next,
612                        sequence_id_next: manager.sequence_id_next(),
613                        key_index_root: tree.and_then(|tree| {
614                            keys.get(name)
615                                .filter(|index| {
616                                    index.indexed && index.source.ptr_eq(&Arc::downgrade(tree))
617                                })
618                                .map(|index| index.tree.root_page_id())
619                        }),
620                        secondary_indices: tree
621                            .and_then(|tree| {
622                                keys.get(name)
623                                    .filter(|index| index.source.ptr_eq(&Arc::downgrade(tree)))
624                            })
625                            .map(|index| {
626                                index
627                                    .secondary
628                                    .iter()
629                                    .map(|(name, index)| (name.clone(), index.resume()))
630                                    .collect()
631                            })
632                            .unwrap_or_default(),
633                    },
634                );
635            }
636            let resume = CatalogResume {
637                entity_id_next,
638                xid_high_water_mark,
639                collections: states,
640            };
641            encode_catalog_resume(metadata, &resume)
642                .map_err(|error| StoreError::Serialization(error.to_string()))?;
643            next_xid_summary = xid_high_water_mark.map(|high_water_mark| {
644                super::recovery_xid::RecoveryXidSummary {
645                    high_water_mark,
646                    roots: resume
647                        .collections
648                        .into_iter()
649                        .map(|(name, state)| (name, state.root_page))
650                        .collect(),
651                }
652            });
653        }
654        // Record the exact pages before the catalog can forget their owners.
655        // Recovery resolves this intent against the durable catalog epoch.
656        let mut retirement_intent = reclaim.clone();
657        retirement_intent.extend_from_slice(&released_pages);
658        if !retirement_intent.is_empty() {
659            pager
660                .record_retirement_intent(&retirement_intent)
661                .map_err(|e| StoreError::Io(std::io::Error::other(e.to_string())))?;
662        }
663        // Do not mutate retirement state or the free list before both the new
664        // children and the manifest that forgets old roots have been synced.
665        publish_paged_metadata(pager, metadata)
666            .map_err(|e| StoreError::Io(std::io::Error::other(e.to_string())))?;
667        *self.recovery_xid_summary.write() = next_xid_summary;
668        self.ownership_catalog_epoch
669            .store(catalog_epoch, Ordering::Release);
670        #[cfg(test)]
671        if !reclaim.is_empty()
672            && std::env::var_os("REDDB_EXPERIMENTAL_EXIT_BEFORE_RETIRED_FREE").is_some()
673        {
674            eprintln!(
675                "experimental exit before retired free pages={}",
676                reclaim.len()
677            );
678            std::process::exit(184);
679        }
680        let mut index = 0;
681        retired.retain_mut(|entry| {
682            let keep = !remove.contains(&index);
683            entry.published = true;
684            index += 1;
685            keep
686        });
687        if !reclaim.is_empty() {
688            pager
689                .free_retired_pages(&reclaim)
690                .map_err(|e| StoreError::Io(std::io::Error::other(e.to_string())))?;
691            #[cfg(test)]
692            if std::env::var_os("REDDB_EXPERIMENTAL_EXIT_AFTER_RETIRED_ENQUEUE").is_some() {
693                eprintln!(
694                    "experimental exit after retired enqueue pages={}",
695                    reclaim.len()
696                );
697                std::process::exit(186);
698            }
699            pager
700                .sync()
701                .map_err(|e| StoreError::Io(std::io::Error::other(e.to_string())))?;
702            #[cfg(test)]
703            if std::env::var_os("REDDB_EXPERIMENTAL_EXIT_AFTER_RETIRED_FREE").is_some() {
704                eprintln!(
705                    "experimental exit after retired free pages={}",
706                    reclaim.len()
707                );
708                std::process::exit(185);
709            }
710        }
711        pager
712            .complete_deferred_publication(&retained_pages, &released_pages)
713            .map_err(|e| StoreError::Io(std::io::Error::other(e.to_string())))?;
714        if !released_pages.is_empty() {
715            pager
716                .sync()
717                .map_err(|e| StoreError::Io(std::io::Error::other(e.to_string())))?;
718        }
719        Ok(())
720    }
721
722    pub(super) fn retire_btree(&self, tree: Arc<BTree>) {
723        let mut retired = self.retired_btrees.lock();
724        if tree.root_page_id() != 0 && !retired.iter().any(|entry| Arc::ptr_eq(&entry.tree, &tree))
725        {
726            retired.push(RetiredBTree {
727                tree,
728                published: false,
729            });
730        }
731    }
732
733    /// A completed inline insert already made this tree match its manager.
734    /// Advance the reuse proof only for that single, fenced mutation. Any
735    /// untracked edit or segment publication keeps the full comparison path.
736    pub(crate) fn note_paged_insert(
737        &self,
738        collection: &str,
739        manager: &Arc<SegmentManager>,
740        before: &[(u64, u64)],
741        id: EntityId,
742    ) {
743        let after = manager.checkpoint_revisions();
744        if before.len() != after.len() {
745            return;
746        }
747        let mut changed = 0;
748        for ((old_id, old_revision), (new_id, new_revision)) in before.iter().zip(&after) {
749            if old_id != new_id {
750                return;
751            }
752            if old_revision != new_revision {
753                if old_revision.checked_add(1) != Some(*new_revision) {
754                    return;
755                }
756                changed += 1;
757            }
758        }
759        if changed != 1 {
760            return;
761        }
762        let mut versions = self.checkpoint_versions.write();
763        let Some(previous) = versions.get_mut(collection) else {
764            return;
765        };
766        if previous.manager.ptr_eq(&Arc::downgrade(manager))
767            && previous.segments == before
768            && previous.format_version == self.format_version()
769            && !previous.pending_vault.contains(&id)
770            && !previous.excluded_xids.contains(&0)
771        {
772            previous.segments = after;
773        }
774    }
775
776    /// Successful physical deletes already changed both the manager and tree.
777    /// Reuse remains valid only when their exact revision delta accounts for
778    /// this batch, with no intervening edits or segment replacement.
779    pub(crate) fn note_paged_delete(
780        &self,
781        collection: &str,
782        manager: &Arc<SegmentManager>,
783        before: &[(u64, u64)],
784        deleted: &[EntityId],
785    ) {
786        let after = manager.checkpoint_revisions();
787        if before.len() != after.len() {
788            return;
789        }
790        let mut changes = 0_u64;
791        for ((old_id, old_revision), (new_id, new_revision)) in before.iter().zip(&after) {
792            if old_id != new_id {
793                return;
794            }
795            let Some(delta) = new_revision.checked_sub(*old_revision) else {
796                return;
797            };
798            let Some(total) = changes.checked_add(delta) else {
799                return;
800            };
801            changes = total;
802        }
803        let Ok(expected) = u64::try_from(deleted.len()) else {
804            return;
805        };
806        if changes != expected {
807            return;
808        }
809        let mut versions = self.checkpoint_versions.write();
810        let Some(previous) = versions.get_mut(collection) else {
811            return;
812        };
813        if !previous.manager.ptr_eq(&Arc::downgrade(manager))
814            || previous.format_version != self.format_version()
815            || deleted.iter().any(|id| previous.pending_vault.contains(id))
816            || previous.excluded_xids.contains(&0)
817        {
818            return;
819        }
820        if previous.segments != before {
821            let Some(deferred) = previous.deferred_rows.as_ref() else {
822                return;
823            };
824            let Some(changed) = manager.checkpoint_mutations_since(&previous.segments) else {
825                return;
826            };
827            let removed: std::collections::HashSet<_> = deleted.iter().copied().collect();
828            let pending: std::collections::BTreeSet<_> = deferred
829                .iter()
830                .copied()
831                .chain(changed)
832                .filter(|id| !removed.contains(id))
833                .collect();
834            if pending.len() > crate::storage::unified::segment::CHECKPOINT_MUTATIONS_MAX {
835                return;
836            }
837            // GC already applied its deletes to the tree. Carry earlier dirty
838            // IDs forward instead of spending their journal on these deletes.
839            // Sparse projection still requires exact WAL proof for every
840            // differing row, so an unlogged manager edit cannot be concealed.
841            previous.has_unprojected_rows = !pending.is_empty();
842            previous.deferred_rows = Some(Arc::new(pending.into_iter().collect()));
843        }
844        previous.segments = after;
845    }
846
847    pub(crate) fn mark_paged_registry_dirty(&self) {
848        self.resident.mark_paged_registry_dirty()
849    }
850
851    /// Get (or lazily create) the per-collection B-tree under a *read*
852    /// lock whenever possible. Returns a cloned `Arc<BTree>` so callers
853    /// can mutate the tree without holding the outer map's RwLock —
854    /// previously every insert serialised on `btree_indices.write()`,
855    /// costing ~60% of the concurrent-insert throughput ceiling.
856    pub(crate) fn get_or_create_btree(&self, collection: &str) -> Option<Arc<BTree>> {
857        let collection = Self::canonical_collection(collection);
858        let pager = self.pager.as_ref()?;
859        if let Some(btree) = self.btree_indices.read().get(collection).cloned() {
860            return Some(btree);
861        }
862        let mut write = self.btree_indices.write();
863        let btree = write
864            .entry(collection.to_string())
865            .or_insert_with(|| Arc::new(BTree::new(Arc::clone(pager))))
866            .clone();
867        Some(btree)
868    }
869
870    pub(crate) fn flush_paged_state(&self) -> Result<(), StoreError> {
871        let Some(pager) = &self.pager else {
872            return Ok(());
873        };
874
875        if self.resident.paged_registry_dirty.load(Ordering::Acquire) {
876            self.flush_paged_registry()?;
877            self.resident
878                .paged_registry_dirty
879                .store(false, Ordering::Release);
880            return Ok(());
881        }
882
883        pager
884            .flush()
885            .map_err(|e| StoreError::Io(std::io::Error::other(e.to_string())))
886    }
887
888    pub(crate) fn flush_paged_registry(&self) -> Result<(), StoreError> {
889        let _vault_guard = self.vault_publication_lock.lock();
890        let Some(pager) = &self.pager else {
891            return Ok(());
892        };
893
894        match pager.read_page(1) {
895            Ok(_) => {}
896            Err(PagerError::PageNotFound(_)) => {
897                let meta_page = pager
898                    .allocate_page(crate::storage::engine::PageType::Header)
899                    .map_err(|e| StoreError::Io(std::io::Error::other(e.to_string())))?;
900                pager
901                    .write_page(meta_page.page_id(), meta_page)
902                    .map_err(|e| StoreError::Io(std::io::Error::other(e.to_string())))?;
903            }
904            Err(e) => {
905                return Err(StoreError::Io(std::io::Error::other(e.to_string())));
906            }
907        }
908
909        let format_version = STORE_VERSION_V11;
910        self.set_format_version(format_version);
911
912        let collections = self.resident.collections.read();
913        let btree_indices = self.btree_indices.read();
914        let mut collection_roots = Vec::with_capacity(collections.len());
915        for (name, _) in collections.iter() {
916            let root_page = btree_indices
917                .get(name)
918                .map_or(0, |btree| btree.root_page_id());
919            collection_roots.push((name.clone(), root_page));
920        }
921        drop(btree_indices);
922        drop(collections);
923
924        let mut meta_data = Vec::with_capacity(4096);
925        reddb_file::encode_native_paged_metadata_header(
926            &mut meta_data,
927            reddb_file::NativePagedMetadataHeader {
928                format_version,
929                collection_count: collection_roots.len() as u32,
930            },
931        );
932        for (name, root_page) in &collection_roots {
933            reddb_file::encode_native_paged_collection_root(&mut meta_data, name, *root_page);
934        }
935
936        let cross_refs = self.resident.cross_refs.read();
937        let total_refs: usize = cross_refs.values().map(|v| v.len()).sum();
938        meta_data.extend_from_slice(&(total_refs as u32).to_le_bytes());
939        for (source_id, refs) in cross_refs.iter() {
940            for (target_id, ref_type, collection) in refs {
941                reddb_file::encode_native_paged_cross_ref(
942                    &mut meta_data,
943                    source_id.raw(),
944                    target_id.raw(),
945                    ref_type.to_byte(),
946                    reddb_engine::storage::system_collection_names::canonical_or_self(collection),
947                );
948            }
949        }
950
951        self.publish_reclaimable_trees(
952            pager,
953            &mut meta_data,
954            &self.btree_indices.read(),
955            &self.key_indices.read(),
956        )?;
957
958        Ok(())
959    }
960
961    /// Get a reference to the underlying pager (if in paged mode).
962    pub fn pager(&self) -> Option<&Arc<Pager>> {
963        self.pager.as_ref()
964    }
965
966    /// Bytes of page-cache memory currently occupied by resident pages
967    /// (ADR 0073 §2). Slots are fixed size, so occupancy times the page size
968    /// *is* the footprint; a store without a pager occupies none.
969    pub fn page_cache_bytes_in_use(&self) -> u64 {
970        self.pager.as_ref().map_or(0, |pager| {
971            pager.cache_len() as u64 * crate::storage::memory_pools::PAGE_CACHE_PAGE_SIZE_BYTES
972        })
973    }
974
975    /// Bytes of WAL buffer memory held by this store's group-commit
976    /// coordinator: queued records plus the writer's fixed append buffer.
977    pub fn wal_buffer_bytes_in_use(&self) -> u64 {
978        self.commit
979            .as_ref()
980            .map_or(0, |commit| commit.buffered_bytes())
981    }
982
983    /// Approximate resident bytes across every collection's segment arena
984    /// (ADR 0073 §2). Same number `stats().total_memory_bytes` reports, without
985    /// building the per-collection stats map a sampler would throw away.
986    pub fn segment_memory_bytes(&self) -> u64 {
987        let managers: Vec<Arc<SegmentManager>> = self
988            .resident
989            .collections
990            .read()
991            .values()
992            .map(Arc::clone)
993            .collect();
994
995        managers
996            .iter()
997            .map(|manager| manager.resident_bytes())
998            .fold(0, u64::saturating_add)
999    }
1000
1001    /// Borrow the immutable store configuration. Runtime hooks (e.g. the
1002    /// `auto_index_id` first-insert hook in `MutationEngine`) read knobs
1003    /// off this struct without going through the legacy global config tree.
1004    pub fn config(&self) -> &UnifiedStoreConfig {
1005        &self.config
1006    }
1007
1008    pub fn with_config(config: UnifiedStoreConfig) -> Self {
1009        Self {
1010            config,
1011            resident: reddb_engine::storage::resident_store::ResidentStore::new(),
1012            format_version: AtomicU32::new(STORE_VERSION_V11),
1013            pager: None,
1014            db_path: None,
1015            btree_indices: RwLock::new(HashMap::new()),
1016            key_indices: RwLock::new(HashMap::new()),
1017            secondary_index_specs: RwLock::new(HashMap::new()),
1018            recovery_xid_summary: RwLock::new(None),
1019            checkpoint_versions: RwLock::new(HashMap::new()),
1020            checkpoint_durable_rows: RwLock::new(super::checkpoint_delta::DurableRows::default()),
1021            retired_btrees: parking_lot::Mutex::new(Vec::new()),
1022            ownership_catalog_epoch: AtomicU64::new(0),
1023            ownership_active_epoch: AtomicU64::new(0),
1024            detached_native_blob_pages: parking_lot::Mutex::new(Default::default()),
1025            commit: None,
1026            replayed_turbo_inserts: parking_lot::Mutex::new(HashMap::new()),
1027            replayed_probabilistic_deltas: parking_lot::Mutex::new(Vec::new()),
1028            snapshot_manager: std::sync::OnceLock::new(),
1029            embedded_commit_session: std::sync::OnceLock::new(),
1030            #[cfg(test)]
1031            test_fail_deferred_wal_append: std::sync::atomic::AtomicBool::new(false),
1032            #[cfg(test)]
1033            test_require_deferred_publication_fence: std::sync::atomic::AtomicBool::new(false),
1034            vault_publication_lock: parking_lot::ReentrantMutex::new(()),
1035            pending_vault_versions: RwLock::new(std::collections::HashSet::new()),
1036        }
1037    }
1038
1039    /// Open or create a page-based database
1040    ///
1041    /// This uses the page engine for ACID durability with B-tree indices.
1042    /// The database file uses 16 KiB pages with checksums and efficient caching.
1043    ///
1044    /// # Arguments
1045    ///
1046    /// * `path` - Path to the database file (e.g., "data.rdb")
1047    ///
1048    /// # Example
1049    ///
1050    /// ```rust,ignore
1051    /// let store = UnifiedStore::open("security.rdb")?;
1052    /// store.create_collection("hosts")?;
1053    /// // ... operations ...
1054    /// store.persist()?; // Flush to disk
1055    /// ```
1056    pub fn open(path: impl AsRef<Path>) -> Result<Self, StoreError> {
1057        Self::open_with_config(path, UnifiedStoreConfig::default())
1058    }
1059
1060    pub fn open_with_config(
1061        path: impl AsRef<Path>,
1062        config: UnifiedStoreConfig,
1063    ) -> Result<Self, StoreError> {
1064        let path = path.as_ref();
1065        let mut pager_config = PagerConfig {
1066            disk_residency: config.resident_memory_bytes_max.is_some(),
1067            ..PagerConfig::default()
1068        };
1069        // ADR 0073 §2 — the page cache is pre-sized from its budget share.
1070        // The pager's own `cache_size` default survives only for direct
1071        // library callers who never resolved a budget.
1072        if let Some(slots) = config.page_cache_slots {
1073            pager_config.cache_size = slots;
1074        }
1075        // Tunables via env — experimental, used by the benchmark harness
1076        // to compare durability profiles head-to-head with Postgres.
1077        // REDDB_DOUBLE_WRITE=0 requests skipping the double-write buffer,
1078        // which otherwise adds two fsyncs per pager flush (one on DWB, one
1079        // on the main file). The pager honors this only when the actual
1080        // data file is proven to live on a CoW filesystem with atomic page
1081        // writes; otherwise it fails closed and keeps DWB enabled.
1082        if matches!(
1083            std::env::var("REDDB_DOUBLE_WRITE").ok().as_deref(),
1084            Some("0") | Some("false") | Some("off")
1085        ) {
1086            pager_config.double_write = false;
1087        }
1088        let pager_open_started = std::time::Instant::now();
1089        let pager = Pager::open(path, pager_config)
1090            .map_err(|e| StoreError::Io(std::io::Error::other(e.to_string())))?;
1091        pager
1092            .enable_deferred_reclamation()
1093            .map_err(|e| StoreError::Io(std::io::Error::other(e.to_string())))?;
1094        trace_startup_phase(path, "paged_pager_open", pager_open_started);
1095
1096        if config.resident_memory_bytes_max.is_none()
1097            && pager
1098                .header()
1099                .map_err(|error| StoreError::Io(std::io::Error::other(error.to_string())))?
1100                .version
1101                >= reddb_file::file_format::PAGE_DISK_RESIDENCY_FILE_VERSION
1102        {
1103            return Err(StoreError::Io(std::io::Error::new(std::io::ErrorKind::Unsupported,
1104                "this database was prepared for disk residency; reopen with the disk-resident storage profile")));
1105        }
1106
1107        let wal_path = reddb_file::layout::unified_wal_path(path);
1108        // Explicit transactions and WAL-only vault versions need committed
1109        // batches in every durability mode, including Strict's inline fsync.
1110        let commit = Some(Arc::new(
1111            StoreCommitCoordinator::open(wal_path, config.durability_mode, config.group_commit)
1112                .map_err(StoreError::Io)?,
1113        ));
1114
1115        let store = Self {
1116            config,
1117            resident: reddb_engine::storage::resident_store::ResidentStore::new(),
1118            format_version: AtomicU32::new(STORE_VERSION_V11),
1119            pager: Some(Arc::new(pager)),
1120            db_path: Some(path.to_path_buf()),
1121            btree_indices: RwLock::new(HashMap::new()),
1122            key_indices: RwLock::new(HashMap::new()),
1123            secondary_index_specs: RwLock::new(HashMap::new()),
1124            recovery_xid_summary: RwLock::new(None),
1125            checkpoint_versions: RwLock::new(HashMap::new()),
1126            checkpoint_durable_rows: RwLock::new(super::checkpoint_delta::DurableRows::default()),
1127            retired_btrees: parking_lot::Mutex::new(Vec::new()),
1128            ownership_catalog_epoch: AtomicU64::new(0),
1129            ownership_active_epoch: AtomicU64::new(0),
1130            detached_native_blob_pages: parking_lot::Mutex::new(Default::default()),
1131            commit,
1132            replayed_turbo_inserts: parking_lot::Mutex::new(HashMap::new()),
1133            replayed_probabilistic_deltas: parking_lot::Mutex::new(Vec::new()),
1134            snapshot_manager: std::sync::OnceLock::new(),
1135            embedded_commit_session: std::sync::OnceLock::new(),
1136            #[cfg(test)]
1137            test_fail_deferred_wal_append: std::sync::atomic::AtomicBool::new(false),
1138            #[cfg(test)]
1139            test_require_deferred_publication_fence: std::sync::atomic::AtomicBool::new(false),
1140            vault_publication_lock: parking_lot::ReentrantMutex::new(()),
1141            pending_vault_versions: RwLock::new(std::collections::HashSet::new()),
1142        };
1143
1144        // An interrupted structural edit may have reached home pages without
1145        // publishing its catalog. Restore those pages before following roots.
1146        let checkpoint_recovery_started = std::time::Instant::now();
1147        if let Some(commit) = &store.commit {
1148            commit
1149                .restore_checkpoint_page_images(&store)
1150                .map_err(StoreError::Io)?;
1151        }
1152        trace_startup_phase(
1153            path,
1154            "paged_checkpoint_recovery",
1155            checkpoint_recovery_started,
1156        );
1157        // Load existing data from pages if database exists
1158        let page_load_started = std::time::Instant::now();
1159        let catalog_load = store.load_from_pages()?;
1160        trace_startup_phase(path, "paged_catalog_and_roots", page_load_started);
1161        // Reconcile independently from the catalog flag. A prior process may
1162        // have published one side of the rename before crashing; the manifest
1163        // operation is idempotent and fails closed on old/new collisions.
1164        store.migrate_operational_manifest(&catalog_load.collisions)?;
1165        let wal_replay_started = std::time::Instant::now();
1166        if let Some(commit) = &store.commit {
1167            commit.replay_into(&store).map_err(StoreError::Io)?;
1168        }
1169        trace_startup_phase(path, "paged_wal_replay", wal_replay_started);
1170        if catalog_load.migrated {
1171            // Publish only the renamed catalog keys. Existing row, key and
1172            // secondary-index roots remain attached and are not rebuilt.
1173            store.mark_paged_registry_dirty();
1174            store.flush_paged_state()?;
1175        }
1176        let manifest_recovery_started = std::time::Instant::now();
1177        store.recover_operational_manifest()?;
1178        if store.config.resident_memory_bytes_max.is_some() {
1179            let missing_key_index = {
1180                let trees = store.btree_indices.read();
1181                let keys = store.key_indices.read();
1182                trees.iter().any(|(name, tree)| {
1183                    !keys
1184                        .get(name)
1185                        .is_some_and(|index| index.source.ptr_eq(&Arc::downgrade(tree)))
1186                })
1187            };
1188            if missing_key_index {
1189                store.persist()?;
1190            }
1191        }
1192        trace_startup_phase(
1193            path,
1194            "paged_manifest_and_derived_roots",
1195            manifest_recovery_started,
1196        );
1197
1198        Ok(store)
1199    }
1200
1201    /// Replay changes the authoritative managers first. Build replacement trees
1202    /// only for changed collections, leaving every published tree untouched until
1203    /// all replacement pages are durable. Retain the WAL for auxiliary recovery.
1204    pub(crate) fn reconcile_replayed_btrees(&self) -> Result<(), StoreError> {
1205        let Some(pager) = &self.pager else {
1206            return Ok(());
1207        };
1208        let collections = self.resident.collections.read();
1209        let mut indices = self.btree_indices.write();
1210        let previous_versions = self.checkpoint_versions.read();
1211        let raw_replay =
1212            self.snapshot_manager.get().is_none() && self.pending_vault_versions.read().is_empty();
1213        for (name, manager) in collections.iter() {
1214            let mut reusable = None;
1215            if raw_replay {
1216                if let (Some(previous), Some(tree)) =
1217                    (previous_versions.get(name), indices.get(name))
1218                {
1219                    let mut current = CheckpointVersion {
1220                        manager: Arc::downgrade(manager),
1221                        segments: manager.checkpoint_revisions(),
1222                        visibility_revision: 0,
1223                        untracked_visibility_revision: 0,
1224                        excluded_xids: Arc::default(),
1225                        pending_vault: Arc::default(),
1226                        format_version: self.format_version(),
1227                        deferred_rows: Some(Arc::default()),
1228                        has_unprojected_rows: false,
1229                    };
1230                    if previous.manager.ptr_eq(&current.manager)
1231                        && previous.format_version == current.format_version
1232                    {
1233                        if previous.segments == current.segments
1234                            && !previous.has_unprojected_rows
1235                            && checkpoint_visibility_matches(previous, &current)
1236                        {
1237                            reusable = Some(Arc::clone(tree));
1238                        } else {
1239                            // The replay ownership epoch protects in-place
1240                            // changes with the same undo/publication protocol
1241                            // used by normal sparse checkpoints. Missing WAL
1242                            // proof, old formats and exhausted journals retain
1243                            // the full comparison/rebuild path below.
1244                            reusable = self.sparse_checkpoint_tree(
1245                                name,
1246                                manager,
1247                                tree,
1248                                previous,
1249                                &mut current,
1250                            )?;
1251                        }
1252                    }
1253                }
1254            }
1255            let tree = match reusable {
1256                Some(tree) => tree,
1257                None => {
1258                    self.checkpoint_tree(pager, manager, indices.get(name), |entity, metadata| {
1259                        #[cfg(test)]
1260                        REPLAY_SERIALIZED_ROWS.with(|count| count.set(count.get() + 1));
1261                        Some(Self::serialize_entity_record(
1262                            entity,
1263                            metadata.as_ref(),
1264                            self.format_version(),
1265                        ))
1266                    })?
1267                }
1268            };
1269            if indices
1270                .get(name)
1271                .is_some_and(|previous| Arc::ptr_eq(previous, &tree))
1272            {
1273                continue;
1274            }
1275            if let Some(previous) = indices.insert(name.clone(), tree) {
1276                self.retire_btree(previous);
1277            }
1278            self.mark_paged_registry_dirty();
1279            #[cfg(test)]
1280            if std::env::var_os("REDDB_EXPERIMENTAL_EXIT_DURING_REPLAY_REBUILD").is_some() {
1281                if std::env::var_os("REDDB_EXPERIMENTAL_REPLAY_NO_SYNC").is_none() {
1282                    pager.sync().map_err(|error| {
1283                        StoreError::Io(std::io::Error::other(error.to_string()))
1284                    })?;
1285                }
1286                eprintln!("experimental replay rebuild exit before manifest");
1287                std::process::exit(182);
1288            }
1289        }
1290        drop(previous_versions);
1291        drop(indices);
1292        drop(collections);
1293        self.flush_paged_state()?;
1294        #[cfg(test)]
1295        if std::env::var_os("REDDB_EXPERIMENTAL_EXIT_AFTER_REPLAY_PUBLICATION").is_some() {
1296            eprintln!("experimental replay exit after manifest");
1297            std::process::exit(182);
1298        }
1299        // Opening has no concurrent writers. Replay has now durably reconciled
1300        // these raw trees; do not immediately rebuild them at the first flush.
1301        if self.snapshot_manager.get().is_none() && self.pending_vault_versions.read().is_empty() {
1302            let collections = self.resident.collections.read();
1303            let mut versions = self.checkpoint_versions.write();
1304            for (name, manager) in collections.iter() {
1305                versions.insert(
1306                    name.clone(),
1307                    CheckpointVersion {
1308                        manager: Arc::downgrade(manager),
1309                        segments: manager.checkpoint_revisions(),
1310                        visibility_revision: 0,
1311                        untracked_visibility_revision: 0,
1312                        excluded_xids: Arc::default(),
1313                        pending_vault: Arc::default(),
1314                        format_version: self.format_version(),
1315                        deferred_rows: Some(Arc::default()),
1316                        has_unprojected_rows: false,
1317                    },
1318                );
1319            }
1320        }
1321        Ok(())
1322    }
1323
1324    pub(crate) fn recover_operational_manifest(&self) -> Result<(), StoreError> {
1325        let Some(path) = &self.db_path else {
1326            return Ok(());
1327        };
1328        let mut collections = self.list_collections();
1329        collections.sort();
1330        let pending_drops =
1331            crate::storage::operational_manifest::OperationalManifest::for_db_path(path)
1332                .recover_or_bootstrap(&collections)
1333                .map_err(|err| storage_integrity("manifest", "current", None, err.to_string()))?;
1334        for name in pending_drops {
1335            if self.get_collection(&name).is_some() {
1336                self.drop_collection(&name)?;
1337            }
1338        }
1339        Ok(())
1340    }
1341
1342    fn migrate_operational_manifest(
1343        &self,
1344        collisions: &[SystemCollectionCollision],
1345    ) -> Result<(), StoreError> {
1346        let Some(path) = &self.db_path else {
1347            return Ok(());
1348        };
1349        let manifest = crate::storage::operational_manifest::OperationalManifest::for_db_path(path);
1350        let collision_refs = collisions
1351            .iter()
1352            .map(|collision| {
1353                (
1354                    collision.survivor.as_str(),
1355                    collision.loser.as_str(),
1356                    collision.canonical.as_str(),
1357                )
1358            })
1359            .collect::<Vec<_>>();
1360        manifest
1361            .reconcile_collection_collisions(&collision_refs)
1362            .map_err(|err| storage_integrity("manifest", "current", None, err.to_string()))?;
1363        manifest
1364            .rename_collections(reddb_engine::storage::system_collection_names::LEGACY_NAMES)
1365            .map_err(|err| storage_integrity("manifest", "current", None, err.to_string()))
1366    }
1367
1368    pub(crate) fn prepare_operational_collection_create(
1369        &self,
1370        name: &str,
1371    ) -> Result<Option<reddb_file::PreparedCollectionCreate<reddb_file::StdVfs>>, StoreError> {
1372        let Some(path) = &self.db_path else {
1373            return Ok(None);
1374        };
1375        crate::storage::operational_manifest::OperationalManifest::for_db_path(path)
1376            .prepare_collection_create(name)
1377            .map(Some)
1378            .map_err(StoreError::Io)
1379    }
1380
1381    pub(crate) fn prepare_operational_collection_drop(
1382        &self,
1383        name: &str,
1384    ) -> Result<Option<reddb_file::PreparedCollectionDrop<reddb_file::StdVfs>>, StoreError> {
1385        let Some(path) = &self.db_path else {
1386            return Ok(None);
1387        };
1388        crate::storage::operational_manifest::OperationalManifest::for_db_path(path)
1389            .prepare_collection_drop(name)
1390            .map(Some)
1391            .map_err(StoreError::Io)
1392    }
1393
1394    /// Read the commit boundary before following potentially interrupted trees.
1395    pub(super) fn published_ownership_epoch(&self) -> Result<u64, StoreError> {
1396        let Some(pager) = &self.pager else {
1397            return Ok(0);
1398        };
1399        let Some(content) = read_meta_payload(pager)? else {
1400            return Ok(0);
1401        };
1402        reddb_file::native_store::decode_native_paged_catalog_ownership_epoch(&content)
1403            .map(|epoch| epoch.unwrap_or(0))
1404            .map_err(|error| StoreError::Serialization(error.to_string()))
1405    }
1406
1407    /// Read the B-tree indices and reconstruct collections from pages.
1408    fn load_from_pages(&self) -> Result<CatalogLoadOutcome, StoreError> {
1409        let pager = match &self.pager {
1410            Some(p) => p,
1411            None => return Ok(CatalogLoadOutcome::default()), // No pager, nothing to load
1412        };
1413
1414        // Get page count
1415        let page_count = pager.page_count().map_err(|e| {
1416            StoreError::Io(std::io::Error::other(format!(
1417                "failed to read page count: {}",
1418                e
1419            )))
1420        })?;
1421        if page_count <= 1 {
1422            // Empty database (only header page)
1423            return Ok(CatalogLoadOutcome::default());
1424        }
1425
1426        // Read metadata starting from page 1 (collections registry). The
1427        // helper transparently follows the `RDM3` overflow chain when the
1428        // metadata blob spans multiple pages and falls back to the legacy
1429        // `<data>-meta` shadow when page 1 itself is corrupted.
1430        if let Some(content_vec) = read_meta_payload(pager)? {
1431            let content: &[u8] = &content_vec;
1432            let file_version = pager
1433                .header()
1434                .map_err(|error| StoreError::Io(std::io::Error::other(error.to_string())))?
1435                .version;
1436            let prepared =
1437                file_version >= reddb_file::file_format::PAGE_DISK_RESIDENCY_FILE_VERSION;
1438            let resume = if prepared {
1439                reddb_file::native_resume::decode_catalog_resume(content)
1440                    .map_err(|error| StoreError::Serialization(error.to_string()))?
1441            } else {
1442                None
1443            };
1444            let mut outcome = CatalogLoadOutcome::default();
1445            let mut ignored_collections = std::collections::HashSet::new();
1446            if let Some(resume) = &resume {
1447                for &(legacy, canonical) in
1448                    reddb_engine::storage::system_collection_names::LEGACY_NAMES
1449                {
1450                    let (Some(legacy_state), Some(canonical_state)) = (
1451                        resume.collections.get(legacy),
1452                        resume.collections.get(canonical),
1453                    ) else {
1454                        continue;
1455                    };
1456                    let shared_physical_state = legacy_state == canonical_state;
1457                    let (survivor, loser) = match (
1458                        legacy_state.entities == 0,
1459                        canonical_state.entities == 0,
1460                    ) {
1461                        (false, true) => (legacy, canonical),
1462                        (true, false) | (true, true) => (canonical, legacy),
1463                        (false, false) if shared_physical_state => (canonical, legacy),
1464                        (false, false) => {
1465                            return Err(page_integrity(
1466                                1,
1467                                None,
1468                                format!(
1469                                    "catalog contains both populated legacy collection '{legacy}' and canonical collection '{canonical}'"
1470                                ),
1471                            ));
1472                        }
1473                    };
1474                    ignored_collections.insert(loser.to_string());
1475                    outcome.collisions.push(SystemCollectionCollision {
1476                        survivor: survivor.to_string(),
1477                        loser: loser.to_string(),
1478                        canonical: canonical.to_string(),
1479                        shared_physical_state,
1480                    });
1481                }
1482                let live_roots = resume
1483                    .collections
1484                    .iter()
1485                    .filter(|(name, _)| !ignored_collections.contains(name.as_str()))
1486                    .flat_map(|(_, state)| {
1487                        std::iter::once(state.root_page)
1488                            .chain(state.key_index_root)
1489                            .chain(
1490                                state
1491                                    .secondary_indices
1492                                    .values()
1493                                    .map(|index| index.root_page),
1494                            )
1495                    })
1496                    .filter(|root| *root != 0)
1497                    .collect::<std::collections::HashSet<_>>();
1498                for collision in &outcome.collisions {
1499                    if collision.shared_physical_state {
1500                        continue;
1501                    }
1502                    let loser_state = resume
1503                        .collections
1504                        .get(&collision.loser)
1505                        .expect("collision loser is present");
1506                    if std::iter::once(loser_state.root_page)
1507                        .chain(loser_state.key_index_root)
1508                        .chain(
1509                            loser_state
1510                                .secondary_indices
1511                                .values()
1512                                .map(|index| index.root_page),
1513                        )
1514                        .filter(|root| *root != 0)
1515                        .any(|root| live_roots.contains(&root))
1516                    {
1517                        return Err(page_integrity(
1518                            1,
1519                            None,
1520                            format!(
1521                                "empty collection '{}' aliases a live collection root",
1522                                collision.loser
1523                            ),
1524                        ));
1525                    }
1526                }
1527                reddb_engine::storage::system_collection_names::migration_plan(
1528                    resume
1529                        .collections
1530                        .keys()
1531                        .map(String::as_str)
1532                        .filter(|name| !ignored_collections.contains(*name)),
1533                )
1534                .map_err(|(legacy, canonical)| {
1535                    page_integrity(
1536                        1,
1537                        None,
1538                        format!(
1539                            "catalog contains both legacy collection '{legacy}' and canonical collection '{canonical}'"
1540                        ),
1541                    )
1542                })?;
1543                for state in resume.collections.values() {
1544                    if !state.secondary_indices.is_empty()
1545                        && file_version < reddb_file::file_format::PAGE_SECONDARY_INDEX_FILE_VERSION
1546                    {
1547                        return Err(page_integrity(
1548                            1,
1549                            None,
1550                            "secondary roots require the file format fence",
1551                        ));
1552                    }
1553                    for root in std::iter::once(state.root_page)
1554                        .chain(state.key_index_root)
1555                        .chain(
1556                            state
1557                                .secondary_indices
1558                                .values()
1559                                .map(|index| index.root_page),
1560                        )
1561                    {
1562                        if root >= page_count {
1563                            return Err(page_integrity(
1564                                root,
1565                                None,
1566                                "published root exceeds the database page count",
1567                            ));
1568                        }
1569                    }
1570                }
1571                self.resident
1572                    .next_entity_id
1573                    .fetch_max(resume.entity_id_next, Ordering::SeqCst);
1574            }
1575            *self.recovery_xid_summary.write() = resume.as_ref().and_then(|resume| {
1576                resume.xid_high_water_mark.map(|high_water_mark| {
1577                    super::recovery_xid::RecoveryXidSummary {
1578                        high_water_mark,
1579                        roots: resume
1580                            .collections
1581                            .iter()
1582                            .filter(|(name, _)| !ignored_collections.contains(name.as_str()))
1583                            .map(|(name, state)| {
1584                                (
1585                                    reddb_engine::storage::system_collection_names::canonical_or_self(name)
1586                                        .to_owned(),
1587                                    state.root_page,
1588                                )
1589                            })
1590                            .collect(),
1591                    }
1592                })
1593            });
1594            if content.len() >= 4 {
1595                let mut pos = 0;
1596                let mut format_version = STORE_VERSION_V1;
1597
1598                let collection_count = if let Some(header) =
1599                    reddb_file::decode_native_paged_metadata_header(content)
1600                        .map_err(|err| StoreError::Serialization(err.to_string()))?
1601                {
1602                    format_version = header.format_version;
1603                    pos += reddb_file::METADATA_HEADER_BYTES;
1604                    header.collection_count as usize
1605                } else {
1606                    let count = u32::from_le_bytes([
1607                        content[pos],
1608                        content[pos + 1],
1609                        content[pos + 2],
1610                        content[pos + 3],
1611                    ]) as usize;
1612                    pos += 4;
1613                    count
1614                };
1615
1616                self.set_format_version(format_version);
1617
1618                if pos > content.len() {
1619                    return Ok(CatalogLoadOutcome::default());
1620                }
1621
1622                let mut source_names = std::collections::HashMap::<String, String>::new();
1623                let mut migrated_catalog = !outcome.collisions.is_empty();
1624                let mut empty_duplicate_roots = std::collections::HashSet::new();
1625
1626                // Read collection names and their B-tree root pages
1627                for _ in 0..collection_count {
1628                    let root = reddb_file::decode_native_paged_collection_root(content, &mut pos)
1629                        .map_err(|err| page_integrity(1, None, err.to_string()))?;
1630                    // Root page ID for this collection's B-tree
1631                    let root_page = root.root_page;
1632                    let stored_name = root.collection;
1633                    if ignored_collections.contains(&stored_name) {
1634                        let shared_physical_state = outcome
1635                            .collisions
1636                            .iter()
1637                            .find(|collision| collision.loser == stored_name)
1638                            .is_some_and(|collision| collision.shared_physical_state);
1639                        if shared_physical_state {
1640                            continue;
1641                        }
1642                        let state = resume
1643                            .as_ref()
1644                            .and_then(|resume| resume.collections.get(&stored_name))
1645                            .expect("ignored collection was proven by catalog resume");
1646                        for retired_root in std::iter::once(state.root_page)
1647                            .chain(state.key_index_root)
1648                            .chain(
1649                                state
1650                                    .secondary_indices
1651                                    .values()
1652                                    .map(|index| index.root_page),
1653                            )
1654                            .filter(|root| *root != 0)
1655                        {
1656                            empty_duplicate_roots.insert(retired_root);
1657                        }
1658                        continue;
1659                    }
1660                    let name = reddb_engine::storage::system_collection_names::canonical_or_self(
1661                        &stored_name,
1662                    )
1663                    .to_owned();
1664                    if let Some(previous) = source_names.insert(name.clone(), stored_name.clone()) {
1665                        if previous != stored_name {
1666                            return Err(page_integrity(
1667                                1,
1668                                None,
1669                                format!(
1670                                    "catalog contains both legacy collection '{previous}' and canonical collection '{name}'"
1671                                ),
1672                            ));
1673                        }
1674                    }
1675                    migrated_catalog |= name != stored_name;
1676
1677                    // Hydrate the collection in memory only. Loading must
1678                    // not emit WAL entries or rewrite the on-disk registry
1679                    // before the existing B-tree roots are attached.
1680                    let _ = self.create_collection_in_memory(&name);
1681
1682                    let counters = resume
1683                        .as_ref()
1684                        .and_then(|resume| resume.collections.get(&stored_name));
1685                    if let (Some(counters), Some(manager)) = (counters, self.get_collection(&name))
1686                    {
1687                        manager.restore_identity_counters(
1688                            counters.entity_id_next,
1689                            counters.row_id_next,
1690                        );
1691                        manager.restore_sequence_id_next(counters.sequence_id_next);
1692                    }
1693                    // Load B-tree with root page if it exists
1694                    if root_page > 0 || counters.is_some() {
1695                        let btree = BTree::with_root(Arc::clone(pager), root_page);
1696
1697                        // Load all entities from B-tree into the collection
1698                        let mut cursor = btree.cursor_first().map_err(|err| {
1699                            page_integrity(root_page, Some(name.clone()), err.to_string())
1700                        })?;
1701                        let manager = self.get_collection(&name);
1702                        let mut persisted_entities = counters.map_or(0, |state| state.entities);
1703                        while counters.is_none() {
1704                            let next = cursor
1705                                .next_with_budget(
1706                                    self.config
1707                                        .resident_memory_bytes_max
1708                                        .and_then(|limit| usize::try_from(limit).ok())
1709                                        .unwrap_or(usize::MAX),
1710                                )
1711                                .map_err(|err| {
1712                                    page_integrity(root_page, Some(name.clone()), err.to_string())
1713                                })?;
1714                            let Some((key, value)) = next else {
1715                                break;
1716                            };
1717                            persisted_entities += 1;
1718                            // Deserialize entity from value bytes
1719                            let key_bytes: [u8; 8] = key.as_slice().try_into().map_err(|_| {
1720                                page_integrity(
1721                                    root_page,
1722                                    Some(name.clone()),
1723                                    "physical entity key must contain eight bytes",
1724                                )
1725                            })?;
1726                            let record = reddb_engine::storage::collection_read::EncodedRecord {
1727                                id: EntityId::new(u64::from_be_bytes(key_bytes)),
1728                                bytes: value,
1729                            };
1730                            let decoded = if let Some(limit) = self.config.resident_memory_bytes_max
1731                            {
1732                                record.decode_with_budget(
1733                                    self.format_version(),
1734                                    usize::try_from(limit).unwrap_or(usize::MAX),
1735                                )
1736                            } else {
1737                                record.decode(self.format_version())
1738                            };
1739                            let (entity, metadata) = decoded.map_err(|err| {
1740                                page_integrity(root_page, Some(name.clone()), err.to_string())
1741                            })?;
1742                            if let Some(m) = &manager {
1743                                let id = entity.id;
1744                                if let EntityKind::TableRow { row_id, .. } = &entity.kind {
1745                                    m.register_row_id(*row_id);
1746                                }
1747                                if self.config.resident_memory_bytes_max.is_some() {
1748                                    self.register_entity_id(id);
1749                                    m.restore_sequence_id_next(
1750                                        entity.sequence_id.saturating_add(1),
1751                                    );
1752                                    m.restore_identity_counters(
1753                                        id.raw().saturating_add(1),
1754                                        m.identity_counters().1,
1755                                    );
1756                                    continue;
1757                                }
1758                                self.resident.context_index.index_entity(&name, &entity);
1759                                m.restore_snapshot_entity(entity.clone())?;
1760                                if let Some(metadata) = metadata {
1761                                    let _ = m.set_metadata(id, metadata);
1762                                }
1763                                self.register_entity_id(id);
1764                                if self.config.auto_index_refs {
1765                                    self.index_cross_refs(&entity, &name)?;
1766                                }
1767                            }
1768                        }
1769
1770                        // Store the B-tree for future lookups
1771                        let btree = Arc::new(btree);
1772                        let key_tree = counters
1773                            .and_then(|state| state.key_index_root)
1774                            .map(|root| Arc::new(BTree::with_root(Arc::clone(pager), root)));
1775                        if let Some(counters) = counters {
1776                            self.key_indices.write().insert(
1777                                name.clone(),
1778                                PublishedKeyIndex {
1779                                    indexed: key_tree.is_some(),
1780                                    secondary: counters.secondary_indices.iter().map(|(name, index)| {
1781                                        (name.clone(), super::secondary_index::PublishedSecondaryIndex {
1782                                            identity: super::secondary_index::SecondaryIndexIdentity {
1783                                                codec_version: index.codec_version,
1784                                                definition: index.definition,
1785                                            },
1786                                            tree: Arc::new(BTree::with_root(Arc::clone(pager), index.root_page)),
1787                                        })
1788                                    }).collect(),
1789                                    source: Arc::downgrade(&btree),
1790                                    tree: key_tree.clone().unwrap_or_else(|| {
1791                                        Arc::new(BTree::with_root(Arc::clone(pager), 0))
1792                                    }),
1793                                },
1794                            );
1795                        }
1796                        if let (Some(manager), Some(limit)) =
1797                            (&manager, self.config.resident_memory_bytes_max)
1798                        {
1799                            manager.install_backing(
1800                                Arc::new(
1801                                    match key_tree {
1802                                        Some(keys) => reddb_engine::storage::collection_read::PagedCollection::with_key_index(Arc::clone(&btree), keys),
1803                                        None => reddb_engine::storage::collection_read::PagedCollection::new(Arc::clone(&btree)),
1804                                    },
1805                                ),
1806                                btree.root_page_id(),
1807                                format_version,
1808                                usize::try_from(limit).unwrap_or(usize::MAX),
1809                                persisted_entities,
1810                            );
1811                        }
1812                        self.btree_indices.write().insert(name.clone(), btree);
1813                        if format_version == STORE_VERSION_V11 {
1814                            if let Some(manager) = &manager {
1815                                self.checkpoint_versions.write().insert(
1816                                    name,
1817                                    CheckpointVersion {
1818                                        manager: Arc::downgrade(manager),
1819                                        segments: manager.checkpoint_revisions(),
1820                                        visibility_revision: 0,
1821                                        untracked_visibility_revision: 0,
1822                                        excluded_xids: Arc::default(),
1823                                        pending_vault: Arc::default(),
1824                                        format_version,
1825                                        deferred_rows: Some(Arc::default()),
1826                                        has_unprojected_rows: false,
1827                                    },
1828                                );
1829                            }
1830                        }
1831                    }
1832                }
1833
1834                if format_version >= STORE_VERSION_V2 && pos + 4 <= content.len() {
1835                    let cross_ref_count = u32::from_le_bytes([
1836                        content[pos],
1837                        content[pos + 1],
1838                        content[pos + 2],
1839                        content[pos + 3],
1840                    ]) as usize;
1841                    pos += 4;
1842
1843                    for _ in 0..cross_ref_count {
1844                        let Ok(cross_ref) =
1845                            reddb_file::decode_native_paged_cross_ref(content, &mut pos)
1846                        else {
1847                            break;
1848                        };
1849                        let source_id = EntityId::new(cross_ref.source_id);
1850                        let target_id = EntityId::new(cross_ref.target_id);
1851                        let ref_type = RefType::from_byte(cross_ref.ref_type);
1852                        let target_collection =
1853                            reddb_engine::storage::system_collection_names::canonical_owned(
1854                                cross_ref.target_collection,
1855                            );
1856
1857                        self.resident
1858                            .cross_refs
1859                            .write()
1860                            .entry(source_id)
1861                            .or_default()
1862                            .push((target_id, ref_type, target_collection.clone()));
1863
1864                        if let Some((collection, mut entity)) = self.get_any(source_id) {
1865                            let exists = entity.cross_refs().iter().any(|xref| {
1866                                xref.target == target_id
1867                                    && xref.ref_type == ref_type
1868                                    && xref.target_collection == target_collection
1869                            });
1870                            if !exists {
1871                                entity.cross_refs_mut().push(CrossRef::new(
1872                                    source_id,
1873                                    target_id,
1874                                    target_collection.clone(),
1875                                    ref_type,
1876                                ));
1877                                if let Some(manager) = self.get_collection(&collection) {
1878                                    let _ = manager.update(entity);
1879                                }
1880                            }
1881                        }
1882                    }
1883                }
1884                let retired = reddb_file::decode_native_paged_retired_trees(content, &mut pos)
1885                    .map_err(|e| StoreError::Serialization(e.to_string()))?;
1886                let already_retired = retired
1887                    .iter()
1888                    .copied()
1889                    .collect::<std::collections::HashSet<_>>();
1890                let mut live_roots: std::collections::HashSet<_> = self
1891                    .btree_indices
1892                    .read()
1893                    .values()
1894                    .map(|tree| tree.root_page_id())
1895                    .collect();
1896                live_roots.extend(
1897                    self.key_indices
1898                        .read()
1899                        .values()
1900                        .flat_map(PublishedKeyIndex::trees)
1901                        .map(|tree| tree.root_page_id()),
1902                );
1903                for root in retired {
1904                    if live_roots.contains(&root)
1905                        || root
1906                            >= pager
1907                                .page_count()
1908                                .map_err(|e| StoreError::Io(std::io::Error::other(e.to_string())))?
1909                    {
1910                        return Err(page_integrity(
1911                            root,
1912                            None,
1913                            "retired root aliases a live or missing page",
1914                        ));
1915                    }
1916                    self.retired_btrees.lock().push(RetiredBTree {
1917                        tree: Arc::new(BTree::with_root(Arc::clone(pager), root)),
1918                        published: true,
1919                    });
1920                }
1921                for root in empty_duplicate_roots
1922                    .iter()
1923                    .copied()
1924                    .filter(|root| !already_retired.contains(root))
1925                {
1926                    self.retire_btree(Arc::new(BTree::with_root(Arc::clone(pager), root)));
1927                }
1928                let deferred = reddb_file::decode_native_paged_deferred_pages(content, &mut pos)
1929                    .map_err(|e| StoreError::Serialization(e.to_string()))?;
1930                if deferred
1931                    .iter()
1932                    .any(|id| live_roots.contains(id) || empty_duplicate_roots.contains(id))
1933                {
1934                    return Err(page_integrity(1, None, "deferred page aliases a live root"));
1935                }
1936                pager
1937                    .restore_deferred_reclamation(&deferred)
1938                    .map_err(|e| StoreError::Io(std::io::Error::other(e.to_string())))?;
1939                let ownership_epoch =
1940                    reddb_file::native_store::decode_native_paged_ownership_epoch(
1941                        content, &mut pos,
1942                    )
1943                    .map_err(|e| StoreError::Serialization(e.to_string()))?;
1944                self.ownership_catalog_epoch
1945                    .store(ownership_epoch.unwrap_or(0), Ordering::Release);
1946                if migrated_catalog {
1947                    self.mark_paged_registry_dirty();
1948                }
1949                if self.format_version() < STORE_VERSION_V11 {
1950                    self.set_format_version(STORE_VERSION_V11);
1951                }
1952                outcome.migrated = migrated_catalog;
1953                return Ok(outcome);
1954            }
1955        }
1956
1957        if self.format_version() < STORE_VERSION_V11 {
1958            self.set_format_version(STORE_VERSION_V11);
1959        }
1960
1961        Ok(CatalogLoadOutcome::default())
1962    }
1963
1964    /// Deserialize an entity from binary bytes
1965    pub(crate) fn deserialize_entity(
1966        data: &[u8],
1967        format_version: u32,
1968    ) -> Result<UnifiedEntity, StoreError> {
1969        let mut pos = 0;
1970        Self::read_entity_binary(data, &mut pos, format_version)
1971            .map_err(|e| StoreError::Serialization(e.to_string()))
1972    }
1973
1974    /// Serialize an entity to binary bytes
1975    pub(crate) fn serialize_entity(entity: &UnifiedEntity, format_version: u32) -> Vec<u8> {
1976        // Pre-allocate ~256 bytes to cover the typical 15-column
1977        // typed row without any Vec growth. Bulk insert calls this
1978        // millions of times per bench run; saving 2-3 reallocs per
1979        // entity amortises.
1980        let mut buf = Vec::with_capacity(256);
1981        Self::write_entity_binary(&mut buf, entity, format_version);
1982        buf
1983    }
1984
1985    pub(crate) fn serialize_entity_record(
1986        entity: &UnifiedEntity,
1987        metadata: Option<&Metadata>,
1988        format_version: u32,
1989    ) -> Vec<u8> {
1990        reddb_engine::storage::metadata_codec::serialize_entity_record(
1991            entity,
1992            metadata,
1993            format_version,
1994        )
1995    }
1996
1997    pub(crate) fn deserialize_entity_record(
1998        data: &[u8],
1999        format_version: u32,
2000    ) -> Result<(UnifiedEntity, Option<Metadata>), StoreError> {
2001        reddb_engine::storage::metadata_codec::deserialize_entity_record(data, format_version)
2002            .map_err(StoreError::Serialization)
2003    }
2004
2005    /// Persist all data to page-based storage
2006    ///
2007    /// Writes all entities to B-tree pages and flushes to disk.
2008    /// This provides ACID durability guarantees.
2009    pub fn persist(&self) -> Result<(), StoreError> {
2010        let _vault_guard = self.vault_publication_lock.lock();
2011        let pending_vault_versions = self.pending_vault_versions.read();
2012        let pager = match &self.pager {
2013            Some(p) => p,
2014            None => {
2015                // No pager attached - use binary file fallback if path available
2016                if let Some(path) = &self.db_path {
2017                    return self
2018                        .save_to_file(path)
2019                        .map_err(|e| StoreError::Serialization(e.to_string()));
2020                }
2021                return Err(StoreError::Io(std::io::Error::other(
2022                    "No pager or path configured for persistence",
2023                )));
2024            }
2025        };
2026        // Prevent unrelated allocations or frees from being attributed to
2027        // this checkpoint's ownership epoch.
2028        let _ownership_guard = pager.lock_ownership_mutations();
2029
2030        match pager.read_page(1) {
2031            Ok(_) => {}
2032            Err(PagerError::PageNotFound(_)) => {
2033                let meta_page = pager
2034                    .allocate_page(crate::storage::engine::PageType::Header)
2035                    .map_err(|e| StoreError::Io(std::io::Error::other(e.to_string())))?;
2036                pager
2037                    .write_page(meta_page.page_id(), meta_page)
2038                    .map_err(|e| StoreError::Io(std::io::Error::other(e.to_string())))?;
2039            }
2040            Err(e) => {
2041                return Err(StoreError::Io(std::io::Error::other(e.to_string())));
2042            }
2043        }
2044
2045        if let Some(commit) = &self.commit {
2046            commit.force_sync().map_err(StoreError::Io)?;
2047        }
2048
2049        struct InterruptedOwnership<'a> {
2050            pager: &'a Pager,
2051            active: bool,
2052        }
2053        impl Drop for InterruptedOwnership<'_> {
2054            fn drop(&mut self) {
2055                if self.active {
2056                    self.pager.fence_interrupted_ownership();
2057                }
2058            }
2059        }
2060        let mut ownership = InterruptedOwnership {
2061            pager,
2062            active: self.commit.is_some(),
2063        };
2064        let epoch = if let Some(commit) = &self.commit {
2065            let epoch = commit
2066                .next_page_ownership_epoch(self)
2067                .map_err(StoreError::Io)?;
2068            commit
2069                .begin_page_ownership_epoch(self, epoch)
2070                .map_err(StoreError::Io)?;
2071            Some(epoch)
2072        } else {
2073            None
2074        };
2075
2076        let collections = self.resident.collections.read();
2077        let mut btree_indices = self.btree_indices.write();
2078
2079        // Collect collection names and their B-tree root pages
2080        let mut collection_roots: Vec<(String, u32)> = Vec::new();
2081
2082        // Build unpublished replacements. Deleting from the current trees here
2083        // would destroy the last checkpoint if a write fails before publication.
2084        let mut rebuilt_indices = HashMap::with_capacity(collections.len());
2085        let previous_versions = self.checkpoint_versions.read();
2086        let mut checkpoint_versions = HashMap::with_capacity(collections.len());
2087        let pending_vault = Arc::new(pending_vault_versions.clone());
2088        let (visibility_revision, excluded, untracked_visibility_revision) =
2089            self.snapshot_manager.get().map_or_else(
2090                || (0, Default::default(), 0),
2091                |manager| manager.checkpoint_projection_state(),
2092            );
2093        let excluded = Arc::new(excluded);
2094        for (name, manager) in collections.iter() {
2095            let mut version = CheckpointVersion {
2096                manager: Arc::downgrade(manager),
2097                segments: manager.checkpoint_revisions(),
2098                visibility_revision,
2099                untracked_visibility_revision,
2100                excluded_xids: Arc::clone(&excluded),
2101                pending_vault: Arc::clone(&pending_vault),
2102                format_version: self.format_version(),
2103                deferred_rows: Some(Arc::default()),
2104                has_unprojected_rows: false,
2105            };
2106            if let (Some(previous), Some(tree)) =
2107                (previous_versions.get(name), btree_indices.get(name))
2108            {
2109                if previous.manager.ptr_eq(&version.manager)
2110                    && previous.segments == version.segments
2111                    && previous.format_version == version.format_version
2112                    && !previous.has_unprojected_rows
2113                    && checkpoint_visibility_matches(previous, &version)
2114                {
2115                    version.deferred_rows = previous.deferred_rows.clone();
2116                    collection_roots.push((name.clone(), tree.root_page_id()));
2117                    rebuilt_indices.insert(name.clone(), Arc::clone(tree));
2118                    checkpoint_versions.insert(name.clone(), version);
2119                    continue;
2120                }
2121            }
2122            if let (Some(previous), Some(tree)) =
2123                (previous_versions.get(name), btree_indices.get(name))
2124            {
2125                if previous.manager.ptr_eq(&version.manager)
2126                    && previous.format_version == version.format_version
2127                {
2128                    if let Some(tree) =
2129                        self.sparse_checkpoint_tree(name, manager, tree, previous, &mut version)?
2130                    {
2131                        collection_roots.push((name.clone(), tree.root_page_id()));
2132                        rebuilt_indices.insert(name.clone(), tree);
2133                        checkpoint_versions.insert(name.clone(), version);
2134                        continue;
2135                    }
2136                }
2137            }
2138            let btree = self.checkpoint_tree(
2139                pager,
2140                manager,
2141                btree_indices.get(name),
2142                |entity, metadata| {
2143                    #[cfg(test)]
2144                    CHECKPOINT_SERIALIZED_ROWS.with(|count| count.set(count.get() + 1));
2145                    let hidden = pending_vault_versions.contains(&entity.id)
2146                        || (matches!(
2147                            entity.data,
2148                            EntityData::Row(_)
2149                                | EntityData::Vector(_)
2150                                | EntityData::QueueMessage(_)
2151                        ) && excluded.contains(&entity.xmin));
2152                    let normalize_xmax = matches!(
2153                        entity.data,
2154                        EntityData::Row(_) | EntityData::QueueMessage(_)
2155                    ) && excluded.contains(&entity.xmax);
2156                    if hidden || normalize_xmax {
2157                        version.remember_deferred_row(entity.id);
2158                    }
2159                    if hidden {
2160                        return None;
2161                    }
2162                    let mut normalized = std::borrow::Cow::Borrowed(entity);
2163                    if normalize_xmax {
2164                        normalized.to_mut().xmax = 0;
2165                    }
2166                    Some(Self::serialize_entity_record(
2167                        &normalized,
2168                        metadata.as_ref(),
2169                        self.format_version(),
2170                    ))
2171                },
2172            )?;
2173            checkpoint_versions.insert(name.clone(), version);
2174
2175            collection_roots.push((name.clone(), btree.root_page_id()));
2176            rebuilt_indices.insert(name.clone(), btree);
2177        }
2178
2179        // Write collection metadata to page 1
2180        let mut meta_data = Vec::with_capacity(4096);
2181
2182        let format_version = STORE_VERSION_V11;
2183        self.set_format_version(format_version);
2184
2185        reddb_file::encode_native_paged_metadata_header(
2186            &mut meta_data,
2187            reddb_file::NativePagedMetadataHeader {
2188                format_version,
2189                collection_count: collection_roots.len() as u32,
2190            },
2191        );
2192
2193        // Write each collection's name and B-tree root page
2194        for (name, root_page) in &collection_roots {
2195            reddb_file::encode_native_paged_collection_root(&mut meta_data, name, *root_page);
2196        }
2197
2198        // Write cross-reference metadata
2199        let cross_refs = self.resident.cross_refs.read();
2200        let total_refs: usize = cross_refs.values().map(|v| v.len()).sum();
2201        meta_data.extend_from_slice(&(total_refs as u32).to_le_bytes());
2202        for (source_id, refs) in cross_refs.iter() {
2203            for (target_id, ref_type, collection) in refs {
2204                reddb_file::encode_native_paged_cross_ref(
2205                    &mut meta_data,
2206                    source_id.raw(),
2207                    target_id.raw(),
2208                    ref_type.to_byte(),
2209                    collection,
2210                );
2211            }
2212        }
2213
2214        for (name, previous) in btree_indices.iter() {
2215            if !rebuilt_indices
2216                .get(name)
2217                .is_some_and(|tree| Arc::ptr_eq(tree, previous))
2218            {
2219                self.retire_btree(Arc::clone(previous));
2220            }
2221        }
2222        #[cfg(test)]
2223        if std::env::var_os("REDDB_EXPERIMENTAL_EXIT_DURING_CHECKPOINT_REBUILD").is_some() {
2224            pager
2225                .sync()
2226                .map_err(|error| StoreError::Io(std::io::Error::other(error.to_string())))?;
2227            eprintln!("experimental checkpoint exit before manifest");
2228            std::process::exit(183);
2229        }
2230        let rebuilt_keys = self.checkpoint_key_indices(pager, &rebuilt_indices)?;
2231        #[cfg(test)]
2232        if std::env::var_os("REDDB_TEST_EXIT_AFTER_SECONDARY_INDEX_BUILD").is_some() {
2233            pager
2234                .sync()
2235                .map_err(|error| StoreError::Io(std::io::Error::other(error.to_string())))?;
2236            std::process::exit(196);
2237        }
2238        self.publish_reclaimable_trees(pager, &mut meta_data, &rebuilt_indices, &rebuilt_keys)?;
2239
2240        #[cfg(test)]
2241        if std::env::var_os("REDDB_TEST_EXIT_AFTER_UNDO_CATALOG_PUBLICATION").is_some() {
2242            std::process::exit(195);
2243        }
2244
2245        // Only the now-durable catalog may replace the live tree handles. The
2246        // previous generation is retained in the manifest retirement ledger.
2247        *btree_indices = rebuilt_indices;
2248        *self.key_indices.write() = rebuilt_keys;
2249        drop(previous_versions);
2250        *self.checkpoint_versions.write() = checkpoint_versions;
2251
2252        if let Some(commit) = &self.commit {
2253            commit
2254                .finish_page_ownership_epoch(self, epoch.expect("WAL ownership epoch"))
2255                .map_err(StoreError::Io)?;
2256            ownership.active = false;
2257            commit.truncate().map_err(StoreError::Io)?;
2258        }
2259        *self.checkpoint_durable_rows.write() = Default::default();
2260
2261        if let Some(limit) = self.config.resident_memory_bytes_max {
2262            let mut versions = self.checkpoint_versions.write();
2263            for (name, manager) in collections.iter() {
2264                if versions
2265                    .get(name)
2266                    .is_some_and(|version| !version.has_unprojected_rows)
2267                {
2268                    if let Some(tree) = btree_indices.get(name) {
2269                        manager.install_backing(
2270                            Arc::new(
2271                                {
2272                                    let keys = self.key_indices.read();
2273                                    let index = keys.get(name).expect("invariant: prepared checkpoint classifies its key index");
2274                                    if index.indexed {
2275                                        reddb_engine::storage::collection_read::PagedCollection::with_key_index(Arc::clone(tree), Arc::clone(&index.tree))
2276                                    } else {
2277                                        reddb_engine::storage::collection_read::PagedCollection::new(Arc::clone(tree))
2278                                    }
2279                                },
2280                            ),
2281                            tree.root_page_id(),
2282                            self.format_version(),
2283                            usize::try_from(limit).unwrap_or(usize::MAX),
2284                            manager.count() as u64,
2285                        );
2286                        if let Some(version) = versions.get_mut(name) {
2287                            version.segments = manager.checkpoint_revisions();
2288                        }
2289                    }
2290                }
2291            }
2292        }
2293
2294        Ok(())
2295    }
2296
2297    /// Check if the store is using page-based persistence
2298    pub fn is_paged(&self) -> bool {
2299        self.pager.is_some()
2300    }
2301
2302    /// Current root page for a collection's primary B-tree, if one has
2303    /// been materialized in this store.
2304    pub(crate) fn collection_root_page(&self, collection: &str) -> Option<u32> {
2305        self.btree_indices
2306            .read()
2307            .get(Self::canonical_collection(collection))
2308            .map(|btree| btree.root_page_id())
2309            .filter(|root| *root != 0)
2310    }
2311
2312    /// Get the database file path (if using paged mode)
2313    pub fn db_path(&self) -> Option<&Path> {
2314        self.db_path.as_deref()
2315    }
2316}
2317
2318#[cfg(test)]
2319mod checkpoint_work_tests {
2320    use super::*;
2321
2322    struct SystemNameProjection;
2323
2324    impl super::super::secondary_index::SecondaryIndexProjector for SystemNameProjection {
2325        fn project(
2326            &self,
2327            entity: &UnifiedEntity,
2328            key: &mut [u8; super::super::secondary_index::SECONDARY_KEY_BYTES_MAX],
2329        ) -> Result<Option<usize>, StoreError> {
2330            key[..8].copy_from_slice(&entity.id.raw().to_be_bytes());
2331            Ok(Some(8))
2332        }
2333    }
2334
2335    #[cfg(target_os = "linux")]
2336    #[test]
2337    fn interrupted_inline_sparse_checkpoint_replays_durable_rows() {
2338        const CHILD: &str = "REDDB_TEST_INLINE_SPARSE_PATH";
2339        if let Some(path) = std::env::var_os(CHILD) {
2340            let insert = std::env::var_os("REDDB_TEST_INLINE_SPARSE_INSERT").is_some();
2341            let multi = std::env::var_os("REDDB_TEST_INLINE_SPARSE_MULTI").is_some();
2342            let store = UnifiedStore::open(path).expect("open store");
2343            let initial_count = if multi { 600 } else { 1 };
2344            for id in 1..=initial_count {
2345                store.insert_auto("rows", row(id, 1)).expect("seed");
2346            }
2347            store.persist().expect("baseline");
2348            let manager = store.get_collection("rows").expect("collection");
2349            let ids: Vec<u64> = if multi {
2350                (1..=initial_count).collect()
2351            } else {
2352                vec![if insert { 2 } else { 1 }]
2353            };
2354            let mut actions = Vec::new();
2355            for id in ids {
2356                if insert {
2357                    manager.insert(row(id, 9)).expect("insert version");
2358                } else {
2359                    manager.update(row(id, 9)).expect("update version");
2360                }
2361                let entity = manager.get(EntityId::new(id)).expect("changed entity");
2362                actions.push(StoreWalAction::upsert_entity(
2363                    "rows",
2364                    &entity,
2365                    None,
2366                    store.format_version(),
2367                ));
2368            }
2369            let references = store
2370                .commit
2371                .as_ref()
2372                .expect("coordinator")
2373                .append_checkpoint_actions(&actions)
2374                .expect("durable WAL");
2375            store.remember_checkpoint_rows(actions, references);
2376            store.persist().expect("exit after sparse write");
2377            panic!("inline sparse crash hook was not reached");
2378        }
2379        for (insert, multi) in [(false, false), (true, false), (false, true)] {
2380            let directory = tempfile::tempdir().expect("temporary store");
2381            let path = directory.path().join("data.rdb");
2382            let mut command =
2383                std::process::Command::new(std::env::current_exe().expect("test executable"));
2384            command
2385                .args(["--exact", "storage::unified::store::impl_pages::checkpoint_work_tests::interrupted_inline_sparse_checkpoint_replays_durable_rows", "--nocapture"])
2386                .env(CHILD, &path)
2387                .env("REDDB_TEST_EXIT_AFTER_INLINE_SPARSE_WRITE", "1");
2388            if insert {
2389                command.env("REDDB_TEST_INLINE_SPARSE_INSERT", "1");
2390            }
2391            if multi {
2392                command.env("REDDB_TEST_INLINE_SPARSE_MULTI", "1");
2393            }
2394            let output = command.output().expect("crash child");
2395            assert_eq!(
2396                output.status.code(),
2397                Some(190),
2398                "insert={insert} multi={multi}: {}",
2399                String::from_utf8_lossy(&output.stderr)
2400            );
2401            let recovered = UnifiedStore::open(&path).expect("recover");
2402            let ids: Vec<u64> = if multi {
2403                (1..=600).collect()
2404            } else {
2405                vec![if insert { 2 } else { 1 }]
2406            };
2407            for id in ids {
2408                assert_eq!(
2409                    recovered
2410                        .get("rows", EntityId::new(id))
2411                        .expect("recovered row")
2412                        .data
2413                        .as_row()
2414                        .expect("row")
2415                        .columns,
2416                    vec![Value::Integer(9)]
2417                );
2418            }
2419            assert_eq!(
2420                recovered.get_collection("rows").expect("rows").count(),
2421                if multi {
2422                    600
2423                } else if insert {
2424                    2
2425                } else {
2426                    1
2427                }
2428            );
2429        }
2430    }
2431
2432    #[test]
2433    fn catalog_ownership_epoch_survives_reopen_and_later_publication() {
2434        let directory = tempfile::tempdir().expect("temporary store");
2435        let path = directory.path().join("data.rdb");
2436        {
2437            let store = UnifiedStore::open(&path).expect("open store");
2438            store.create_collection("rows").expect("create collection");
2439            store.persist().expect("publish ownership epoch");
2440            assert_eq!(store.ownership_catalog_epoch.load(Ordering::Acquire), 1);
2441            store.persist().expect("publish next ownership epoch");
2442        }
2443        let reopened = UnifiedStore::open(&path).expect("reopen store");
2444        assert_eq!(reopened.ownership_catalog_epoch.load(Ordering::Acquire), 2);
2445    }
2446
2447    #[test]
2448    fn metadata_overflow_with_missing_continuation_fails_closed() {
2449        use crate::storage::engine::{Page, PageType, HEADER_SIZE};
2450
2451        let directory = tempfile::tempdir().expect("temporary store");
2452        let store = UnifiedStore::open(directory.path().join("data.rdb")).expect("paged store");
2453        let pager = store.pager.as_ref().unwrap();
2454        let valid_payload = vec![42; META_PAGE_CONTENT_CAP * 3];
2455        let valid_manifest = build_meta_page1_with_overflow(pager, &valid_payload).unwrap();
2456        pager.write_page(1, valid_manifest).unwrap();
2457        assert!(!existing_overflow_chain(pager).unwrap().is_empty());
2458        assert_eq!(read_meta_payload(pager).unwrap().unwrap(), valid_payload);
2459
2460        let missing = pager.page_count().unwrap() + 100;
2461        let mut manifest = Page::new(PageType::Header, 1);
2462        reddb_file::encode_native_metadata_overflow_header(
2463            &mut manifest.as_bytes_mut()[HEADER_SIZE..],
2464            reddb_file::NativeMetadataOverflowHeader {
2465                format_version: STORE_VERSION_V11,
2466                total_payload_bytes: META_PAGE_CONTENT_CAP as u32 + 1,
2467                next_overflow_page_id: missing,
2468            },
2469        )
2470        .unwrap();
2471        pager.write_page(1, manifest).unwrap();
2472
2473        assert!(
2474            existing_overflow_chain(pager).is_err(),
2475            "a missing continuation must not be treated as an empty chain"
2476        );
2477
2478        let wrong_type = pager.allocate_page(PageType::BTreeLeaf).unwrap();
2479        let mut manifest = Page::new(PageType::Header, 1);
2480        reddb_file::encode_native_metadata_overflow_header(
2481            &mut manifest.as_bytes_mut()[HEADER_SIZE..],
2482            reddb_file::NativeMetadataOverflowHeader {
2483                format_version: STORE_VERSION_V11,
2484                total_payload_bytes: META_PAGE_CONTENT_CAP as u32 + 1,
2485                next_overflow_page_id: wrong_type.page_id(),
2486            },
2487        )
2488        .unwrap();
2489        pager.write_page(1, manifest).unwrap();
2490        assert!(
2491            existing_overflow_chain(pager).is_err(),
2492            "a live page must not be mistaken for metadata overflow"
2493        );
2494        assert!(
2495            read_meta_payload(pager).is_err(),
2496            "a live page must not be read as metadata overflow"
2497        );
2498
2499        let mut cycle = pager.allocate_page(PageType::Overflow).unwrap();
2500        let cycle_id = cycle.page_id();
2501        reddb_file::encode_native_metadata_overflow_continuation_header(
2502            &mut cycle.as_bytes_mut()[HEADER_SIZE..],
2503            reddb_file::NativeMetadataOverflowContinuationHeader {
2504                next_overflow_page_id: cycle_id,
2505                chunk_bytes: 1,
2506            },
2507        )
2508        .unwrap();
2509        pager.write_page(cycle_id, cycle).unwrap();
2510        let mut manifest = Page::new(PageType::Header, 1);
2511        reddb_file::encode_native_metadata_overflow_header(
2512            &mut manifest.as_bytes_mut()[HEADER_SIZE..],
2513            reddb_file::NativeMetadataOverflowHeader {
2514                format_version: STORE_VERSION_V11,
2515                total_payload_bytes: META_V3_FIRST_PAYLOAD_CAP as u32 + 3,
2516                next_overflow_page_id: cycle_id,
2517            },
2518        )
2519        .unwrap();
2520        pager.write_page(1, manifest).unwrap();
2521        assert!(
2522            read_meta_payload(pager).is_err(),
2523            "cyclic overflow must not be accepted"
2524        );
2525
2526        let mut manifest = Page::new(PageType::Header, 1);
2527        reddb_file::encode_native_metadata_overflow_header(
2528            &mut manifest.as_bytes_mut()[HEADER_SIZE..],
2529            reddb_file::NativeMetadataOverflowHeader {
2530                format_version: STORE_VERSION_V11,
2531                total_payload_bytes: META_PAGE_CONTENT_CAP as u32 + 1,
2532                next_overflow_page_id: 0,
2533            },
2534        )
2535        .unwrap();
2536        pager.write_page(1, manifest).unwrap();
2537        assert!(
2538            read_meta_payload(pager).is_err(),
2539            "a truncated metadata payload must not be accepted"
2540        );
2541    }
2542
2543    #[test]
2544    fn retired_tree_pages_remain_pinned_by_a_cursor() {
2545        let directory = tempfile::tempdir().expect("temporary store");
2546        let store = UnifiedStore::open(&directory.path().join("data.rdb")).expect("paged store");
2547        for id in 1..=100 {
2548            store.insert_auto("rows", row(id, 1)).expect("seed");
2549        }
2550        store.persist().expect("initial checkpoint");
2551        let tree = store
2552            .btree_indices
2553            .read()
2554            .get("rows")
2555            .cloned()
2556            .expect("tree");
2557        let mut cursor = tree.cursor_first().expect("cursor");
2558        drop(tree);
2559        let manager = store.get_collection("rows").expect("rows");
2560        for value in 2..=6 {
2561            manager.update(row(1, value)).expect("change");
2562            store.persist().expect("checkpoint with old reader");
2563        }
2564        let mut count = 0;
2565        while let Some((_, bytes)) = cursor.next().expect("pinned tree must stay readable") {
2566            let (entity, _) =
2567                UnifiedStore::deserialize_entity_record(&bytes, store.format_version())
2568                    .expect("decode");
2569            assert_eq!(
2570                entity.data.as_row().expect("row").columns,
2571                [Value::Integer(1)]
2572            );
2573            count += 1;
2574        }
2575        assert_eq!(count, 100);
2576        drop(cursor);
2577        store.persist().expect("reclaim after reader release");
2578        assert!(store.retired_btrees.lock().is_empty());
2579    }
2580
2581    #[test]
2582    fn checkpoint_payload_buffer_is_bounded_for_a_large_collection() {
2583        let directory = tempfile::tempdir().expect("temporary store");
2584        let store = UnifiedStore::open(&directory.path().join("data.rdb")).expect("paged store");
2585        store.create_collection("rows").expect("collection");
2586        let manager = store.get_collection("rows").expect("rows");
2587        for id in (1..=1200).rev() {
2588            manager
2589                .insert(UnifiedEntity::table_row(
2590                    EntityId::new(id),
2591                    "rows",
2592                    id,
2593                    vec![Value::text("x".repeat(4096))],
2594                ))
2595                .expect("seed");
2596        }
2597        CHECKPOINT_BUFFERED_BYTES.with(|peak| peak.set(0));
2598        store.persist().expect("bounded checkpoint");
2599        assert!(CHECKPOINT_BUFFERED_BYTES.with(|peak| peak.get()) <= CHECKPOINT_BATCH_BYTES);
2600        assert!(CHECKPOINT_BUFFERED_BYTES.with(|peak| peak.get()) > 0);
2601        assert_eq!(store.get_collection("rows").expect("rows").count(), 1200);
2602    }
2603
2604    #[test]
2605    fn unchanged_checkpoint_does_not_serialize_collection_rows() {
2606        let directory = tempfile::tempdir().expect("temporary store");
2607        let store = UnifiedStore::open(&directory.path().join("data.rdb")).expect("paged store");
2608        store.create_collection("rows").expect("collection");
2609        for id in 1..=40 {
2610            store
2611                .insert(
2612                    "rows",
2613                    UnifiedEntity::table_row(
2614                        EntityId::new(id),
2615                        "rows",
2616                        id,
2617                        vec![Value::Integer(id as i64)],
2618                    ),
2619                )
2620                .expect("seed row");
2621        }
2622        store.persist().expect("initial checkpoint");
2623        CHECKPOINT_SERIALIZED_ROWS.with(|count| count.set(0));
2624        store.persist().expect("unchanged checkpoint");
2625        assert_eq!(
2626            CHECKPOINT_SERIALIZED_ROWS.with(|count| count.get()),
2627            0,
2628            "unchanged collections must not hold the writer fence while serializing every row"
2629        );
2630        drop(store);
2631        let reopened = UnifiedStore::open(&directory.path().join("data.rdb")).expect("reopen");
2632        CHECKPOINT_SERIALIZED_ROWS.with(|count| count.set(0));
2633        reopened.persist().expect("unchanged recovered generation");
2634        assert_eq!(CHECKPOINT_SERIALIZED_ROWS.with(|count| count.get()), 0);
2635    }
2636
2637    #[test]
2638    fn sparse_wal_replay_does_not_reencode_complete_collections() {
2639        let directory = tempfile::tempdir().expect("temporary store");
2640        let path = directory.path().join("data.rdb");
2641        let store = UnifiedStore::open(&path).expect("paged store");
2642        for (name, first) in [("rows", 1), ("cold", 129)] {
2643            store.create_collection(name).expect("collection");
2644            for id in first..first + 128 {
2645                store
2646                    .insert(
2647                        name,
2648                        UnifiedEntity::table_row(
2649                            EntityId::new(id),
2650                            name,
2651                            id,
2652                            vec![Value::text(format!("{id}:{}", "x".repeat(8192)))],
2653                        ),
2654                    )
2655                    .expect("seed");
2656            }
2657        }
2658        store.persist().expect("durable baseline");
2659        let action = super::super::commit::StoreWalAction::upsert_entity(
2660            "rows",
2661            &row(1, 42),
2662            None,
2663            STORE_VERSION_V11,
2664        );
2665        // The acknowledged WAL image has not reached its home B-tree yet.
2666        store
2667            .commit
2668            .as_ref()
2669            .unwrap()
2670            .append_actions(&[action])
2671            .unwrap();
2672        drop(store);
2673
2674        REPLAY_SERIALIZED_ROWS.with(|count| count.set(0));
2675        let recovered = UnifiedStore::open(&path).expect("replay committed change");
2676        assert_eq!(
2677            recovered
2678                .get("rows", EntityId::new(1))
2679                .unwrap()
2680                .data
2681                .as_row()
2682                .unwrap()
2683                .columns,
2684            vec![Value::Integer(42)]
2685        );
2686        for name in ["rows", "cold"] {
2687            assert_eq!(recovered.get_collection(name).unwrap().count(), 128);
2688        }
2689        assert_eq!(
2690            REPLAY_SERIALIZED_ROWS.with(|count| count.get()),
2691            0,
2692            "one WAL-backed row must not reencode its whole collection or unrelated cold rows"
2693        );
2694        for (name, first) in [("rows", 2), ("cold", 129)] {
2695            let end = if name == "rows" { 129 } else { 257 };
2696            for id in first..end {
2697                assert_eq!(
2698                    recovered
2699                        .get(name, EntityId::new(id))
2700                        .unwrap()
2701                        .data
2702                        .as_row()
2703                        .unwrap()
2704                        .columns,
2705                    vec![Value::text(format!("{id}:{}", "x".repeat(8192)))],
2706                );
2707            }
2708        }
2709
2710        drop(recovered);
2711        let reopened = UnifiedStore::open(&path).expect("durable sparse replay");
2712        assert_eq!(
2713            reopened
2714                .get("rows", EntityId::new(1))
2715                .unwrap()
2716                .data
2717                .as_row()
2718                .unwrap()
2719                .columns,
2720            vec![Value::Integer(42)]
2721        );
2722    }
2723
2724    fn replay_test_payload(bytes: usize) -> String {
2725        let mut state = 0x1234_5678_u32;
2726        (0..bytes)
2727            .map(|_| {
2728                state ^= state << 13;
2729                state ^= state >> 17;
2730                state ^= state << 5;
2731                char::from(b'!' + (state % 90) as u8)
2732            })
2733            .collect()
2734    }
2735
2736    #[test]
2737    fn wal_replay_with_exhausted_row_proof_keeps_full_validation() {
2738        let directory = tempfile::tempdir().unwrap();
2739        let path = directory.path().join("data.rdb");
2740        let config = || UnifiedStoreConfig::default().with_page_cache_slots(4);
2741        let store = UnifiedStore::open_with_config(&path, config()).unwrap();
2742        for id in 1..=16 {
2743            store.insert_auto("rows", row(id, 1)).unwrap();
2744        }
2745        store.persist().unwrap();
2746        // Larger than the retained-image allowance, so replay must fall back.
2747        let payload = replay_test_payload(64 * 1024);
2748        let changed =
2749            UnifiedEntity::table_row(EntityId::new(1), "rows", 1, vec![Value::text(payload)]);
2750        let action = StoreWalAction::upsert_entity("rows", &changed, None, store.format_version());
2751        store
2752            .commit
2753            .as_ref()
2754            .unwrap()
2755            .append_actions(&[action])
2756            .unwrap();
2757        drop(store);
2758        REPLAY_SERIALIZED_ROWS.with(|count| count.set(0));
2759        let recovered = UnifiedStore::open_with_config(&path, config()).unwrap();
2760        assert!(REPLAY_SERIALIZED_ROWS.with(|count| count.get()) >= 16);
2761        assert_eq!(
2762            recovered
2763                .get("rows", EntityId::new(1))
2764                .unwrap()
2765                .data
2766                .as_row()
2767                .unwrap()
2768                .columns,
2769            changed.data.as_row().unwrap().columns
2770        );
2771        drop(recovered);
2772        let reopened = UnifiedStore::open_with_config(&path, config()).unwrap();
2773        assert_eq!(reopened.get_collection("rows").unwrap().count(), 16);
2774        assert_eq!(
2775            reopened
2776                .get("rows", EntityId::new(1))
2777                .unwrap()
2778                .data
2779                .as_row()
2780                .unwrap()
2781                .columns,
2782            changed.data.as_row().unwrap().columns
2783        );
2784    }
2785
2786    #[cfg(target_os = "linux")]
2787    #[test]
2788    fn interrupted_sparse_replay_preserves_rows_and_bounds_growth() {
2789        const CHILD: &str = "REDDB_TEST_SPARSE_REPLAY_PATH";
2790        if let Some(path) = std::env::var_os(CHILD) {
2791            let _store = UnifiedStore::open(path).unwrap();
2792            panic!("replay crash hook was not reached");
2793        }
2794        for overflow in [false, true] {
2795            for (hook, code) in [
2796                ("REDDB_TEST_EXIT_AFTER_SPARSE_BATCH", 193),
2797                ("REDDB_EXPERIMENTAL_EXIT_AFTER_REPLAY_PUBLICATION", 182),
2798            ] {
2799                let directory = tempfile::tempdir().unwrap();
2800                let path = directory.path().join("data.rdb");
2801                let store = UnifiedStore::open(&path).unwrap();
2802                for id in 1..=128 {
2803                    store.insert_auto("rows", row(id, 1)).unwrap();
2804                }
2805                store.persist().unwrap();
2806                let changed = if overflow {
2807                    UnifiedEntity::table_row(
2808                        EntityId::new(1),
2809                        "rows",
2810                        1,
2811                        vec![Value::text(replay_test_payload(16 * 1024))],
2812                    )
2813                } else {
2814                    row(1, 42)
2815                };
2816                let action =
2817                    StoreWalAction::upsert_entity("rows", &changed, None, store.format_version());
2818                store
2819                    .commit
2820                    .as_ref()
2821                    .unwrap()
2822                    .append_actions(&[action])
2823                    .unwrap();
2824                drop(store);
2825                let mut first_size = 0;
2826                for cycle in 0..12 {
2827                    let output = std::process::Command::new(std::env::current_exe().unwrap())
2828                        .args(["--exact", "storage::unified::store::impl_pages::checkpoint_work_tests::interrupted_sparse_replay_preserves_rows_and_bounds_growth", "--nocapture"])
2829                        .env(CHILD, &path).env(hook, "1").output().unwrap();
2830                    assert_eq!(
2831                        output.status.code(),
2832                        Some(code),
2833                        "overflow={overflow} hook={hook} cycle={cycle}: {}",
2834                        String::from_utf8_lossy(&output.stderr)
2835                    );
2836                    let size = std::fs::metadata(&path).unwrap().len();
2837                    if cycle == 0 {
2838                        first_size = size;
2839                    }
2840                    assert!(
2841                        size <= first_size + 2 * crate::storage::engine::PAGE_SIZE as u64,
2842                        "repeated recovery must reuse reservations: {first_size} -> {size}"
2843                    );
2844                }
2845                for _ in 0..2 {
2846                    let recovered = UnifiedStore::open(&path).unwrap();
2847                    assert_eq!(recovered.get_collection("rows").unwrap().count(), 128);
2848                    for id in 1..=128 {
2849                        let expected = if id == 1 { changed.clone() } else { row(id, 1) };
2850                        assert_eq!(
2851                            recovered
2852                                .get("rows", EntityId::new(id))
2853                                .unwrap()
2854                                .data
2855                                .as_row()
2856                                .unwrap()
2857                                .columns,
2858                            expected.data.as_row().unwrap().columns
2859                        );
2860                    }
2861                }
2862            }
2863        }
2864    }
2865
2866    #[test]
2867    fn paged_reopen_preserves_stored_sequence_identity() {
2868        let directory = tempfile::tempdir().expect("temporary store");
2869        let path = directory.path().join("data.rdb");
2870        let store = UnifiedStore::open(&path).expect("paged store");
2871        store.create_collection("rows").expect("collection");
2872        for id in [30, 10, 20] {
2873            store.insert("rows", row(id, id as i64)).expect("insert");
2874        }
2875        store.persist().expect("checkpoint");
2876        let sequences = [10, 20, 30].map(|id| {
2877            store
2878                .get("rows", EntityId::new(id))
2879                .expect("row")
2880                .sequence_id
2881        });
2882        drop(store);
2883        let reopened = UnifiedStore::open(&path).expect("reopen");
2884        let recovered = [10, 20, 30].map(|id| {
2885            reopened
2886                .get("rows", EntityId::new(id))
2887                .expect("row")
2888                .sequence_id
2889        });
2890        assert_eq!(
2891            recovered, sequences,
2892            "opening a checkpoint must not rewrite stored row identities"
2893        );
2894    }
2895
2896    #[test]
2897    fn auto_insert_persists_the_assigned_sequence_identity() {
2898        let directory = tempfile::tempdir().expect("temporary store");
2899        let store = UnifiedStore::open(&directory.path().join("data.rdb")).expect("paged store");
2900        for id in 1u64..=3 {
2901            store
2902                .insert_auto("rows", row(id, id as i64))
2903                .expect("insert");
2904        }
2905        let manager = store.get_collection("rows").expect("rows");
2906        let tree = store
2907            .btree_indices
2908            .read()
2909            .get("rows")
2910            .cloned()
2911            .expect("tree");
2912        for id in 1u64..=3 {
2913            let bytes = tree
2914                .get(&id.to_be_bytes())
2915                .expect("lookup")
2916                .expect("stored row");
2917            let (persisted, _) =
2918                UnifiedStore::deserialize_entity_record(&bytes, store.format_version())
2919                    .expect("decode");
2920            assert_eq!(
2921                persisted.sequence_id,
2922                manager
2923                    .get(EntityId::new(id))
2924                    .expect("live row")
2925                    .sequence_id
2926            );
2927        }
2928    }
2929
2930    #[test]
2931    fn completed_inline_insert_reuses_history_but_not_untracked_edits() {
2932        let directory = tempfile::tempdir().expect("temporary store");
2933        let path = directory.path().join("data.rdb");
2934        let store = UnifiedStore::open(&path).expect("paged store");
2935        store.insert_auto("rows", row(1, 1)).expect("seed");
2936        store.persist().expect("checkpoint");
2937        store.insert_auto("rows", row(2, 2)).expect("append");
2938        CHECKPOINT_SERIALIZED_ROWS.with(|count| count.set(0));
2939        store.persist().expect("inline append checkpoint");
2940        assert_eq!(CHECKPOINT_SERIALIZED_ROWS.with(|count| count.get()), 0);
2941        store
2942            .get_collection("rows")
2943            .expect("rows")
2944            .update(row(1, 99))
2945            .expect("untracked edit");
2946        store.insert_auto("rows", row(3, 3)).expect("next append");
2947        CHECKPOINT_SERIALIZED_ROWS.with(|count| count.set(0));
2948        store.persist().expect("untracked edit checkpoint");
2949        assert!(CHECKPOINT_SERIALIZED_ROWS.with(|count| count.get()) > 0);
2950        drop(store);
2951        let reopened = UnifiedStore::open(&path).expect("reopen");
2952        assert_eq!(
2953            reopened
2954                .get("rows", EntityId::new(1))
2955                .expect("row")
2956                .data
2957                .as_row()
2958                .expect("table")
2959                .columns,
2960            vec![Value::Integer(99)]
2961        );
2962    }
2963
2964    #[test]
2965    fn replayed_trees_are_reusable_after_durable_reconciliation() {
2966        let directory = tempfile::tempdir().expect("temporary store");
2967        let path = directory.path().join("data.rdb");
2968        let store = UnifiedStore::open(&path).expect("paged store");
2969        store.insert_auto("rows", row(1, 1)).expect("seed");
2970        store.persist().expect("checkpoint");
2971        store
2972            .insert_auto("rows", row(2, 2))
2973            .expect("WAL-backed append");
2974        drop(store);
2975        let reopened = UnifiedStore::open(&path).expect("replay");
2976        assert!(reopened.get("rows", EntityId::new(2)).is_some());
2977        CHECKPOINT_SERIALIZED_ROWS.with(|count| count.set(0));
2978        reopened.persist().expect("checkpoint reconciled tree");
2979        assert_eq!(CHECKPOINT_SERIALIZED_ROWS.with(|count| count.get()), 0);
2980    }
2981
2982    #[test]
2983    fn legacy_system_catalog_and_wal_migrate_without_cold_boot_rebuild() {
2984        use crate::storage::wal::{WalRecord, WalWriter};
2985
2986        let directory = tempfile::tempdir().expect("temporary store");
2987        let path = directory.path().join("data.rdb");
2988        let config = UnifiedStoreConfig {
2989            page_cache_slots: Some(32),
2990            resident_memory_bytes_max: Some(2 * 1024 * 1024),
2991            ..UnifiedStoreConfig::default()
2992        };
2993        let index_identity = super::super::secondary_index::SecondaryIndexIdentity {
2994            codec_version: 1,
2995            definition: [91; 32],
2996        };
2997        let baseline_index_root;
2998        {
2999            let store = UnifiedStore::open_with_config(&path, config.clone()).expect("paged store");
3000            store
3001                .create_collection("red_config")
3002                .expect("system collection");
3003            store
3004                .insert_auto(
3005                    "red_config",
3006                    UnifiedEntity::table_row(
3007                        EntityId::new(1),
3008                        "red_config",
3009                        1,
3010                        vec![Value::text("checkpoint")],
3011                    ),
3012                )
3013                .expect("legacy-compatible insert");
3014            store
3015                .register_secondary_index(
3016                    "red_config",
3017                    "by_id",
3018                    index_identity.clone(),
3019                    Arc::new(SystemNameProjection),
3020                )
3021                .expect("secondary index");
3022            store.persist().expect("baseline checkpoint");
3023            baseline_index_root = store
3024                .secondary_index("red.config", "by_id", &index_identity)
3025                .expect("published secondary index")
3026                .root_page_id();
3027        }
3028
3029        // Recreate a pre-migration catalog and operational manifest. The two
3030        // spellings are deliberately equal-length so the test can preserve
3031        // every root and checksum-protected page layout except the name.
3032        {
3033            let pager = Pager::open(
3034                &path,
3035                PagerConfig {
3036                    disk_residency: true,
3037                    ..PagerConfig::default()
3038                },
3039            )
3040            .expect("open pager");
3041            let mut page = pager.read_page(1).expect("catalog page");
3042            let bytes = page.as_bytes_mut();
3043            let canonical = b"red.config";
3044            let legacy = b"red_config";
3045            let mut replacements = 0;
3046            for offset in 0..=bytes.len() - canonical.len() {
3047                if &bytes[offset..offset + canonical.len()] == canonical {
3048                    bytes[offset..offset + legacy.len()].copy_from_slice(legacy);
3049                    replacements += 1;
3050                }
3051            }
3052            assert!(replacements >= 1, "catalog must contain the canonical name");
3053            pager.write_page(1, page).expect("write legacy catalog");
3054            pager.sync().expect("sync legacy catalog");
3055        }
3056        crate::storage::operational_manifest::OperationalManifest::for_db_path(&path)
3057            .rename_collections(&[("red.config", "red_config")])
3058            .expect("restore legacy operational name");
3059
3060        // Append an acknowledged transaction written by a legacy binary. Its
3061        // payload must replay into the canonical manager during migration.
3062        let wal_upsert = reddb_file::encode_store_wal_action_frame(
3063            &reddb_file::StoreWalActionFrame::UpsertEntityRecord {
3064                collection: "red_config".to_string(),
3065                record: UnifiedStore::serialize_entity_record(
3066                    &UnifiedEntity::table_row(
3067                        EntityId::new(2),
3068                        "red_config",
3069                        2,
3070                        vec![Value::text("legacy WAL")],
3071                    ),
3072                    None,
3073                    STORE_VERSION_CURRENT,
3074                ),
3075            },
3076        )
3077        .expect("legacy WAL action");
3078        let wal_delete = reddb_file::encode_store_wal_action_frame(
3079            &reddb_file::StoreWalActionFrame::DeleteEntityRecord {
3080                collection: "red_config".to_string(),
3081                entity_id: EntityId::new(1).raw(),
3082            },
3083        )
3084        .expect("legacy WAL delete");
3085        let wal_path = reddb_file::layout::unified_wal_path(&path);
3086        {
3087            let mut writer = WalWriter::open(&wal_path).expect("open WAL");
3088            writer
3089                .append(&WalRecord::TxCommitBatch {
3090                    tx_id: 77,
3091                    actions: vec![wal_upsert, wal_delete],
3092                })
3093                .expect("append legacy transaction");
3094            writer.sync().expect("sync legacy transaction");
3095        }
3096
3097        let (root_after_recovery, pages_after_recovery) = {
3098            let recovered =
3099                UnifiedStore::open_with_config(&path, config.clone()).expect("migrate and recover");
3100            assert_eq!(recovered.list_collections(), vec!["red.config".to_string()]);
3101            assert!(
3102                recovered.get("red.config", EntityId::new(1)).is_none(),
3103                "legacy WAL deletes must target the canonical collection"
3104            );
3105            assert_eq!(
3106                recovered
3107                    .get("red.config", EntityId::new(2))
3108                    .expect("WAL row")
3109                    .kind
3110                    .collection(),
3111                "red.config"
3112            );
3113            let root = recovered
3114                .collection_root_page("red.config")
3115                .expect("published canonical root");
3116            assert_eq!(
3117                recovered
3118                    .secondary_index("red.config", "by_id", &index_identity)
3119                    .expect("recovered secondary index")
3120                    .root_page_id(),
3121                baseline_index_root,
3122                "name migration must reuse the secondary-index root"
3123            );
3124            let pages = recovered
3125                .pager()
3126                .expect("pager")
3127                .page_count()
3128                .expect("page count");
3129            (root, pages)
3130        };
3131
3132        // Simulate a crash/import boundary where the catalog rename is
3133        // durable but the operational manifest still has the legacy key.
3134        crate::storage::operational_manifest::OperationalManifest::for_db_path(&path)
3135            .rename_collections(&[("red.config", "red_config")])
3136            .expect("restore stale manifest identity");
3137
3138        CHECKPOINT_SERIALIZED_ROWS.with(|count| count.set(0));
3139        let cold = UnifiedStore::open_with_config(&path, config).expect("second cold boot");
3140        assert_eq!(
3141            cold.collection_root_page("red.config"),
3142            Some(root_after_recovery),
3143            "an idempotent cold boot must reuse the published data root"
3144        );
3145        assert_eq!(
3146            cold.pager()
3147                .expect("pager")
3148                .page_count()
3149                .expect("page count"),
3150            pages_after_recovery,
3151            "cold boot must not allocate replacement index trees"
3152        );
3153        assert_eq!(CHECKPOINT_SERIALIZED_ROWS.with(|count| count.get()), 0);
3154        assert_eq!(
3155            cold.secondary_index("red.config", "by_id", &index_identity)
3156                .expect("secondary index after cold boot")
3157                .root_page_id(),
3158            baseline_index_root
3159        );
3160        assert!(
3161            crate::storage::operational_manifest::OperationalManifest::for_db_path(&path)
3162                .recover_or_bootstrap(&["red.config".to_string()])
3163                .expect("canonical manifest")
3164                .is_empty()
3165        );
3166    }
3167
3168    struct DuplicateSystemCatalogFixture {
3169        _directory: tempfile::TempDir,
3170        path: std::path::PathBuf,
3171        config: UnifiedStoreConfig,
3172        pages_before: u32,
3173        legacy_root: Option<u32>,
3174        legacy_key_root: Option<u32>,
3175        legacy_secondary_root: Option<u32>,
3176        index_identity: super::super::secondary_index::SecondaryIndexIdentity,
3177    }
3178
3179    fn duplicate_system_catalog_fixture(
3180        legacy_populated: bool,
3181        canonical_populated: bool,
3182        index_legacy: bool,
3183    ) -> DuplicateSystemCatalogFixture {
3184        let directory = tempfile::tempdir().expect("temporary store");
3185        let path = directory.path().join("data.rdb");
3186        let config = UnifiedStoreConfig {
3187            page_cache_slots: Some(32),
3188            resident_memory_bytes_max: Some(2 * 1024 * 1024),
3189            ..UnifiedStoreConfig::default()
3190        };
3191        let index_identity = super::super::secondary_index::SecondaryIndexIdentity {
3192            codec_version: 1,
3193            definition: [73; 32],
3194        };
3195        let (legacy_root, legacy_key_root, legacy_secondary_root);
3196        {
3197            let store = UnifiedStore::open_with_config(&path, config.clone()).expect("paged store");
3198            store
3199                .create_collection("cfg_legacy")
3200                .expect("legacy source collection");
3201            if legacy_populated {
3202                store
3203                    .insert_auto(
3204                        "cfg_legacy",
3205                        UnifiedEntity::table_row(
3206                            EntityId::new(1),
3207                            "red_config",
3208                            1,
3209                            vec![Value::text("legacy")],
3210                        ),
3211                    )
3212                    .expect("legacy row");
3213            }
3214            store
3215                .create_collection("cfg.canon_")
3216                .expect("canonical source collection");
3217            if canonical_populated {
3218                store
3219                    .insert_auto(
3220                        "cfg.canon_",
3221                        UnifiedEntity::table_row(
3222                            EntityId::new(2),
3223                            "red.config",
3224                            2,
3225                            vec![Value::text("canonical")],
3226                        ),
3227                    )
3228                    .expect("canonical row");
3229            }
3230            if index_legacy {
3231                store
3232                    .register_secondary_index(
3233                        "cfg_legacy",
3234                        "by_id",
3235                        index_identity.clone(),
3236                        Arc::new(SystemNameProjection),
3237                    )
3238                    .expect("legacy secondary index");
3239            }
3240            store.persist().expect("checkpoint duplicate catalog");
3241            legacy_root = store.collection_root_page("cfg_legacy");
3242            legacy_key_root = store
3243                .key_indices
3244                .read()
3245                .get("cfg_legacy")
3246                .map(|index| index.tree.root_page_id());
3247            legacy_secondary_root = index_legacy.then(|| {
3248                store
3249                    .secondary_index("cfg_legacy", "by_id", &index_identity)
3250                    .expect("legacy secondary root")
3251                    .root_page_id()
3252            });
3253        }
3254
3255        // Recreate the production shape: a populated legacy collection and an
3256        // empty canonical collection published by an intermediate binary.
3257        // Every spelling has the same byte length, so roots and checksummed
3258        // page layouts stay unchanged while only the catalog identities move.
3259        {
3260            let pager = Pager::open(
3261                &path,
3262                PagerConfig {
3263                    disk_residency: true,
3264                    ..PagerConfig::default()
3265                },
3266            )
3267            .expect("open pager");
3268            let mut page = pager.read_page(1).expect("catalog page");
3269            let bytes = page.as_bytes_mut();
3270            for (source, destination) in [
3271                (b"cfg_legacy".as_slice(), b"red_config".as_slice()),
3272                (b"cfg.canon_".as_slice(), b"red.config".as_slice()),
3273            ] {
3274                let mut replacements = 0;
3275                for offset in 0..=bytes.len() - source.len() {
3276                    if &bytes[offset..offset + source.len()] == source {
3277                        bytes[offset..offset + destination.len()].copy_from_slice(destination);
3278                        replacements += 1;
3279                    }
3280                }
3281                assert!(replacements >= 1, "catalog must contain the source name");
3282            }
3283            pager.write_page(1, page).expect("write duplicate catalog");
3284            pager.sync().expect("sync duplicate catalog");
3285        }
3286        crate::storage::operational_manifest::OperationalManifest::for_db_path(&path)
3287            .rename_collections(&[("cfg_legacy", "red_config"), ("cfg.canon_", "red.config")])
3288            .expect("publish duplicate operational names");
3289
3290        let pages_before = Pager::open(
3291            &path,
3292            PagerConfig {
3293                disk_residency: true,
3294                ..PagerConfig::default()
3295            },
3296        )
3297        .expect("inspect pager")
3298        .page_count()
3299        .expect("page count");
3300        DuplicateSystemCatalogFixture {
3301            _directory: directory,
3302            path,
3303            config,
3304            pages_before,
3305            legacy_root,
3306            legacy_key_root,
3307            legacy_secondary_root,
3308            index_identity,
3309        }
3310    }
3311
3312    fn alias_duplicate_system_catalog_state(fixture: &DuplicateSystemCatalogFixture) {
3313        let pager = Pager::open(
3314            &fixture.path,
3315            PagerConfig {
3316                disk_residency: true,
3317                ..PagerConfig::default()
3318            },
3319        )
3320        .expect("open duplicate catalog pager");
3321        let mut metadata = read_meta_payload(&pager)
3322            .expect("read duplicate catalog")
3323            .expect("published duplicate catalog");
3324        let mut resume = reddb_file::native_resume::decode_catalog_resume(&metadata)
3325            .expect("decode duplicate resume")
3326            .expect("prepared duplicate resume");
3327        let legacy_state = resume
3328            .collections
3329            .get("red_config")
3330            .expect("legacy resume state")
3331            .clone();
3332        resume
3333            .collections
3334            .insert("red.config".to_string(), legacy_state.clone());
3335
3336        let header = reddb_file::decode_native_paged_metadata_header(&metadata)
3337            .expect("decode metadata header")
3338            .expect("native metadata header");
3339        let mut position = reddb_file::METADATA_HEADER_BYTES;
3340        let mut replaced_root = false;
3341        for _ in 0..header.collection_count {
3342            let root = reddb_file::decode_native_paged_collection_root(&metadata, &mut position)
3343                .expect("decode collection root");
3344            if root.collection == "red.config" {
3345                metadata[position - 4..position]
3346                    .copy_from_slice(&legacy_state.root_page.to_le_bytes());
3347                replaced_root = true;
3348            }
3349        }
3350        assert!(replaced_root, "canonical catalog root must be present");
3351
3352        let trailer = metadata
3353            .windows(4)
3354            .rposition(|bytes| bytes == b"RDRS")
3355            .expect("catalog resume trailer");
3356        metadata.truncate(trailer);
3357        reddb_file::native_resume::encode_catalog_resume(&mut metadata, &resume)
3358            .expect("encode aliased resume");
3359        publish_paged_metadata(&pager, &metadata).expect("publish aliased catalog");
3360    }
3361
3362    #[test]
3363    fn populated_legacy_system_collection_replaces_empty_canonical_duplicate() {
3364        let fixture = duplicate_system_catalog_fixture(true, false, true);
3365        let recovered = UnifiedStore::open_with_config(&fixture.path, fixture.config.clone())
3366            .expect("resolve empty duplicate");
3367        assert_eq!(recovered.list_collections(), vec!["red.config".to_string()]);
3368        assert_eq!(
3369            recovered
3370                .get("red.config", EntityId::new(1))
3371                .expect("preserved legacy row")
3372                .kind
3373                .collection(),
3374            "red.config"
3375        );
3376        assert_eq!(
3377            recovered.collection_root_page("red.config"),
3378            fixture.legacy_root,
3379            "the populated data root must be reused"
3380        );
3381        assert_eq!(
3382            recovered
3383                .key_indices
3384                .read()
3385                .get("red.config")
3386                .map(|index| index.tree.root_page_id()),
3387            fixture.legacy_key_root,
3388            "the populated key-index root must be reused"
3389        );
3390        assert_eq!(
3391            recovered
3392                .secondary_index("red.config", "by_id", &fixture.index_identity)
3393                .expect("surviving secondary index")
3394                .root_page_id(),
3395            fixture
3396                .legacy_secondary_root
3397                .expect("fixture secondary root")
3398        );
3399        drop(recovered);
3400
3401        let cold = UnifiedStore::open_with_config(&fixture.path, fixture.config)
3402            .expect("idempotent cold boot");
3403        assert_eq!(cold.list_collections(), vec!["red.config".to_string()]);
3404        assert_eq!(
3405            cold.pager()
3406                .expect("pager")
3407                .page_count()
3408                .expect("page count"),
3409            fixture.pages_before,
3410            "resolving an empty duplicate must not extend the file"
3411        );
3412    }
3413
3414    #[test]
3415    fn populated_canonical_system_collection_replaces_empty_legacy_duplicate() {
3416        let fixture = duplicate_system_catalog_fixture(false, true, false);
3417        let recovered = UnifiedStore::open_with_config(&fixture.path, fixture.config)
3418            .expect("retain populated canonical duplicate");
3419        assert_eq!(recovered.list_collections(), vec!["red.config".to_string()]);
3420        assert_eq!(
3421            recovered
3422                .get("red.config", EntityId::new(2))
3423                .expect("preserved canonical row")
3424                .kind
3425                .collection(),
3426            "red.config"
3427        );
3428        assert!(recovered.get("red.config", EntityId::new(1)).is_none());
3429    }
3430
3431    #[test]
3432    fn empty_canonical_system_collection_replaces_empty_legacy_duplicate() {
3433        let fixture = duplicate_system_catalog_fixture(false, false, false);
3434        let recovered = UnifiedStore::open_with_config(&fixture.path, fixture.config)
3435            .expect("prefer canonical empty duplicate");
3436        assert_eq!(recovered.list_collections(), vec!["red.config".to_string()]);
3437        assert_eq!(
3438            recovered
3439                .get_collection("red.config")
3440                .expect("canonical collection")
3441                .count(),
3442            0
3443        );
3444    }
3445
3446    #[test]
3447    fn populated_system_collection_duplicates_still_fail_closed() {
3448        let fixture = duplicate_system_catalog_fixture(true, true, false);
3449        let error = match UnifiedStore::open_with_config(&fixture.path, fixture.config) {
3450            Ok(_) => panic!("two populated identities are ambiguous"),
3451            Err(error) => error,
3452        };
3453        assert!(
3454            error
3455                .to_string()
3456                .contains("both populated legacy collection 'red_config' and canonical collection 'red.config'"),
3457            "unexpected error: {error}"
3458        );
3459    }
3460
3461    #[test]
3462    fn populated_system_collection_aliases_reconcile_without_rebuild() {
3463        let fixture = duplicate_system_catalog_fixture(true, false, true);
3464        alias_duplicate_system_catalog_state(&fixture);
3465
3466        let recovered = UnifiedStore::open_with_config(&fixture.path, fixture.config.clone())
3467            .expect("reconcile identical populated aliases");
3468        assert_eq!(recovered.list_collections(), vec!["red.config".to_string()]);
3469        assert_eq!(
3470            recovered
3471                .get("red.config", EntityId::new(1))
3472                .expect("preserved aliased row")
3473                .kind
3474                .collection(),
3475            "red.config"
3476        );
3477        assert_eq!(
3478            recovered.collection_root_page("red.config"),
3479            fixture.legacy_root,
3480            "the shared data root must be reused"
3481        );
3482        assert_eq!(
3483            recovered
3484                .key_indices
3485                .read()
3486                .get("red.config")
3487                .map(|index| index.tree.root_page_id()),
3488            fixture.legacy_key_root,
3489            "the shared key-index root must be reused"
3490        );
3491        assert_eq!(
3492            recovered
3493                .secondary_index("red.config", "by_id", &fixture.index_identity)
3494                .expect("shared secondary index")
3495                .root_page_id(),
3496            fixture
3497                .legacy_secondary_root
3498                .expect("fixture secondary root")
3499        );
3500        drop(recovered);
3501
3502        CHECKPOINT_SERIALIZED_ROWS.with(|count| count.set(0));
3503        let cold = UnifiedStore::open_with_config(&fixture.path, fixture.config)
3504            .expect("idempotent cold boot");
3505        assert_eq!(cold.list_collections(), vec!["red.config".to_string()]);
3506        assert_eq!(CHECKPOINT_SERIALIZED_ROWS.with(|count| count.get()), 0);
3507        assert_eq!(
3508            cold.pager()
3509                .expect("pager")
3510                .page_count()
3511                .expect("page count"),
3512            fixture.pages_before,
3513            "reconciling a physical alias must not extend the file"
3514        );
3515    }
3516
3517    fn row(id: u64, value: i64) -> UnifiedEntity {
3518        UnifiedEntity::table_row(EntityId::new(id), "rows", id, vec![Value::Integer(value)])
3519    }
3520
3521    #[test]
3522    fn checkpoint_reuse_tracks_manager_updates_metadata_deletes_and_bulk_inserts() {
3523        let directory = tempfile::tempdir().expect("temporary store");
3524        let path = directory.path().join("data.rdb");
3525        let store = UnifiedStore::open(&path).expect("paged store");
3526        store.create_collection("rows").expect("collection");
3527        store.insert("rows", row(1, 1)).expect("seed");
3528        store.persist().expect("initial checkpoint");
3529        let manager = store.get_collection("rows").expect("rows");
3530        manager.update(row(1, 2)).expect("direct update");
3531        CHECKPOINT_SERIALIZED_ROWS.with(|count| count.set(0));
3532        store.persist().expect("updated row checkpoint");
3533        assert_eq!(CHECKPOINT_SERIALIZED_ROWS.with(|count| count.get()), 1);
3534        let mut metadata = Metadata::new();
3535        metadata.set("verified", MetadataValue::Bool(true));
3536        manager
3537            .set_metadata(EntityId::new(1), metadata)
3538            .expect("metadata");
3539        CHECKPOINT_SERIALIZED_ROWS.with(|count| count.set(0));
3540        store.persist().expect("metadata-only checkpoint");
3541        assert_eq!(CHECKPOINT_SERIALIZED_ROWS.with(|count| count.get()), 1);
3542        manager
3543            .bulk_insert(vec![row(2, 2), row(3, 3)])
3544            .expect("bulk insert");
3545        CHECKPOINT_SERIALIZED_ROWS.with(|count| count.set(0));
3546        store.persist().expect("bulk insert checkpoint");
3547        assert_eq!(CHECKPOINT_SERIALIZED_ROWS.with(|count| count.get()), 3);
3548        manager.delete(EntityId::new(2)).expect("delete");
3549        CHECKPOINT_SERIALIZED_ROWS.with(|count| count.set(0));
3550        store.persist().expect("changed checkpoint");
3551        assert_eq!(CHECKPOINT_SERIALIZED_ROWS.with(|count| count.get()), 2);
3552        drop(store);
3553        let recovered = UnifiedStore::open(&path).expect("recover changed rows");
3554        let manager = recovered.get_collection("rows").expect("rows");
3555        assert_eq!(manager.count(), 2);
3556        assert_eq!(
3557            manager
3558                .get(EntityId::new(1))
3559                .expect("row")
3560                .data
3561                .as_row()
3562                .expect("table")
3563                .columns,
3564            vec![Value::Integer(2)]
3565        );
3566        assert!(manager.get(EntityId::new(2)).is_none());
3567        assert!(manager
3568            .get_metadata(EntityId::new(1))
3569            .expect("metadata")
3570            .get("verified")
3571            .is_some());
3572    }
3573
3574    #[test]
3575    fn unrelated_visibility_changes_do_not_serialize_collection_rows() {
3576        let directory = tempfile::tempdir().expect("temporary store");
3577        let store = UnifiedStore::open(&directory.path().join("data.rdb")).expect("paged store");
3578        let snapshots = Arc::new(crate::storage::transaction::snapshot::SnapshotManager::new());
3579        assert!(store.snapshot_manager.set(Arc::clone(&snapshots)).is_ok());
3580        store.create_collection("rows").expect("collection");
3581        for id in 1..=40 {
3582            store.insert("rows", row(id, id as i64)).expect("seed");
3583        }
3584        store.persist().expect("initial checkpoint");
3585        let unrelated = snapshots.begin();
3586        snapshots.rollback(unrelated);
3587        CHECKPOINT_SERIALIZED_ROWS.with(|count| count.set(0));
3588        store.persist().expect("unrelated rollback checkpoint");
3589        assert_eq!(CHECKPOINT_SERIALIZED_ROWS.with(|count| count.get()), 0);
3590
3591        store
3592            .pending_vault_versions
3593            .write()
3594            .insert(EntityId::new(100));
3595        CHECKPOINT_SERIALIZED_ROWS.with(|count| count.set(0));
3596        store.persist().expect("unrelated vault checkpoint");
3597        assert_eq!(CHECKPOINT_SERIALIZED_ROWS.with(|count| count.get()), 0);
3598        store.pending_vault_versions.write().clear();
3599        CHECKPOINT_SERIALIZED_ROWS.with(|count| count.set(0));
3600        store
3601            .persist()
3602            .expect("unrelated vault publication checkpoint");
3603        assert_eq!(CHECKPOINT_SERIALIZED_ROWS.with(|count| count.get()), 0);
3604    }
3605
3606    #[test]
3607    fn low_level_rollback_of_a_non_active_xid_invalidates_sparse_visibility() {
3608        let directory = tempfile::tempdir().expect("temporary store");
3609        let path = directory.path().join("data.rdb");
3610        let store = UnifiedStore::open(&path).expect("paged store");
3611        let snapshots = Arc::new(crate::storage::transaction::snapshot::SnapshotManager::new());
3612        assert!(store.snapshot_manager.set(Arc::clone(&snapshots)).is_ok());
3613        store.create_collection("rows").expect("collection");
3614        let xid = snapshots.begin();
3615        let mut entity = row(1, 1);
3616        entity.xmin = xid;
3617        store.insert("rows", entity).expect("committed row");
3618        snapshots.commit(xid);
3619        store.persist().expect("committed baseline");
3620        snapshots.rollback(xid);
3621        store
3622            .persist()
3623            .expect("untracked outcome requires validation");
3624        drop(store);
3625        let recovered = UnifiedStore::open(&path).expect("recover");
3626        assert_eq!(recovered.get_collection("rows").expect("rows").count(), 0);
3627    }
3628
3629    #[test]
3630    fn exhausted_deferred_row_cache_keeps_full_validation() {
3631        let directory = tempfile::tempdir().expect("temporary store");
3632        let path = directory.path().join("data.rdb");
3633        let store = UnifiedStore::open(&path).expect("paged store");
3634        let snapshots = Arc::new(crate::storage::transaction::snapshot::SnapshotManager::new());
3635        assert!(store.snapshot_manager.set(Arc::clone(&snapshots)).is_ok());
3636        store.create_collection("rows").expect("collection");
3637        let xid = snapshots.begin();
3638        for id in 1..=1025 {
3639            let mut entity = row(id, id as i64);
3640            entity.xmin = xid;
3641            store.insert("rows", entity).expect("pending row");
3642        }
3643        store.persist().expect("exclude pending rows");
3644        assert!(store.checkpoint_versions.read()["rows"]
3645            .deferred_rows
3646            .is_none());
3647        snapshots.commit(xid);
3648        store
3649            .persist()
3650            .expect("publish committed rows conservatively");
3651        drop(store);
3652        let recovered = UnifiedStore::open(&path).expect("recover");
3653        assert_eq!(
3654            recovered.get_collection("rows").expect("rows").count(),
3655            1025
3656        );
3657    }
3658
3659    #[test]
3660    fn rollback_invalidates_reuse_without_a_segment_mutation() {
3661        let directory = tempfile::tempdir().expect("temporary store");
3662        let path = directory.path().join("data.rdb");
3663        let store = UnifiedStore::open(&path).expect("paged store");
3664        let snapshots = Arc::new(crate::storage::transaction::snapshot::SnapshotManager::new());
3665        assert!(store.snapshot_manager.set(Arc::clone(&snapshots)).is_ok());
3666        store.create_collection("rows").expect("collection");
3667        let xid = snapshots.begin();
3668        let mut pending = row(1, 1);
3669        pending.xmin = xid;
3670        store.insert("rows", pending).expect("version");
3671        store.persist().expect("before rollback");
3672        snapshots.rollback(xid);
3673        store.persist().expect("after rollback");
3674        drop(store);
3675        let recovered = UnifiedStore::open(&path).expect("recover");
3676        assert_eq!(recovered.get_collection("rows").expect("rows").count(), 0);
3677    }
3678
3679    #[test]
3680    fn rolled_back_delete_is_normalized_without_a_segment_mutation() {
3681        let directory = tempfile::tempdir().expect("temporary store");
3682        let path = directory.path().join("data.rdb");
3683        let store = UnifiedStore::open(&path).expect("paged store");
3684        let snapshots = Arc::new(crate::storage::transaction::snapshot::SnapshotManager::new());
3685        assert!(store.snapshot_manager.set(Arc::clone(&snapshots)).is_ok());
3686        store.create_collection("rows").expect("collection");
3687        let xid = snapshots.begin();
3688        let mut deleted = row(1, 1);
3689        deleted.xmax = xid;
3690        store.insert("rows", deleted).expect("version");
3691        store.persist().expect("before rollback");
3692        snapshots.rollback(xid);
3693        store.persist().expect("after rollback");
3694        drop(store);
3695        let recovered = UnifiedStore::open(&path).expect("recover");
3696        let manager = recovered.get_collection("rows").expect("rows");
3697        assert_eq!(manager.get(EntityId::new(1)).expect("retained row").xmax, 0);
3698    }
3699
3700    #[test]
3701    fn publishing_a_pending_vault_version_invalidates_reuse() {
3702        let directory = tempfile::tempdir().expect("temporary store");
3703        let path = directory.path().join("data.rdb");
3704        let store = UnifiedStore::open(&path).expect("paged store");
3705        store.create_collection("rows").expect("collection");
3706        store.insert("rows", row(1, 1)).expect("row");
3707        store
3708            .pending_vault_versions
3709            .write()
3710            .insert(EntityId::new(1));
3711        store.persist().expect("exclude pending vault version");
3712        store
3713            .pending_vault_versions
3714            .write()
3715            .remove(&EntityId::new(1));
3716        store.persist().expect("include published vault version");
3717        drop(store);
3718        let recovered = UnifiedStore::open(&path).expect("recover");
3719        assert_eq!(recovered.get_collection("rows").expect("rows").count(), 1);
3720    }
3721}