Skip to main content

reddb_server/storage/unified/store/
impl_pages.rs

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