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