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