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