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 recovery_xid_summary: RwLock::new(None),
1066 checkpoint_versions: RwLock::new(HashMap::new()),
1067 checkpoint_durable_rows: RwLock::new(super::checkpoint_delta::DurableRows::default()),
1068 retired_btrees: parking_lot::Mutex::new(Vec::new()),
1069 ownership_catalog_epoch: AtomicU64::new(0),
1070 ownership_active_epoch: AtomicU64::new(0),
1071 detached_native_blob_pages: parking_lot::Mutex::new(Default::default()),
1072 commit: None,
1073 replayed_turbo_inserts: parking_lot::Mutex::new(HashMap::new()),
1074 replayed_probabilistic_deltas: parking_lot::Mutex::new(Vec::new()),
1075 snapshot_manager: std::sync::OnceLock::new(),
1076 embedded_commit_session: std::sync::OnceLock::new(),
1077 #[cfg(test)]
1078 test_fail_deferred_wal_append: std::sync::atomic::AtomicBool::new(false),
1079 #[cfg(test)]
1080 test_require_deferred_publication_fence: std::sync::atomic::AtomicBool::new(false),
1081 vault_publication_lock: parking_lot::ReentrantMutex::new(()),
1082 pending_vault_versions: RwLock::new(std::collections::HashSet::new()),
1083 }
1084 }
1085
1086 pub fn open(path: impl AsRef<Path>) -> Result<Self, StoreError> {
1104 Self::open_with_config(path, UnifiedStoreConfig::default())
1105 }
1106
1107 pub fn open_with_config(
1108 path: impl AsRef<Path>,
1109 config: UnifiedStoreConfig,
1110 ) -> Result<Self, StoreError> {
1111 Self::open_with_config_for_recovery(path, config, false, None, None)
1112 }
1113
1114 pub fn open_read_only_with_config(
1117 path: impl AsRef<Path>,
1118 mut config: UnifiedStoreConfig,
1119 ) -> Result<Self, StoreError> {
1120 let path = path.as_ref();
1121 config.embedded_wal_path = None;
1122 let pager = Pager::open(
1123 path,
1124 PagerConfig {
1125 read_only: true,
1126 create: false,
1127 disk_residency: config.resident_memory_bytes_max.is_some(),
1128 cache_size: config
1129 .page_cache_slots
1130 .unwrap_or_else(|| PagerConfig::default().cache_size),
1131 ..PagerConfig::default()
1132 },
1133 )
1134 .map_err(|error| StoreError::Io(std::io::Error::other(error.to_string())))?;
1135 pager
1136 .enable_deferred_reclamation()
1137 .map_err(|error| StoreError::Io(std::io::Error::other(error.to_string())))?;
1138 let mut store = Self::with_config(config);
1139 store.pager = Some(Arc::new(pager));
1140 store.db_path = Some(path.to_path_buf());
1141 let wal_path = reddb_file::layout::unified_wal_path(path);
1142 StoreCommitCoordinator::restore_checkpoint_page_images_from_path(&store, &wal_path)
1143 .map_err(StoreError::Io)?;
1144 let catalog = store.load_from_pages(false, None)?;
1145 StoreCommitCoordinator::replay_read_only(&store, &wal_path).map_err(StoreError::Io)?;
1146 let pending_drops = store.legacy_pending_collection_drops()?.unwrap_or_default();
1147 for name in pending_drops {
1148 if !catalog
1149 .collisions
1150 .iter()
1151 .any(|collision| collision.loser == name)
1152 && store.get_collection(&name).is_some()
1153 {
1154 store.drop_collection_in_memory(&name)?;
1155 }
1156 }
1157 Ok(store)
1158 }
1159
1160 pub(crate) fn open_with_config_for_recovery(
1163 path: impl AsRef<Path>,
1164 config: UnifiedStoreConfig,
1165 retain_canonical_system_config: bool,
1166 boot_wal: Option<&crate::storage::compact::boot_system_wal::BootSystemWalPlan>,
1167 catalog_plan: Option<&crate::storage::compact::catalog_aliases::CanonicalCatalogPlan>,
1168 ) -> Result<Self, StoreError> {
1169 let path = path.as_ref();
1170 let mut pager_config = PagerConfig {
1171 disk_residency: config.resident_memory_bytes_max.is_some(),
1172 ..PagerConfig::default()
1173 };
1174 if let Some(slots) = config.page_cache_slots {
1178 pager_config.cache_size = slots;
1179 }
1180 if matches!(
1188 std::env::var("REDDB_DOUBLE_WRITE").ok().as_deref(),
1189 Some("0") | Some("false") | Some("off")
1190 ) {
1191 pager_config.double_write = false;
1192 }
1193 let pager_open_started = std::time::Instant::now();
1194 let pager = Pager::open(path, pager_config)
1195 .map_err(|e| StoreError::Io(std::io::Error::other(e.to_string())))?;
1196 pager
1197 .enable_deferred_reclamation()
1198 .map_err(|e| StoreError::Io(std::io::Error::other(e.to_string())))?;
1199 trace_startup_phase(path, "paged_pager_open", pager_open_started);
1200
1201 if config.resident_memory_bytes_max.is_none()
1202 && pager
1203 .header()
1204 .map_err(|error| StoreError::Io(std::io::Error::other(error.to_string())))?
1205 .version
1206 >= reddb_file::file_format::PAGE_DISK_RESIDENCY_FILE_VERSION
1207 {
1208 return Err(StoreError::Io(std::io::Error::new(std::io::ErrorKind::Unsupported,
1209 "this database was prepared for disk residency; reopen with the disk-resident storage profile")));
1210 }
1211
1212 let wal_path = reddb_file::layout::unified_wal_path(path);
1213 let commit = Some(Arc::new(
1216 StoreCommitCoordinator::open(wal_path, config.durability_mode, config.group_commit)
1217 .map_err(StoreError::Io)?,
1218 ));
1219
1220 let store = Self {
1221 config,
1222 resident: reddb_engine::storage::resident_store::ResidentStore::new(),
1223 format_version: AtomicU32::new(STORE_VERSION_V11),
1224 pager: Some(Arc::new(pager)),
1225 db_path: Some(path.to_path_buf()),
1226 operational_manifest: parking_lot::Mutex::new(None),
1227 btree_indices: RwLock::new(HashMap::new()),
1228 key_indices: RwLock::new(HashMap::new()),
1229 secondary_index_specs: RwLock::new(HashMap::new()),
1230 recovery_xid_summary: RwLock::new(None),
1231 checkpoint_versions: RwLock::new(HashMap::new()),
1232 checkpoint_durable_rows: RwLock::new(super::checkpoint_delta::DurableRows::default()),
1233 retired_btrees: parking_lot::Mutex::new(Vec::new()),
1234 ownership_catalog_epoch: AtomicU64::new(0),
1235 ownership_active_epoch: AtomicU64::new(0),
1236 detached_native_blob_pages: parking_lot::Mutex::new(Default::default()),
1237 commit,
1238 replayed_turbo_inserts: parking_lot::Mutex::new(HashMap::new()),
1239 replayed_probabilistic_deltas: parking_lot::Mutex::new(Vec::new()),
1240 snapshot_manager: std::sync::OnceLock::new(),
1241 embedded_commit_session: std::sync::OnceLock::new(),
1242 #[cfg(test)]
1243 test_fail_deferred_wal_append: std::sync::atomic::AtomicBool::new(false),
1244 #[cfg(test)]
1245 test_require_deferred_publication_fence: std::sync::atomic::AtomicBool::new(false),
1246 vault_publication_lock: parking_lot::ReentrantMutex::new(()),
1247 pending_vault_versions: RwLock::new(std::collections::HashSet::new()),
1248 };
1249
1250 let checkpoint_recovery_started = std::time::Instant::now();
1253 if let Some(commit) = &store.commit {
1254 commit
1255 .restore_checkpoint_page_images(&store)
1256 .map_err(StoreError::Io)?;
1257 }
1258 trace_startup_phase(
1259 path,
1260 "paged_checkpoint_recovery",
1261 checkpoint_recovery_started,
1262 );
1263 let page_load_started = std::time::Instant::now();
1265 let catalog_load = store.load_from_pages(retain_canonical_system_config, catalog_plan)?;
1266 trace_startup_phase(path, "paged_catalog_and_roots", page_load_started);
1267 let wal_replay_started = std::time::Instant::now();
1268 if let Some(commit) = &store.commit {
1269 commit
1270 .replay_into(&store, boot_wal)
1271 .map_err(StoreError::Io)?;
1272 }
1273 trace_startup_phase(path, "paged_wal_replay", wal_replay_started);
1274 if catalog_load.migrated {
1275 store.mark_paged_registry_dirty();
1278 store.flush_paged_state()?;
1279 }
1280 let manifest_recovery_started = std::time::Instant::now();
1281 store.recover_operational_manifest(&catalog_load.collisions)?;
1282 if store.config.resident_memory_bytes_max.is_some() {
1283 let missing_key_index = {
1284 let trees = store.btree_indices.read();
1285 let keys = store.key_indices.read();
1286 trees.iter().any(|(name, tree)| {
1287 !keys
1288 .get(name)
1289 .is_some_and(|index| index.source.ptr_eq(&Arc::downgrade(tree)))
1290 })
1291 };
1292 if missing_key_index {
1293 store.persist()?;
1294 }
1295 }
1296 trace_startup_phase(
1297 path,
1298 "paged_manifest_and_derived_roots",
1299 manifest_recovery_started,
1300 );
1301
1302 Ok(store)
1303 }
1304
1305 pub(crate) fn reconcile_replayed_btrees(&self) -> Result<(), StoreError> {
1309 let Some(pager) = &self.pager else {
1310 return Ok(());
1311 };
1312 let collections = self.resident.collections.read();
1313 let mut indices = self.btree_indices.write();
1314 let previous_versions = self.checkpoint_versions.read();
1315 let raw_replay =
1316 self.snapshot_manager.get().is_none() && self.pending_vault_versions.read().is_empty();
1317 for (name, manager) in collections.iter() {
1318 let mut reusable = None;
1319 if raw_replay {
1320 if let (Some(previous), Some(tree)) =
1321 (previous_versions.get(name), indices.get(name))
1322 {
1323 let mut current = CheckpointVersion {
1324 manager: Arc::downgrade(manager),
1325 segments: manager.checkpoint_revisions(),
1326 visibility_revision: 0,
1327 untracked_visibility_revision: 0,
1328 excluded_xids: Arc::default(),
1329 pending_vault: Arc::default(),
1330 format_version: self.format_version(),
1331 deferred_rows: Some(Arc::default()),
1332 has_unprojected_rows: false,
1333 };
1334 if previous.manager.ptr_eq(¤t.manager)
1335 && previous.format_version == current.format_version
1336 {
1337 if previous.segments == current.segments
1338 && !previous.has_unprojected_rows
1339 && checkpoint_visibility_matches(previous, ¤t)
1340 {
1341 reusable = Some(Arc::clone(tree));
1342 } else {
1343 reusable = self.sparse_checkpoint_tree(
1349 name,
1350 manager,
1351 tree,
1352 previous,
1353 &mut current,
1354 )?;
1355 }
1356 }
1357 }
1358 }
1359 let tree = match reusable {
1360 Some(tree) => tree,
1361 None => {
1362 self.checkpoint_tree(pager, manager, indices.get(name), |entity, metadata| {
1363 #[cfg(test)]
1364 REPLAY_SERIALIZED_ROWS.with(|count| count.set(count.get() + 1));
1365 Some(Self::serialize_entity_record(
1366 entity,
1367 metadata.as_ref(),
1368 self.format_version(),
1369 ))
1370 })?
1371 }
1372 };
1373 if indices
1374 .get(name)
1375 .is_some_and(|previous| Arc::ptr_eq(previous, &tree))
1376 {
1377 continue;
1378 }
1379 if let Some(previous) = indices.insert(name.clone(), tree) {
1380 self.retire_btree(previous);
1381 }
1382 self.mark_paged_registry_dirty();
1383 #[cfg(test)]
1384 if std::env::var_os("REDDB_EXPERIMENTAL_EXIT_DURING_REPLAY_REBUILD").is_some() {
1385 if std::env::var_os("REDDB_EXPERIMENTAL_REPLAY_NO_SYNC").is_none() {
1386 pager.sync().map_err(|error| {
1387 StoreError::Io(std::io::Error::other(error.to_string()))
1388 })?;
1389 }
1390 eprintln!("experimental replay rebuild exit before manifest");
1391 std::process::exit(182);
1392 }
1393 }
1394 drop(previous_versions);
1395 drop(indices);
1396 drop(collections);
1397 self.flush_paged_state()?;
1398 #[cfg(test)]
1399 if std::env::var_os("REDDB_EXPERIMENTAL_EXIT_AFTER_REPLAY_PUBLICATION").is_some() {
1400 eprintln!("experimental replay exit after manifest");
1401 std::process::exit(182);
1402 }
1403 if self.snapshot_manager.get().is_none() && self.pending_vault_versions.read().is_empty() {
1406 let collections = self.resident.collections.read();
1407 let mut versions = self.checkpoint_versions.write();
1408 for (name, manager) in collections.iter() {
1409 versions.insert(
1410 name.clone(),
1411 CheckpointVersion {
1412 manager: Arc::downgrade(manager),
1413 segments: manager.checkpoint_revisions(),
1414 visibility_revision: 0,
1415 untracked_visibility_revision: 0,
1416 excluded_xids: Arc::default(),
1417 pending_vault: Arc::default(),
1418 format_version: self.format_version(),
1419 deferred_rows: Some(Arc::default()),
1420 has_unprojected_rows: false,
1421 },
1422 );
1423 }
1424 }
1425 Ok(())
1426 }
1427
1428 fn legacy_pending_collection_drops(&self) -> Result<Option<Vec<String>>, StoreError> {
1429 match self.try_get_config("red.storage.legacy_manifest_imported")? {
1430 Some(Value::Boolean(true)) => return Ok(None),
1431 None => {}
1432 Some(_) => {
1433 return Err(StoreError::Serialization(
1434 "invalid legacy manifest import marker".into(),
1435 ))
1436 }
1437 }
1438 let Some(path) = &self.db_path else {
1439 return Ok(None);
1440 };
1441 self.operational_manifest_for_path(path)
1442 .read_only_pending_drops_if_present()
1443 .map_err(|err| storage_integrity("manifest", "current", None, err.to_string()))
1444 }
1445
1446 fn recover_operational_manifest(
1447 &self,
1448 collisions: &[SystemCollectionCollision],
1449 ) -> Result<(), StoreError> {
1450 let Some(pending) = self.legacy_pending_collection_drops()? else {
1451 return Ok(());
1452 };
1453 for name in pending {
1456 if !collisions.iter().any(|collision| collision.loser == name)
1457 && self.get_collection(&name).is_some()
1458 {
1459 self.drop_collection(&name)?;
1460 }
1461 }
1462 self.try_set_config_tree(
1463 "red.storage.legacy_manifest_imported",
1464 &crate::serde_json::Value::Bool(true),
1465 )?;
1466 Ok(())
1467 }
1468
1469 pub(super) fn published_ownership_epoch(&self) -> Result<u64, StoreError> {
1471 let Some(pager) = &self.pager else {
1472 return Ok(0);
1473 };
1474 let Some(content) = read_meta_payload(pager)? else {
1475 return Ok(0);
1476 };
1477 reddb_file::native_store::decode_native_paged_catalog_ownership_epoch(&content)
1478 .map(|epoch| epoch.unwrap_or(0))
1479 .map_err(|error| StoreError::Serialization(error.to_string()))
1480 }
1481
1482 fn load_from_pages(
1484 &self,
1485 retain_canonical_system_config: bool,
1486 catalog_plan: Option<&crate::storage::compact::catalog_aliases::CanonicalCatalogPlan>,
1487 ) -> Result<CatalogLoadOutcome, StoreError> {
1488 let pager = match &self.pager {
1489 Some(p) => p,
1490 None => return Ok(CatalogLoadOutcome::default()), };
1492
1493 let page_count = pager.page_count().map_err(|e| {
1495 StoreError::Io(std::io::Error::other(format!(
1496 "failed to read page count: {}",
1497 e
1498 )))
1499 })?;
1500 if page_count <= 1 {
1501 return Ok(CatalogLoadOutcome::default());
1503 }
1504
1505 if let Some(content_vec) = read_meta_payload(pager)? {
1510 let content: &[u8] = &content_vec;
1511 let file_version = pager
1512 .header()
1513 .map_err(|error| StoreError::Io(std::io::Error::other(error.to_string())))?
1514 .version;
1515 let prepared =
1516 file_version >= reddb_file::file_format::PAGE_DISK_RESIDENCY_FILE_VERSION;
1517 let resume = if prepared {
1518 reddb_file::native_resume::decode_catalog_resume(content)
1519 .map_err(|error| StoreError::Serialization(error.to_string()))?
1520 } else {
1521 None
1522 };
1523 let mut outcome = CatalogLoadOutcome::default();
1524 let mut ignored_collections = std::collections::HashSet::new();
1525 if retain_canonical_system_config
1526 && !resume.as_ref().is_some_and(|resume| {
1527 resume.collections.contains_key("red_config")
1528 && resume.collections.contains_key("red.config")
1529 })
1530 {
1531 return Err(page_integrity(
1532 1,
1533 None,
1534 "canonical config recovery requires both resume identities",
1535 ));
1536 }
1537 if let Some(resume) = &resume {
1538 for &(legacy, canonical) in
1539 reddb_engine::storage::system_collection_names::LEGACY_NAMES
1540 {
1541 let (Some(legacy_state), Some(canonical_state)) = (
1542 resume.collections.get(legacy),
1543 resume.collections.get(canonical),
1544 ) else {
1545 continue;
1546 };
1547 let merged_resume = merge_shared_alias_resume(legacy_state, canonical_state);
1548 let shared_physical_state = merged_resume.is_some();
1549 let resume_counter_drift = legacy_state != canonical_state;
1550 let (survivor, loser) = match (
1551 legacy_state.entities == 0,
1552 canonical_state.entities == 0,
1553 ) {
1554 _ if retain_canonical_system_config && legacy == "red_config" => {
1555 (canonical, legacy)
1556 }
1557 _ if catalog_plan.is_some_and(|plan| plan.retires(legacy, canonical)) => {
1558 (canonical, legacy)
1559 }
1560 (false, true) => (legacy, canonical),
1561 (true, false) | (true, true) => (canonical, legacy),
1562 (false, false) if shared_physical_state => (canonical, legacy),
1563 (false, false) => {
1564 let (
1565 data_root_match,
1566 key_index_root_match,
1567 secondary_index_state_match,
1568 ) = shared_alias_resume_matches(legacy_state, canonical_state);
1569 return Err(page_integrity(
1570 1,
1571 None,
1572 format!(
1573 "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})"
1574 ),
1575 ));
1576 }
1577 };
1578 ignored_collections.insert(loser.to_string());
1579 outcome.collisions.push(SystemCollectionCollision {
1580 survivor: survivor.to_string(),
1581 loser: loser.to_string(),
1582 canonical: canonical.to_string(),
1583 shared_physical_state,
1584 resume_counter_drift,
1585 merged_resume,
1586 });
1587 }
1588 let live_roots = resume
1589 .collections
1590 .iter()
1591 .filter(|(name, _)| !ignored_collections.contains(name.as_str()))
1592 .flat_map(|(_, state)| {
1593 std::iter::once(state.root_page)
1594 .chain(state.key_index_root)
1595 .chain(
1596 state
1597 .secondary_indices
1598 .values()
1599 .map(|index| index.root_page),
1600 )
1601 })
1602 .filter(|root| *root != 0)
1603 .collect::<std::collections::HashSet<_>>();
1604 for collision in &outcome.collisions {
1605 if collision.shared_physical_state {
1606 continue;
1607 }
1608 let loser_state = resume
1609 .collections
1610 .get(&collision.loser)
1611 .expect("collision loser is present");
1612 if std::iter::once(loser_state.root_page)
1613 .chain(loser_state.key_index_root)
1614 .chain(
1615 loser_state
1616 .secondary_indices
1617 .values()
1618 .map(|index| index.root_page),
1619 )
1620 .filter(|root| *root != 0)
1621 .any(|root| live_roots.contains(&root))
1622 {
1623 return Err(page_integrity(
1624 1,
1625 None,
1626 format!(
1627 "empty collection '{}' aliases a live collection root",
1628 collision.loser
1629 ),
1630 ));
1631 }
1632 }
1633 reddb_engine::storage::system_collection_names::migration_plan(
1634 resume
1635 .collections
1636 .keys()
1637 .map(String::as_str)
1638 .filter(|name| !ignored_collections.contains(*name)),
1639 )
1640 .map_err(|(legacy, canonical)| {
1641 page_integrity(
1642 1,
1643 None,
1644 format!(
1645 "catalog contains both legacy collection '{legacy}' and canonical collection '{canonical}'"
1646 ),
1647 )
1648 })?;
1649 for state in resume.collections.values() {
1650 if !state.secondary_indices.is_empty()
1651 && file_version < reddb_file::file_format::PAGE_SECONDARY_INDEX_FILE_VERSION
1652 {
1653 return Err(page_integrity(
1654 1,
1655 None,
1656 "secondary roots require the file format fence",
1657 ));
1658 }
1659 for root in std::iter::once(state.root_page)
1660 .chain(state.key_index_root)
1661 .chain(
1662 state
1663 .secondary_indices
1664 .values()
1665 .map(|index| index.root_page),
1666 )
1667 {
1668 if root >= page_count {
1669 return Err(page_integrity(
1670 root,
1671 None,
1672 "published root exceeds the database page count",
1673 ));
1674 }
1675 }
1676 }
1677 self.resident
1678 .next_entity_id
1679 .fetch_max(resume.entity_id_next, Ordering::SeqCst);
1680 }
1681 *self.recovery_xid_summary.write() = resume.as_ref().and_then(|resume| {
1682 resume.xid_high_water_mark.map(|high_water_mark| {
1683 super::recovery_xid::RecoveryXidSummary {
1684 high_water_mark,
1685 roots: resume
1686 .collections
1687 .iter()
1688 .filter(|(name, _)| !ignored_collections.contains(name.as_str()))
1689 .map(|(name, state)| {
1690 (
1691 reddb_engine::storage::system_collection_names::canonical_or_self(name)
1692 .to_owned(),
1693 state.root_page,
1694 )
1695 })
1696 .collect(),
1697 }
1698 })
1699 });
1700 if content.len() >= 4 {
1701 let mut pos = 0;
1702 let mut format_version = STORE_VERSION_V1;
1703
1704 let collection_count = if let Some(header) =
1705 reddb_file::decode_native_paged_metadata_header(content)
1706 .map_err(|err| StoreError::Serialization(err.to_string()))?
1707 {
1708 format_version = header.format_version;
1709 pos += reddb_file::METADATA_HEADER_BYTES;
1710 header.collection_count as usize
1711 } else {
1712 let count = u32::from_le_bytes([
1713 content[pos],
1714 content[pos + 1],
1715 content[pos + 2],
1716 content[pos + 3],
1717 ]) as usize;
1718 pos += 4;
1719 count
1720 };
1721
1722 self.set_format_version(format_version);
1723
1724 if pos > content.len() {
1725 return Ok(CatalogLoadOutcome::default());
1726 }
1727
1728 let mut source_names = std::collections::HashMap::<String, String>::new();
1729 let mut migrated_catalog = !outcome.collisions.is_empty();
1730 let mut empty_duplicate_roots = std::collections::HashSet::new();
1731 let mut recovery_config_roots = [0u8; 2];
1732 let mut scanned_xid_high_water_mark = 0u64;
1736
1737 for _ in 0..collection_count {
1739 let root = reddb_file::decode_native_paged_collection_root(content, &mut pos)
1740 .map_err(|err| page_integrity(1, None, err.to_string()))?;
1741 let root_page = root.root_page;
1743 let stored_name = root.collection;
1744 if retain_canonical_system_config {
1745 let config_index = match stored_name.as_str() {
1746 "red_config" => Some(0),
1747 "red.config" => Some(1),
1748 _ => None,
1749 };
1750 if let Some(index) = config_index {
1751 recovery_config_roots[index] =
1752 recovery_config_roots[index].saturating_add(1);
1753 let state = resume
1754 .as_ref()
1755 .and_then(|resume| resume.collections.get(&stored_name))
1756 .expect("both recovery identities were checked");
1757 if state.root_page != root_page {
1758 return Err(page_integrity(
1759 1,
1760 None,
1761 "config collection root differs from resume catalog",
1762 ));
1763 }
1764 }
1765 }
1766 if ignored_collections.contains(&stored_name) {
1767 let shared_physical_state = outcome
1768 .collisions
1769 .iter()
1770 .find(|collision| collision.loser == stored_name)
1771 .is_some_and(|collision| collision.shared_physical_state);
1772 if shared_physical_state {
1773 continue;
1774 }
1775 let state = resume
1776 .as_ref()
1777 .and_then(|resume| resume.collections.get(&stored_name))
1778 .expect("ignored collection was proven by catalog resume");
1779 for retired_root in std::iter::once(state.root_page)
1780 .chain(state.key_index_root)
1781 .chain(
1782 state
1783 .secondary_indices
1784 .values()
1785 .map(|index| index.root_page),
1786 )
1787 .filter(|root| *root != 0)
1788 {
1789 empty_duplicate_roots.insert(retired_root);
1790 }
1791 continue;
1792 }
1793 let name = reddb_engine::storage::system_collection_names::canonical_or_self(
1794 &stored_name,
1795 )
1796 .to_owned();
1797 if let Some(previous) = source_names.insert(name.clone(), stored_name.clone()) {
1798 if previous != stored_name {
1799 return Err(page_integrity(
1800 1,
1801 None,
1802 format!(
1803 "catalog contains both legacy collection '{previous}' and canonical collection '{name}'"
1804 ),
1805 ));
1806 }
1807 }
1808 migrated_catalog |= name != stored_name;
1809
1810 let _ = self.create_collection_in_memory(&name);
1814
1815 let shared_alias = outcome.collisions.iter().find(|collision| {
1816 collision.survivor == stored_name && collision.shared_physical_state
1817 });
1818 let counters = shared_alias
1819 .and_then(|collision| collision.merged_resume.as_ref())
1820 .or_else(|| {
1821 resume
1822 .as_ref()
1823 .and_then(|resume| resume.collections.get(&stored_name))
1824 });
1825 if let (Some(counters), Some(manager)) = (counters, self.get_collection(&name))
1826 {
1827 manager.restore_identity_counters(
1828 counters.entity_id_next,
1829 counters.row_id_next,
1830 );
1831 manager.restore_sequence_id_next(counters.sequence_id_next);
1832 }
1833 if root_page > 0 || counters.is_some() {
1835 let btree = BTree::with_root(Arc::clone(pager), root_page);
1836
1837 let mut cursor = btree.cursor_first().map_err(|err| {
1839 page_integrity(root_page, Some(name.clone()), err.to_string())
1840 })?;
1841 let manager = self.get_collection(&name);
1842 let hydrate_read_only =
1843 self.is_read_only() && self.config.resident_memory_bytes_max.is_none();
1844 let mut persisted_entities = if hydrate_read_only {
1845 0
1846 } else {
1847 counters.map_or(0, |state| state.entities)
1848 };
1849 while counters.is_none() || hydrate_read_only {
1850 let next = cursor
1851 .next_with_budget(
1852 self.config
1853 .resident_memory_bytes_max
1854 .and_then(|limit| usize::try_from(limit).ok())
1855 .unwrap_or(usize::MAX),
1856 )
1857 .map_err(|err| {
1858 page_integrity(root_page, Some(name.clone()), err.to_string())
1859 })?;
1860 let Some((key, value)) = next else {
1861 break;
1862 };
1863 persisted_entities += 1;
1864 let key_bytes: [u8; 8] = key.as_slice().try_into().map_err(|_| {
1866 page_integrity(
1867 root_page,
1868 Some(name.clone()),
1869 "physical entity key must contain eight bytes",
1870 )
1871 })?;
1872 let record = reddb_engine::storage::collection_read::EncodedRecord {
1873 id: EntityId::new(u64::from_be_bytes(key_bytes)),
1874 bytes: value,
1875 };
1876 let decoded = if let Some(limit) = self.config.resident_memory_bytes_max
1877 {
1878 record.decode_with_budget(
1879 self.format_version(),
1880 usize::try_from(limit).unwrap_or(usize::MAX),
1881 )
1882 } else {
1883 record.decode(self.format_version())
1884 };
1885 let (entity, metadata) = decoded.map_err(|err| {
1886 page_integrity(root_page, Some(name.clone()), err.to_string())
1887 })?;
1888 scanned_xid_high_water_mark = scanned_xid_high_water_mark
1889 .max(entity.xmin)
1890 .max(entity.xmax);
1891 if let Some(m) = &manager {
1892 let id = entity.id;
1893 if let EntityKind::TableRow { row_id, .. } = &entity.kind {
1894 m.register_row_id(*row_id);
1895 }
1896 if self.config.resident_memory_bytes_max.is_some() {
1897 self.register_entity_id(id);
1898 m.restore_sequence_id_next(
1899 entity.sequence_id.saturating_add(1),
1900 );
1901 m.restore_identity_counters(
1902 id.raw().saturating_add(1),
1903 m.identity_counters().1,
1904 );
1905 continue;
1906 }
1907 self.resident.context_index.index_entity(&name, &entity);
1908 m.restore_snapshot_entity(entity.clone())?;
1909 if let Some(metadata) = metadata {
1910 let _ = m.set_metadata(id, metadata);
1911 }
1912 self.register_entity_id(id);
1913 if self.config.auto_index_refs {
1914 self.index_cross_refs(&entity, &name)?;
1915 }
1916 }
1917 }
1918
1919 let btree = Arc::new(btree);
1921 if shared_alias.is_some_and(|collision| collision.resume_counter_drift) {
1922 persisted_entities = u64::try_from(btree.count().map_err(|err| {
1923 page_integrity(root_page, Some(name.clone()), err.to_string())
1924 })?)
1925 .map_err(|_| {
1926 page_integrity(
1927 root_page,
1928 Some(name.clone()),
1929 "shared collection entity count exceeds u64",
1930 )
1931 })?;
1932 }
1933 let key_tree = counters
1934 .and_then(|state| state.key_index_root)
1935 .map(|root| Arc::new(BTree::with_root(Arc::clone(pager), root)));
1936 if let Some(counters) = counters {
1937 self.key_indices.write().insert(
1938 name.clone(),
1939 PublishedKeyIndex {
1940 indexed: key_tree.is_some(),
1941 secondary: counters.secondary_indices.iter().map(|(name, index)| {
1942 (name.clone(), super::secondary_index::PublishedSecondaryIndex {
1943 identity: super::secondary_index::SecondaryIndexIdentity {
1944 codec_version: index.codec_version,
1945 definition: index.definition,
1946 },
1947 tree: Arc::new(BTree::with_root(Arc::clone(pager), index.root_page)),
1948 })
1949 }).collect(),
1950 source: Arc::downgrade(&btree),
1951 tree: key_tree.clone().unwrap_or_else(|| {
1952 Arc::new(BTree::with_root(Arc::clone(pager), 0))
1953 }),
1954 },
1955 );
1956 }
1957 if let (Some(manager), Some(limit)) =
1958 (&manager, self.config.resident_memory_bytes_max)
1959 {
1960 manager.install_backing(
1961 Arc::new(
1962 match key_tree {
1963 Some(keys) => reddb_engine::storage::collection_read::PagedCollection::with_key_index(Arc::clone(&btree), keys),
1964 None => reddb_engine::storage::collection_read::PagedCollection::new(Arc::clone(&btree)),
1965 },
1966 ),
1967 btree.root_page_id(),
1968 format_version,
1969 usize::try_from(limit).unwrap_or(usize::MAX),
1970 persisted_entities,
1971 );
1972 }
1973 self.btree_indices.write().insert(name.clone(), btree);
1974 if format_version == STORE_VERSION_V11 {
1975 if let Some(manager) = &manager {
1976 self.checkpoint_versions.write().insert(
1977 name,
1978 CheckpointVersion {
1979 manager: Arc::downgrade(manager),
1980 segments: manager.checkpoint_revisions(),
1981 visibility_revision: 0,
1982 untracked_visibility_revision: 0,
1983 excluded_xids: Arc::default(),
1984 pending_vault: Arc::default(),
1985 format_version,
1986 deferred_rows: Some(Arc::default()),
1987 has_unprojected_rows: false,
1988 },
1989 );
1990 }
1991 }
1992 }
1993 }
1994
1995 if retain_canonical_system_config && recovery_config_roots != [1, 1] {
1996 return Err(page_integrity(
1997 1,
1998 None,
1999 "canonical config recovery requires exactly one root for each identity",
2000 ));
2001 }
2002 if retain_canonical_system_config
2003 && (format_version < STORE_VERSION_V2 || pos + 4 > content.len())
2004 {
2005 return Err(page_integrity(
2006 1,
2007 None,
2008 "canonical config recovery requires a complete cross-reference catalog",
2009 ));
2010 }
2011 if format_version >= STORE_VERSION_V2 && pos + 4 <= content.len() {
2012 let cross_ref_count = u32::from_le_bytes([
2013 content[pos],
2014 content[pos + 1],
2015 content[pos + 2],
2016 content[pos + 3],
2017 ]) as usize;
2018 pos += 4;
2019
2020 for _ in 0..cross_ref_count {
2021 let cross_ref =
2022 match reddb_file::decode_native_paged_cross_ref(content, &mut pos) {
2023 Ok(cross_ref) => cross_ref,
2024 Err(error) if retain_canonical_system_config => {
2025 return Err(page_integrity(1, None, error.to_string()));
2026 }
2027 Err(_) => break,
2028 };
2029 if retain_canonical_system_config
2030 && cross_ref.target_collection == "red_config"
2031 {
2032 return Err(page_integrity(
2033 1,
2034 None,
2035 "legacy red_config has cross-references and cannot be retired",
2036 ));
2037 }
2038 let source_id = EntityId::new(cross_ref.source_id);
2039 let target_id = EntityId::new(cross_ref.target_id);
2040 let ref_type = RefType::from_byte(cross_ref.ref_type);
2041 let target_collection =
2042 reddb_engine::storage::system_collection_names::canonical_owned(
2043 cross_ref.target_collection,
2044 );
2045
2046 self.resident
2047 .cross_refs
2048 .write()
2049 .entry(source_id)
2050 .or_default()
2051 .push((target_id, ref_type, target_collection.clone()));
2052
2053 if let Some((collection, mut entity)) = self.get_any(source_id) {
2054 let exists = entity.cross_refs().iter().any(|xref| {
2055 xref.target == target_id
2056 && xref.ref_type == ref_type
2057 && xref.target_collection == target_collection
2058 });
2059 if !exists {
2060 entity.cross_refs_mut().push(CrossRef::new(
2061 source_id,
2062 target_id,
2063 target_collection.clone(),
2064 ref_type,
2065 ));
2066 if let Some(manager) = self.get_collection(&collection) {
2067 let _ = manager.update(entity);
2068 }
2069 }
2070 }
2071 }
2072 }
2073 let retired = reddb_file::decode_native_paged_retired_trees(content, &mut pos)
2074 .map_err(|e| StoreError::Serialization(e.to_string()))?;
2075 let already_retired = retired
2076 .iter()
2077 .copied()
2078 .collect::<std::collections::HashSet<_>>();
2079 let mut live_roots: std::collections::HashSet<_> = self
2080 .btree_indices
2081 .read()
2082 .values()
2083 .map(|tree| tree.root_page_id())
2084 .collect();
2085 live_roots.extend(
2086 self.key_indices
2087 .read()
2088 .values()
2089 .flat_map(PublishedKeyIndex::trees)
2090 .map(|tree| tree.root_page_id()),
2091 );
2092 for root in retired {
2093 if live_roots.contains(&root)
2094 || root
2095 >= pager
2096 .page_count()
2097 .map_err(|e| StoreError::Io(std::io::Error::other(e.to_string())))?
2098 {
2099 return Err(page_integrity(
2100 root,
2101 None,
2102 "retired root aliases a live or missing page",
2103 ));
2104 }
2105 self.retired_btrees.lock().push(RetiredBTree {
2106 tree: Arc::new(BTree::with_root(Arc::clone(pager), root)),
2107 published: true,
2108 });
2109 }
2110 for root in empty_duplicate_roots
2111 .iter()
2112 .copied()
2113 .filter(|root| !already_retired.contains(root))
2114 {
2115 self.retire_btree(Arc::new(BTree::with_root(Arc::clone(pager), root)));
2116 }
2117 let deferred = reddb_file::decode_native_paged_deferred_pages(content, &mut pos)
2118 .map_err(|e| StoreError::Serialization(e.to_string()))?;
2119 if deferred
2120 .iter()
2121 .any(|id| live_roots.contains(id) || empty_duplicate_roots.contains(id))
2122 {
2123 return Err(page_integrity(1, None, "deferred page aliases a live root"));
2124 }
2125 pager
2126 .restore_deferred_reclamation(&deferred)
2127 .map_err(|e| StoreError::Io(std::io::Error::other(e.to_string())))?;
2128 let ownership_epoch =
2129 reddb_file::native_store::decode_native_paged_ownership_epoch(
2130 content, &mut pos,
2131 )
2132 .map_err(|e| StoreError::Serialization(e.to_string()))?;
2133 self.ownership_catalog_epoch
2134 .store(ownership_epoch.unwrap_or(0), Ordering::Release);
2135 if migrated_catalog {
2136 self.mark_paged_registry_dirty();
2137 }
2138 if self.format_version() < STORE_VERSION_V11 {
2139 self.set_format_version(STORE_VERSION_V11);
2140 }
2141 if self.config.resident_memory_bytes_max.is_some()
2142 && resume.is_none()
2143 && !migrated_catalog
2144 && !retain_canonical_system_config
2145 {
2146 self.remember_startup_xid_high_water_mark(scanned_xid_high_water_mark);
2147 }
2148 outcome.migrated = migrated_catalog;
2149 return Ok(outcome);
2150 }
2151 }
2152
2153 if retain_canonical_system_config {
2154 return Err(page_integrity(
2155 1,
2156 None,
2157 "canonical config recovery requires a complete catalog",
2158 ));
2159 }
2160 if self.format_version() < STORE_VERSION_V11 {
2161 self.set_format_version(STORE_VERSION_V11);
2162 }
2163
2164 Ok(CatalogLoadOutcome::default())
2165 }
2166
2167 pub(crate) fn deserialize_entity(
2169 data: &[u8],
2170 format_version: u32,
2171 ) -> Result<UnifiedEntity, StoreError> {
2172 let mut pos = 0;
2173 Self::read_entity_binary(data, &mut pos, format_version)
2174 .map_err(|e| StoreError::Serialization(e.to_string()))
2175 }
2176
2177 pub(crate) fn serialize_entity(entity: &UnifiedEntity, format_version: u32) -> Vec<u8> {
2179 let mut buf = Vec::with_capacity(256);
2184 Self::write_entity_binary(&mut buf, entity, format_version);
2185 buf
2186 }
2187
2188 pub(crate) fn serialize_entity_record(
2189 entity: &UnifiedEntity,
2190 metadata: Option<&Metadata>,
2191 format_version: u32,
2192 ) -> Vec<u8> {
2193 reddb_engine::storage::metadata_codec::serialize_entity_record(
2194 entity,
2195 metadata,
2196 format_version,
2197 )
2198 }
2199
2200 pub(crate) fn deserialize_entity_record(
2201 data: &[u8],
2202 format_version: u32,
2203 ) -> Result<(UnifiedEntity, Option<Metadata>), StoreError> {
2204 reddb_engine::storage::metadata_codec::deserialize_entity_record(data, format_version)
2205 .map_err(StoreError::Serialization)
2206 }
2207
2208 pub fn persist(&self) -> Result<(), StoreError> {
2213 self.persist_inner(false)
2214 }
2215
2216 pub(crate) fn persist_after_runtime_snapshots(&self) -> Result<(), StoreError> {
2217 self.persist_inner(true)
2218 }
2219
2220 fn persist_inner(&self, runtime_snapshots_durable: bool) -> Result<(), StoreError> {
2221 if self.is_read_only() {
2222 return Ok(());
2223 }
2224 let _vault_guard = self.vault_publication_lock.lock();
2225 let pending_vault_versions = self.pending_vault_versions.read();
2226 let pager = match &self.pager {
2227 Some(p) => p,
2228 None => {
2229 if let Some(path) = &self.db_path {
2231 return self
2232 .save_to_file(path)
2233 .map_err(|e| StoreError::Serialization(e.to_string()));
2234 }
2235 return Err(StoreError::Io(std::io::Error::other(
2236 "No pager or path configured for persistence",
2237 )));
2238 }
2239 };
2240 let _ownership_guard = pager.lock_ownership_mutations();
2243
2244 match pager.read_page(1) {
2245 Ok(_) => {}
2246 Err(PagerError::PageNotFound(_)) => {
2247 let meta_page = pager
2248 .allocate_page(crate::storage::engine::PageType::Header)
2249 .map_err(|e| StoreError::Io(std::io::Error::other(e.to_string())))?;
2250 pager
2251 .write_page(meta_page.page_id(), meta_page)
2252 .map_err(|e| StoreError::Io(std::io::Error::other(e.to_string())))?;
2253 }
2254 Err(e) => {
2255 return Err(StoreError::Io(std::io::Error::other(e.to_string())));
2256 }
2257 }
2258
2259 if let Some(commit) = &self.commit {
2260 commit.force_sync().map_err(StoreError::Io)?;
2261 }
2262
2263 struct InterruptedOwnership<'a> {
2264 pager: &'a Pager,
2265 active: bool,
2266 }
2267 impl Drop for InterruptedOwnership<'_> {
2268 fn drop(&mut self) {
2269 if self.active {
2270 self.pager.fence_interrupted_ownership();
2271 }
2272 }
2273 }
2274 let mut ownership = InterruptedOwnership {
2275 pager,
2276 active: self.commit.is_some(),
2277 };
2278 let epoch = if let Some(commit) = &self.commit {
2279 let epoch = commit
2280 .next_page_ownership_epoch(self)
2281 .map_err(StoreError::Io)?;
2282 commit
2283 .begin_page_ownership_epoch(self, epoch)
2284 .map_err(StoreError::Io)?;
2285 Some(epoch)
2286 } else {
2287 None
2288 };
2289
2290 let collections = self.resident.collections.read();
2291 let mut btree_indices = self.btree_indices.write();
2292
2293 let mut collection_roots: Vec<(String, u32)> = Vec::new();
2295
2296 let mut rebuilt_indices = HashMap::with_capacity(collections.len());
2299 let previous_versions = self.checkpoint_versions.read();
2300 let mut checkpoint_versions = HashMap::with_capacity(collections.len());
2301 let pending_vault = Arc::new(pending_vault_versions.clone());
2302 let (visibility_revision, excluded, untracked_visibility_revision) =
2303 self.snapshot_manager.get().map_or_else(
2304 || (0, Default::default(), 0),
2305 |manager| manager.checkpoint_projection_state(),
2306 );
2307 let excluded = Arc::new(excluded);
2308 for (name, manager) in collections.iter() {
2309 let mut version = CheckpointVersion {
2310 manager: Arc::downgrade(manager),
2311 segments: manager.checkpoint_revisions(),
2312 visibility_revision,
2313 untracked_visibility_revision,
2314 excluded_xids: Arc::clone(&excluded),
2315 pending_vault: Arc::clone(&pending_vault),
2316 format_version: self.format_version(),
2317 deferred_rows: Some(Arc::default()),
2318 has_unprojected_rows: false,
2319 };
2320 if let (Some(previous), Some(tree)) =
2321 (previous_versions.get(name), btree_indices.get(name))
2322 {
2323 if previous.manager.ptr_eq(&version.manager)
2324 && previous.segments == version.segments
2325 && previous.format_version == version.format_version
2326 && !previous.has_unprojected_rows
2327 && checkpoint_visibility_matches(previous, &version)
2328 {
2329 version.deferred_rows = previous.deferred_rows.clone();
2330 collection_roots.push((name.clone(), tree.root_page_id()));
2331 rebuilt_indices.insert(name.clone(), Arc::clone(tree));
2332 checkpoint_versions.insert(name.clone(), version);
2333 continue;
2334 }
2335 }
2336 if let (Some(previous), Some(tree)) =
2337 (previous_versions.get(name), btree_indices.get(name))
2338 {
2339 if previous.manager.ptr_eq(&version.manager)
2340 && previous.format_version == version.format_version
2341 {
2342 if let Some(tree) =
2343 self.sparse_checkpoint_tree(name, manager, tree, previous, &mut version)?
2344 {
2345 collection_roots.push((name.clone(), tree.root_page_id()));
2346 rebuilt_indices.insert(name.clone(), tree);
2347 checkpoint_versions.insert(name.clone(), version);
2348 continue;
2349 }
2350 }
2351 }
2352 let btree = self.checkpoint_tree(
2353 pager,
2354 manager,
2355 btree_indices.get(name),
2356 |entity, metadata| {
2357 #[cfg(test)]
2358 CHECKPOINT_SERIALIZED_ROWS.with(|count| count.set(count.get() + 1));
2359 let hidden = pending_vault_versions.contains(&entity.id)
2360 || (matches!(
2361 entity.data,
2362 EntityData::Row(_)
2363 | EntityData::Vector(_)
2364 | EntityData::QueueMessage(_)
2365 ) && excluded.contains(&entity.xmin));
2366 let normalize_xmax = matches!(
2367 entity.data,
2368 EntityData::Row(_) | EntityData::QueueMessage(_)
2369 ) && excluded.contains(&entity.xmax);
2370 if hidden || normalize_xmax {
2371 version.remember_deferred_row(entity.id);
2372 }
2373 if hidden {
2374 return None;
2375 }
2376 let mut normalized = std::borrow::Cow::Borrowed(entity);
2377 if normalize_xmax {
2378 normalized.to_mut().xmax = 0;
2379 }
2380 Some(Self::serialize_entity_record(
2381 &normalized,
2382 metadata.as_ref(),
2383 self.format_version(),
2384 ))
2385 },
2386 )?;
2387 checkpoint_versions.insert(name.clone(), version);
2388
2389 collection_roots.push((name.clone(), btree.root_page_id()));
2390 rebuilt_indices.insert(name.clone(), btree);
2391 }
2392
2393 let mut meta_data = Vec::with_capacity(4096);
2395
2396 let format_version = STORE_VERSION_V11;
2397 self.set_format_version(format_version);
2398
2399 reddb_file::encode_native_paged_metadata_header(
2400 &mut meta_data,
2401 reddb_file::NativePagedMetadataHeader {
2402 format_version,
2403 collection_count: collection_roots.len() as u32,
2404 },
2405 );
2406
2407 for (name, root_page) in &collection_roots {
2409 reddb_file::encode_native_paged_collection_root(&mut meta_data, name, *root_page);
2410 }
2411
2412 let cross_refs = self.resident.cross_refs.read();
2414 let total_refs: usize = cross_refs.values().map(|v| v.len()).sum();
2415 meta_data.extend_from_slice(&(total_refs as u32).to_le_bytes());
2416 for (source_id, refs) in cross_refs.iter() {
2417 for (target_id, ref_type, collection) in refs {
2418 reddb_file::encode_native_paged_cross_ref(
2419 &mut meta_data,
2420 source_id.raw(),
2421 target_id.raw(),
2422 ref_type.to_byte(),
2423 collection,
2424 );
2425 }
2426 }
2427
2428 for (name, previous) in btree_indices.iter() {
2429 if !rebuilt_indices
2430 .get(name)
2431 .is_some_and(|tree| Arc::ptr_eq(tree, previous))
2432 {
2433 self.retire_btree(Arc::clone(previous));
2434 }
2435 }
2436 #[cfg(test)]
2437 if std::env::var_os("REDDB_EXPERIMENTAL_EXIT_DURING_CHECKPOINT_REBUILD").is_some() {
2438 pager
2439 .sync()
2440 .map_err(|error| StoreError::Io(std::io::Error::other(error.to_string())))?;
2441 eprintln!("experimental checkpoint exit before manifest");
2442 std::process::exit(183);
2443 }
2444 let rebuilt_keys = self.checkpoint_key_indices(pager, &rebuilt_indices)?;
2445 #[cfg(test)]
2446 if std::env::var_os("REDDB_TEST_EXIT_AFTER_SECONDARY_INDEX_BUILD").is_some() {
2447 pager
2448 .sync()
2449 .map_err(|error| StoreError::Io(std::io::Error::other(error.to_string())))?;
2450 std::process::exit(196);
2451 }
2452 self.publish_reclaimable_trees(pager, &mut meta_data, &rebuilt_indices, &rebuilt_keys)?;
2453
2454 #[cfg(test)]
2455 if std::env::var_os("REDDB_TEST_EXIT_AFTER_UNDO_CATALOG_PUBLICATION").is_some() {
2456 std::process::exit(195);
2457 }
2458
2459 *btree_indices = rebuilt_indices;
2462 *self.key_indices.write() = rebuilt_keys;
2463 drop(previous_versions);
2464 *self.checkpoint_versions.write() = checkpoint_versions;
2465
2466 if let Some(commit) = &self.commit {
2467 commit
2468 .finish_page_ownership_epoch(self, epoch.expect("WAL ownership epoch"))
2469 .map_err(StoreError::Io)?;
2470 ownership.active = false;
2471 if runtime_snapshots_durable {
2472 commit
2473 .truncate_after_runtime_checkpoint()
2474 .map_err(StoreError::Io)?;
2475 } else {
2476 commit.truncate().map_err(StoreError::Io)?;
2477 }
2478 }
2479 *self.checkpoint_durable_rows.write() = Default::default();
2480
2481 if let Some(limit) = self.config.resident_memory_bytes_max {
2482 let mut versions = self.checkpoint_versions.write();
2483 for (name, manager) in collections.iter() {
2484 if versions
2485 .get(name)
2486 .is_some_and(|version| !version.has_unprojected_rows)
2487 {
2488 if let Some(tree) = btree_indices.get(name) {
2489 manager.install_backing(
2490 Arc::new(
2491 {
2492 let keys = self.key_indices.read();
2493 let index = keys.get(name).expect("invariant: prepared checkpoint classifies its key index");
2494 if index.indexed {
2495 reddb_engine::storage::collection_read::PagedCollection::with_key_index(Arc::clone(tree), Arc::clone(&index.tree))
2496 } else {
2497 reddb_engine::storage::collection_read::PagedCollection::new(Arc::clone(tree))
2498 }
2499 },
2500 ),
2501 tree.root_page_id(),
2502 self.format_version(),
2503 usize::try_from(limit).unwrap_or(usize::MAX),
2504 manager.count() as u64,
2505 );
2506 if let Some(version) = versions.get_mut(name) {
2507 version.segments = manager.checkpoint_revisions();
2508 }
2509 }
2510 }
2511 }
2512 }
2513
2514 Ok(())
2515 }
2516
2517 pub fn is_paged(&self) -> bool {
2519 self.pager.is_some()
2520 }
2521
2522 pub(crate) fn collection_root_page(&self, collection: &str) -> Option<u32> {
2525 self.btree_indices
2526 .read()
2527 .get(Self::canonical_collection(collection))
2528 .map(|btree| btree.root_page_id())
2529 .filter(|root| *root != 0)
2530 }
2531
2532 pub(crate) fn operational_manifest_for_path(
2535 &self,
2536 path: &Path,
2537 ) -> reddb_file::OperationalManifest {
2538 let mut cached = self.operational_manifest.lock();
2539 if let Some((current, manifest)) = cached.as_ref() {
2540 if current == path {
2541 return manifest.clone();
2542 }
2543 }
2544 let manifest = reddb_file::OperationalManifest::for_db_path(path);
2545 *cached = Some((path.to_owned(), manifest.clone()));
2546 manifest
2547 }
2548
2549 pub fn db_path(&self) -> Option<&Path> {
2551 self.db_path.as_deref()
2552 }
2553}
2554
2555#[cfg(test)]
2556mod checkpoint_work_tests {
2557 use super::*;
2558 use reddb_file::PAGED_PAGE_SIZE as PAGE_SIZE;
2559
2560 struct SystemNameProjection;
2561
2562 impl super::super::secondary_index::SecondaryIndexProjector for SystemNameProjection {
2563 fn project(
2564 &self,
2565 entity: &UnifiedEntity,
2566 key: &mut [u8; super::super::secondary_index::SECONDARY_KEY_BYTES_MAX],
2567 ) -> Result<Option<usize>, StoreError> {
2568 key[..8].copy_from_slice(&entity.id.raw().to_be_bytes());
2569 Ok(Some(8))
2570 }
2571 }
2572
2573 #[cfg(target_os = "linux")]
2574 #[test]
2575 fn interrupted_inline_sparse_checkpoint_replays_durable_rows() {
2576 const CHILD: &str = "REDDB_TEST_INLINE_SPARSE_PATH";
2577 if let Some(path) = std::env::var_os(CHILD) {
2578 let insert = std::env::var_os("REDDB_TEST_INLINE_SPARSE_INSERT").is_some();
2579 let multi = std::env::var_os("REDDB_TEST_INLINE_SPARSE_MULTI").is_some();
2580 let store = UnifiedStore::open(path).expect("open store");
2581 let initial_count = if multi { 600 } else { 1 };
2582 for id in 1..=initial_count {
2583 store.insert_auto("rows", row(id, 1)).expect("seed");
2584 }
2585 store.persist().expect("baseline");
2586 let manager = store.get_collection("rows").expect("collection");
2587 let ids: Vec<u64> = if multi {
2588 (1..=initial_count).collect()
2589 } else {
2590 vec![if insert { 2 } else { 1 }]
2591 };
2592 let mut actions = Vec::new();
2593 for id in ids {
2594 if insert {
2595 manager.insert(row(id, 9)).expect("insert version");
2596 } else {
2597 manager.update(row(id, 9)).expect("update version");
2598 }
2599 let entity = manager.get(EntityId::new(id)).expect("changed entity");
2600 actions.push(StoreWalAction::upsert_entity(
2601 "rows",
2602 &entity,
2603 None,
2604 store.format_version(),
2605 ));
2606 }
2607 let references = store
2608 .commit
2609 .as_ref()
2610 .expect("coordinator")
2611 .append_checkpoint_actions(&actions)
2612 .expect("durable WAL");
2613 store.remember_checkpoint_rows(actions, references);
2614 store.persist().expect("exit after sparse write");
2615 panic!("inline sparse crash hook was not reached");
2616 }
2617 for (insert, multi) in [(false, false), (true, false), (false, true)] {
2618 let directory = tempfile::tempdir().expect("temporary store");
2619 let path = directory.path().join("data.rdb");
2620 let mut command =
2621 std::process::Command::new(std::env::current_exe().expect("test executable"));
2622 command
2623 .args(["--exact", "storage::unified::store::impl_pages::checkpoint_work_tests::interrupted_inline_sparse_checkpoint_replays_durable_rows", "--nocapture"])
2624 .env(CHILD, &path)
2625 .env("REDDB_TEST_EXIT_AFTER_INLINE_SPARSE_WRITE", "1");
2626 if insert {
2627 command.env("REDDB_TEST_INLINE_SPARSE_INSERT", "1");
2628 }
2629 if multi {
2630 command.env("REDDB_TEST_INLINE_SPARSE_MULTI", "1");
2631 }
2632 let output = command.output().expect("crash child");
2633 assert_eq!(
2634 output.status.code(),
2635 Some(190),
2636 "insert={insert} multi={multi}: {}",
2637 String::from_utf8_lossy(&output.stderr)
2638 );
2639 let recovered = UnifiedStore::open(&path).expect("recover");
2640 let ids: Vec<u64> = if multi {
2641 (1..=600).collect()
2642 } else {
2643 vec![if insert { 2 } else { 1 }]
2644 };
2645 for id in ids {
2646 assert_eq!(
2647 recovered
2648 .get("rows", EntityId::new(id))
2649 .expect("recovered row")
2650 .data
2651 .as_row()
2652 .expect("row")
2653 .columns,
2654 vec![Value::Integer(9)]
2655 );
2656 }
2657 assert_eq!(
2658 recovered.get_collection("rows").expect("rows").count(),
2659 if multi {
2660 600
2661 } else if insert {
2662 2
2663 } else {
2664 1
2665 }
2666 );
2667 }
2668 }
2669
2670 #[test]
2671 fn catalog_ownership_epoch_survives_reopen_and_later_publication() {
2672 let directory = tempfile::tempdir().expect("temporary store");
2673 let path = directory.path().join("data.rdb");
2674 {
2675 let store = UnifiedStore::open(&path).expect("open store");
2676 store.create_collection("rows").expect("create collection");
2677 store.persist().expect("publish ownership epoch");
2678 assert_eq!(store.ownership_catalog_epoch.load(Ordering::Acquire), 1);
2679 store.persist().expect("publish next ownership epoch");
2680 }
2681 let reopened = UnifiedStore::open(&path).expect("reopen store");
2682 assert_eq!(reopened.ownership_catalog_epoch.load(Ordering::Acquire), 2);
2683 }
2684
2685 #[test]
2686 fn metadata_overflow_with_missing_continuation_fails_closed() {
2687 use crate::storage::engine::{Page, PageType, HEADER_SIZE};
2688
2689 let directory = tempfile::tempdir().expect("temporary store");
2690 let store = UnifiedStore::open(directory.path().join("data.rdb")).expect("paged store");
2691 let pager = store.pager.as_ref().unwrap();
2692 let valid_payload = vec![42; META_PAGE_CONTENT_CAP * 3];
2693 let valid_manifest = build_meta_page1_with_overflow(pager, &valid_payload).unwrap();
2694 pager.write_page(1, valid_manifest).unwrap();
2695 assert!(!existing_overflow_chain(pager).unwrap().is_empty());
2696 assert_eq!(read_meta_payload(pager).unwrap().unwrap(), valid_payload);
2697
2698 let missing = pager.page_count().unwrap() + 100;
2699 let mut manifest = Page::new(PageType::Header, 1);
2700 reddb_file::encode_native_metadata_overflow_header(
2701 &mut manifest.as_bytes_mut()[HEADER_SIZE..],
2702 reddb_file::NativeMetadataOverflowHeader {
2703 format_version: STORE_VERSION_V11,
2704 total_payload_bytes: META_PAGE_CONTENT_CAP as u32 + 1,
2705 next_overflow_page_id: missing,
2706 },
2707 )
2708 .unwrap();
2709 pager.write_page(1, manifest).unwrap();
2710
2711 assert!(
2712 existing_overflow_chain(pager).is_err(),
2713 "a missing continuation must not be treated as an empty chain"
2714 );
2715
2716 let wrong_type = pager.allocate_page(PageType::BTreeLeaf).unwrap();
2717 let mut manifest = Page::new(PageType::Header, 1);
2718 reddb_file::encode_native_metadata_overflow_header(
2719 &mut manifest.as_bytes_mut()[HEADER_SIZE..],
2720 reddb_file::NativeMetadataOverflowHeader {
2721 format_version: STORE_VERSION_V11,
2722 total_payload_bytes: META_PAGE_CONTENT_CAP as u32 + 1,
2723 next_overflow_page_id: wrong_type.page_id(),
2724 },
2725 )
2726 .unwrap();
2727 pager.write_page(1, manifest).unwrap();
2728 assert!(
2729 existing_overflow_chain(pager).is_err(),
2730 "a live page must not be mistaken for metadata overflow"
2731 );
2732 assert!(
2733 read_meta_payload(pager).is_err(),
2734 "a live page must not be read as metadata overflow"
2735 );
2736
2737 let mut cycle = pager.allocate_page(PageType::Overflow).unwrap();
2738 let cycle_id = cycle.page_id();
2739 reddb_file::encode_native_metadata_overflow_continuation_header(
2740 &mut cycle.as_bytes_mut()[HEADER_SIZE..],
2741 reddb_file::NativeMetadataOverflowContinuationHeader {
2742 next_overflow_page_id: cycle_id,
2743 chunk_bytes: 1,
2744 },
2745 )
2746 .unwrap();
2747 pager.write_page(cycle_id, cycle).unwrap();
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_V3_FIRST_PAYLOAD_CAP as u32 + 3,
2754 next_overflow_page_id: cycle_id,
2755 },
2756 )
2757 .unwrap();
2758 pager.write_page(1, manifest).unwrap();
2759 assert!(
2760 read_meta_payload(pager).is_err(),
2761 "cyclic overflow must not be accepted"
2762 );
2763
2764 let mut manifest = Page::new(PageType::Header, 1);
2765 reddb_file::encode_native_metadata_overflow_header(
2766 &mut manifest.as_bytes_mut()[HEADER_SIZE..],
2767 reddb_file::NativeMetadataOverflowHeader {
2768 format_version: STORE_VERSION_V11,
2769 total_payload_bytes: META_PAGE_CONTENT_CAP as u32 + 1,
2770 next_overflow_page_id: 0,
2771 },
2772 )
2773 .unwrap();
2774 pager.write_page(1, manifest).unwrap();
2775 assert!(
2776 read_meta_payload(pager).is_err(),
2777 "a truncated metadata payload must not be accepted"
2778 );
2779 }
2780
2781 #[test]
2782 fn retired_tree_pages_remain_pinned_by_a_cursor() {
2783 let directory = tempfile::tempdir().expect("temporary store");
2784 let store = UnifiedStore::open(&directory.path().join("data.rdb")).expect("paged store");
2785 for id in 1..=100 {
2786 store.insert_auto("rows", row(id, 1)).expect("seed");
2787 }
2788 store.persist().expect("initial checkpoint");
2789 let tree = store
2790 .btree_indices
2791 .read()
2792 .get("rows")
2793 .cloned()
2794 .expect("tree");
2795 let mut cursor = tree.cursor_first().expect("cursor");
2796 drop(tree);
2797 let manager = store.get_collection("rows").expect("rows");
2798 for value in 2..=6 {
2799 manager.update(row(1, value)).expect("change");
2800 store.persist().expect("checkpoint with old reader");
2801 }
2802 let mut count = 0;
2803 while let Some((_, bytes)) = cursor.next().expect("pinned tree must stay readable") {
2804 let (entity, _) =
2805 UnifiedStore::deserialize_entity_record(&bytes, store.format_version())
2806 .expect("decode");
2807 assert_eq!(
2808 entity.data.as_row().expect("row").columns,
2809 [Value::Integer(1)]
2810 );
2811 count += 1;
2812 }
2813 assert_eq!(count, 100);
2814 drop(cursor);
2815 store.persist().expect("reclaim after reader release");
2816 assert!(store.retired_btrees.lock().is_empty());
2817 }
2818
2819 #[test]
2820 fn checkpoint_payload_buffer_is_bounded_for_a_large_collection() {
2821 let directory = tempfile::tempdir().expect("temporary store");
2822 let store = UnifiedStore::open(&directory.path().join("data.rdb")).expect("paged store");
2823 store.create_collection("rows").expect("collection");
2824 let manager = store.get_collection("rows").expect("rows");
2825 for id in (1..=1200).rev() {
2826 manager
2827 .insert(UnifiedEntity::table_row(
2828 EntityId::new(id),
2829 "rows",
2830 id,
2831 vec![Value::text("x".repeat(4096))],
2832 ))
2833 .expect("seed");
2834 }
2835 CHECKPOINT_BUFFERED_BYTES.with(|peak| peak.set(0));
2836 store.persist().expect("bounded checkpoint");
2837 assert!(CHECKPOINT_BUFFERED_BYTES.with(|peak| peak.get()) <= CHECKPOINT_BATCH_BYTES);
2838 assert!(CHECKPOINT_BUFFERED_BYTES.with(|peak| peak.get()) > 0);
2839 assert_eq!(store.get_collection("rows").expect("rows").count(), 1200);
2840 }
2841
2842 #[test]
2843 fn unchanged_checkpoint_does_not_serialize_collection_rows() {
2844 let directory = tempfile::tempdir().expect("temporary store");
2845 let store = UnifiedStore::open(&directory.path().join("data.rdb")).expect("paged store");
2846 store.create_collection("rows").expect("collection");
2847 for id in 1..=40 {
2848 store
2849 .insert(
2850 "rows",
2851 UnifiedEntity::table_row(
2852 EntityId::new(id),
2853 "rows",
2854 id,
2855 vec![Value::Integer(id as i64)],
2856 ),
2857 )
2858 .expect("seed row");
2859 }
2860 store.persist().expect("initial checkpoint");
2861 CHECKPOINT_SERIALIZED_ROWS.with(|count| count.set(0));
2862 store.persist().expect("unchanged checkpoint");
2863 assert_eq!(
2864 CHECKPOINT_SERIALIZED_ROWS.with(|count| count.get()),
2865 0,
2866 "unchanged collections must not hold the writer fence while serializing every row"
2867 );
2868 drop(store);
2869 let reopened = UnifiedStore::open(&directory.path().join("data.rdb")).expect("reopen");
2870 CHECKPOINT_SERIALIZED_ROWS.with(|count| count.set(0));
2871 reopened.persist().expect("unchanged recovered generation");
2872 assert_eq!(CHECKPOINT_SERIALIZED_ROWS.with(|count| count.get()), 0);
2873 }
2874
2875 #[test]
2876 fn sparse_wal_replay_does_not_reencode_complete_collections() {
2877 let directory = tempfile::tempdir().expect("temporary store");
2878 let path = directory.path().join("data.rdb");
2879 let store = UnifiedStore::open(&path).expect("paged store");
2880 for (name, first) in [("rows", 1), ("cold", 129)] {
2881 store.create_collection(name).expect("collection");
2882 for id in first..first + 128 {
2883 store
2884 .insert(
2885 name,
2886 UnifiedEntity::table_row(
2887 EntityId::new(id),
2888 name,
2889 id,
2890 vec![Value::text(format!("{id}:{}", "x".repeat(8192)))],
2891 ),
2892 )
2893 .expect("seed");
2894 }
2895 }
2896 store.persist().expect("durable baseline");
2897 let action = super::super::commit::StoreWalAction::upsert_entity(
2898 "rows",
2899 &row(1, 42),
2900 None,
2901 STORE_VERSION_V11,
2902 );
2903 store
2905 .commit
2906 .as_ref()
2907 .unwrap()
2908 .append_actions(&[action])
2909 .unwrap();
2910 drop(store);
2911
2912 REPLAY_SERIALIZED_ROWS.with(|count| count.set(0));
2913 let recovered = UnifiedStore::open(&path).expect("replay committed change");
2914 assert_eq!(
2915 recovered
2916 .get("rows", EntityId::new(1))
2917 .unwrap()
2918 .data
2919 .as_row()
2920 .unwrap()
2921 .columns,
2922 vec![Value::Integer(42)]
2923 );
2924 for name in ["rows", "cold"] {
2925 assert_eq!(recovered.get_collection(name).unwrap().count(), 128);
2926 }
2927 assert_eq!(
2928 REPLAY_SERIALIZED_ROWS.with(|count| count.get()),
2929 0,
2930 "one WAL-backed row must not reencode its whole collection or unrelated cold rows"
2931 );
2932 for (name, first) in [("rows", 2), ("cold", 129)] {
2933 let end = if name == "rows" { 129 } else { 257 };
2934 for id in first..end {
2935 assert_eq!(
2936 recovered
2937 .get(name, EntityId::new(id))
2938 .unwrap()
2939 .data
2940 .as_row()
2941 .unwrap()
2942 .columns,
2943 vec![Value::text(format!("{id}:{}", "x".repeat(8192)))],
2944 );
2945 }
2946 }
2947
2948 drop(recovered);
2949 let reopened = UnifiedStore::open(&path).expect("durable sparse replay");
2950 assert_eq!(
2951 reopened
2952 .get("rows", EntityId::new(1))
2953 .unwrap()
2954 .data
2955 .as_row()
2956 .unwrap()
2957 .columns,
2958 vec![Value::Integer(42)]
2959 );
2960 }
2961
2962 fn replay_test_payload(bytes: usize) -> String {
2963 let mut state = 0x1234_5678_u32;
2964 (0..bytes)
2965 .map(|_| {
2966 state ^= state << 13;
2967 state ^= state >> 17;
2968 state ^= state << 5;
2969 char::from(b'!' + (state % 90) as u8)
2970 })
2971 .collect()
2972 }
2973
2974 #[test]
2975 fn wal_replay_with_exhausted_row_proof_keeps_full_validation() {
2976 let directory = tempfile::tempdir().unwrap();
2977 let path = directory.path().join("data.rdb");
2978 let config = || UnifiedStoreConfig::default().with_page_cache_slots(4);
2979 let store = UnifiedStore::open_with_config(&path, config()).unwrap();
2980 for id in 1..=16 {
2981 store.insert_auto("rows", row(id, 1)).unwrap();
2982 }
2983 store.persist().unwrap();
2984 let payload = replay_test_payload(64 * 1024);
2986 let changed =
2987 UnifiedEntity::table_row(EntityId::new(1), "rows", 1, vec![Value::text(payload)]);
2988 let action = StoreWalAction::upsert_entity("rows", &changed, None, store.format_version());
2989 store
2990 .commit
2991 .as_ref()
2992 .unwrap()
2993 .append_actions(&[action])
2994 .unwrap();
2995 drop(store);
2996 REPLAY_SERIALIZED_ROWS.with(|count| count.set(0));
2997 let recovered = UnifiedStore::open_with_config(&path, config()).unwrap();
2998 assert!(REPLAY_SERIALIZED_ROWS.with(|count| count.get()) >= 16);
2999 assert_eq!(
3000 recovered
3001 .get("rows", EntityId::new(1))
3002 .unwrap()
3003 .data
3004 .as_row()
3005 .unwrap()
3006 .columns,
3007 changed.data.as_row().unwrap().columns
3008 );
3009 drop(recovered);
3010 let reopened = UnifiedStore::open_with_config(&path, config()).unwrap();
3011 assert_eq!(reopened.get_collection("rows").unwrap().count(), 16);
3012 assert_eq!(
3013 reopened
3014 .get("rows", EntityId::new(1))
3015 .unwrap()
3016 .data
3017 .as_row()
3018 .unwrap()
3019 .columns,
3020 changed.data.as_row().unwrap().columns
3021 );
3022 }
3023
3024 #[cfg(target_os = "linux")]
3025 #[test]
3026 fn interrupted_sparse_replay_preserves_rows_and_bounds_growth() {
3027 const CHILD: &str = "REDDB_TEST_SPARSE_REPLAY_PATH";
3028 if let Some(path) = std::env::var_os(CHILD) {
3029 let _store = UnifiedStore::open(path).unwrap();
3030 panic!("replay crash hook was not reached");
3031 }
3032 for overflow in [false, true] {
3033 for (hook, code) in [
3034 ("REDDB_TEST_EXIT_AFTER_SPARSE_BATCH", 193),
3035 ("REDDB_EXPERIMENTAL_EXIT_AFTER_REPLAY_PUBLICATION", 182),
3036 ] {
3037 let directory = tempfile::tempdir().unwrap();
3038 let path = directory.path().join("data.rdb");
3039 let store = UnifiedStore::open(&path).unwrap();
3040 for id in 1..=128 {
3041 store.insert_auto("rows", row(id, 1)).unwrap();
3042 }
3043 store.persist().unwrap();
3044 let changed = if overflow {
3045 UnifiedEntity::table_row(
3046 EntityId::new(1),
3047 "rows",
3048 1,
3049 vec![Value::text(replay_test_payload(16 * 1024))],
3050 )
3051 } else {
3052 row(1, 42)
3053 };
3054 let action =
3055 StoreWalAction::upsert_entity("rows", &changed, None, store.format_version());
3056 store
3057 .commit
3058 .as_ref()
3059 .unwrap()
3060 .append_actions(&[action])
3061 .unwrap();
3062 drop(store);
3063 let mut first_size = 0;
3064 for cycle in 0..12 {
3065 let output = std::process::Command::new(std::env::current_exe().unwrap())
3066 .args(["--exact", "storage::unified::store::impl_pages::checkpoint_work_tests::interrupted_sparse_replay_preserves_rows_and_bounds_growth", "--nocapture"])
3067 .env(CHILD, &path).env(hook, "1").output().unwrap();
3068 assert_eq!(
3069 output.status.code(),
3070 Some(code),
3071 "overflow={overflow} hook={hook} cycle={cycle}: {}",
3072 String::from_utf8_lossy(&output.stderr)
3073 );
3074 let size = std::fs::metadata(&path).unwrap().len();
3075 if cycle == 0 {
3076 first_size = size;
3077 }
3078 assert!(
3079 size <= first_size + 2 * crate::storage::engine::PAGE_SIZE as u64,
3080 "repeated recovery must reuse reservations: {first_size} -> {size}"
3081 );
3082 }
3083 for _ in 0..2 {
3084 let recovered = UnifiedStore::open(&path).unwrap();
3085 assert_eq!(recovered.get_collection("rows").unwrap().count(), 128);
3086 for id in 1..=128 {
3087 let expected = if id == 1 { changed.clone() } else { row(id, 1) };
3088 assert_eq!(
3089 recovered
3090 .get("rows", EntityId::new(id))
3091 .unwrap()
3092 .data
3093 .as_row()
3094 .unwrap()
3095 .columns,
3096 expected.data.as_row().unwrap().columns
3097 );
3098 }
3099 }
3100 }
3101 }
3102 }
3103
3104 #[test]
3105 fn paged_reopen_preserves_stored_sequence_identity() {
3106 let directory = tempfile::tempdir().expect("temporary store");
3107 let path = directory.path().join("data.rdb");
3108 let store = UnifiedStore::open(&path).expect("paged store");
3109 store.create_collection("rows").expect("collection");
3110 for id in [30, 10, 20] {
3111 store.insert("rows", row(id, id as i64)).expect("insert");
3112 }
3113 store.persist().expect("checkpoint");
3114 let sequences = [10, 20, 30].map(|id| {
3115 store
3116 .get("rows", EntityId::new(id))
3117 .expect("row")
3118 .sequence_id
3119 });
3120 drop(store);
3121 let reopened = UnifiedStore::open(&path).expect("reopen");
3122 let recovered = [10, 20, 30].map(|id| {
3123 reopened
3124 .get("rows", EntityId::new(id))
3125 .expect("row")
3126 .sequence_id
3127 });
3128 assert_eq!(
3129 recovered, sequences,
3130 "opening a checkpoint must not rewrite stored row identities"
3131 );
3132 }
3133
3134 #[test]
3135 fn auto_insert_persists_the_assigned_sequence_identity() {
3136 let directory = tempfile::tempdir().expect("temporary store");
3137 let store = UnifiedStore::open(&directory.path().join("data.rdb")).expect("paged store");
3138 for id in 1u64..=3 {
3139 store
3140 .insert_auto("rows", row(id, id as i64))
3141 .expect("insert");
3142 }
3143 let manager = store.get_collection("rows").expect("rows");
3144 let tree = store
3145 .btree_indices
3146 .read()
3147 .get("rows")
3148 .cloned()
3149 .expect("tree");
3150 for id in 1u64..=3 {
3151 let bytes = tree
3152 .get(&id.to_be_bytes())
3153 .expect("lookup")
3154 .expect("stored row");
3155 let (persisted, _) =
3156 UnifiedStore::deserialize_entity_record(&bytes, store.format_version())
3157 .expect("decode");
3158 assert_eq!(
3159 persisted.sequence_id,
3160 manager
3161 .get(EntityId::new(id))
3162 .expect("live row")
3163 .sequence_id
3164 );
3165 }
3166 }
3167
3168 #[test]
3169 fn completed_inline_insert_reuses_history_but_not_untracked_edits() {
3170 let directory = tempfile::tempdir().expect("temporary store");
3171 let path = directory.path().join("data.rdb");
3172 let store = UnifiedStore::open(&path).expect("paged store");
3173 store.insert_auto("rows", row(1, 1)).expect("seed");
3174 store.persist().expect("checkpoint");
3175 store.insert_auto("rows", row(2, 2)).expect("append");
3176 CHECKPOINT_SERIALIZED_ROWS.with(|count| count.set(0));
3177 store.persist().expect("inline append checkpoint");
3178 assert_eq!(CHECKPOINT_SERIALIZED_ROWS.with(|count| count.get()), 0);
3179 store
3180 .get_collection("rows")
3181 .expect("rows")
3182 .update(row(1, 99))
3183 .expect("untracked edit");
3184 store.insert_auto("rows", row(3, 3)).expect("next append");
3185 CHECKPOINT_SERIALIZED_ROWS.with(|count| count.set(0));
3186 store.persist().expect("untracked edit checkpoint");
3187 assert!(CHECKPOINT_SERIALIZED_ROWS.with(|count| count.get()) > 0);
3188 drop(store);
3189 let reopened = UnifiedStore::open(&path).expect("reopen");
3190 assert_eq!(
3191 reopened
3192 .get("rows", EntityId::new(1))
3193 .expect("row")
3194 .data
3195 .as_row()
3196 .expect("table")
3197 .columns,
3198 vec![Value::Integer(99)]
3199 );
3200 }
3201
3202 #[test]
3203 fn replayed_trees_are_reusable_after_durable_reconciliation() {
3204 let directory = tempfile::tempdir().expect("temporary store");
3205 let path = directory.path().join("data.rdb");
3206 let store = UnifiedStore::open(&path).expect("paged store");
3207 store.insert_auto("rows", row(1, 1)).expect("seed");
3208 store.persist().expect("checkpoint");
3209 store
3210 .insert_auto("rows", row(2, 2))
3211 .expect("WAL-backed append");
3212 drop(store);
3213 let reopened = UnifiedStore::open(&path).expect("replay");
3214 assert!(reopened.get("rows", EntityId::new(2)).is_some());
3215 CHECKPOINT_SERIALIZED_ROWS.with(|count| count.set(0));
3216 reopened.persist().expect("checkpoint reconciled tree");
3217 assert_eq!(CHECKPOINT_SERIALIZED_ROWS.with(|count| count.get()), 0);
3218 }
3219
3220 #[test]
3221 fn legacy_system_catalog_and_wal_migrate_without_cold_boot_rebuild() {
3222 use crate::storage::wal::{WalRecord, WalWriter};
3223
3224 let directory = tempfile::tempdir().expect("temporary store");
3225 let path = directory.path().join("data.rdb");
3226 let config = UnifiedStoreConfig {
3227 page_cache_slots: Some(32),
3228 resident_memory_bytes_max: Some(2 * 1024 * 1024),
3229 ..UnifiedStoreConfig::default()
3230 };
3231 let index_identity = super::super::secondary_index::SecondaryIndexIdentity {
3232 codec_version: 1,
3233 definition: [91; 32],
3234 };
3235 let baseline_index_root;
3236 {
3237 let store = UnifiedStore::open_with_config(&path, config.clone()).expect("paged store");
3238 store
3239 .create_collection("red_config")
3240 .expect("system collection");
3241 store
3242 .insert_auto(
3243 "red_config",
3244 UnifiedEntity::table_row(
3245 EntityId::new(1),
3246 "red_config",
3247 1,
3248 vec![Value::text("checkpoint")],
3249 ),
3250 )
3251 .expect("legacy-compatible insert");
3252 store
3253 .register_secondary_index(
3254 "red_config",
3255 "by_id",
3256 index_identity.clone(),
3257 Arc::new(SystemNameProjection),
3258 )
3259 .expect("secondary index");
3260 store.persist().expect("baseline checkpoint");
3261 baseline_index_root = store
3262 .secondary_index("red.config", "by_id", &index_identity)
3263 .expect("published secondary index")
3264 .root_page_id();
3265 }
3266
3267 {
3271 let pager = Pager::open(
3272 &path,
3273 PagerConfig {
3274 disk_residency: true,
3275 ..PagerConfig::default()
3276 },
3277 )
3278 .expect("open pager");
3279 let mut page = pager.read_page(1).expect("catalog page");
3280 let bytes = page.as_bytes_mut();
3281 let canonical = b"red.config";
3282 let legacy = b"red_config";
3283 let mut replacements = 0;
3284 for offset in 0..=bytes.len() - canonical.len() {
3285 if &bytes[offset..offset + canonical.len()] == canonical {
3286 bytes[offset..offset + legacy.len()].copy_from_slice(legacy);
3287 replacements += 1;
3288 }
3289 }
3290 assert!(replacements >= 1, "catalog must contain the canonical name");
3291 pager.write_page(1, page).expect("write legacy catalog");
3292 pager.sync().expect("sync legacy catalog");
3293 }
3294 crate::storage::operational_manifest::OperationalManifest::for_db_path(&path)
3295 .rename_collections(&[("red.config", "red_config")])
3296 .expect("restore legacy operational name");
3297
3298 let wal_upsert = reddb_file::encode_store_wal_action_frame(
3301 &reddb_file::StoreWalActionFrame::UpsertEntityRecord {
3302 collection: "red_config".to_string(),
3303 record: UnifiedStore::serialize_entity_record(
3304 &UnifiedEntity::table_row(
3305 EntityId::new(2),
3306 "red_config",
3307 2,
3308 vec![Value::text("legacy WAL")],
3309 ),
3310 None,
3311 STORE_VERSION_CURRENT,
3312 ),
3313 },
3314 )
3315 .expect("legacy WAL action");
3316 let wal_delete = reddb_file::encode_store_wal_action_frame(
3317 &reddb_file::StoreWalActionFrame::DeleteEntityRecord {
3318 collection: "red_config".to_string(),
3319 entity_id: EntityId::new(1).raw(),
3320 },
3321 )
3322 .expect("legacy WAL delete");
3323 let wal_path = reddb_file::layout::unified_wal_path(&path);
3324 {
3325 let mut writer = WalWriter::open(&wal_path).expect("open WAL");
3326 writer
3327 .append(&WalRecord::TxCommitBatch {
3328 tx_id: 77,
3329 actions: vec![wal_upsert, wal_delete],
3330 })
3331 .expect("append legacy transaction");
3332 writer.sync().expect("sync legacy transaction");
3333 }
3334
3335 let (root_after_recovery, pages_after_recovery) = {
3336 let recovered =
3337 UnifiedStore::open_with_config(&path, config.clone()).expect("migrate and recover");
3338 assert_eq!(recovered.list_collections(), vec!["red.config".to_string()]);
3339 assert!(
3340 recovered.get("red.config", EntityId::new(1)).is_none(),
3341 "legacy WAL deletes must target the canonical collection"
3342 );
3343 assert_eq!(
3344 recovered
3345 .get("red.config", EntityId::new(2))
3346 .expect("WAL row")
3347 .kind
3348 .collection(),
3349 "red.config"
3350 );
3351 let root = recovered
3352 .collection_root_page("red.config")
3353 .expect("published canonical root");
3354 assert_eq!(
3355 recovered
3356 .secondary_index("red.config", "by_id", &index_identity)
3357 .expect("recovered secondary index")
3358 .root_page_id(),
3359 baseline_index_root,
3360 "name migration must reuse the secondary-index root"
3361 );
3362 let pages = recovered
3363 .pager()
3364 .expect("pager")
3365 .page_count()
3366 .expect("page count");
3367 (root, pages)
3368 };
3369
3370 crate::storage::operational_manifest::OperationalManifest::for_db_path(&path)
3373 .rename_collections(&[("red.config", "red_config")])
3374 .expect("restore stale manifest identity");
3375
3376 CHECKPOINT_SERIALIZED_ROWS.with(|count| count.set(0));
3377 let cold = UnifiedStore::open_with_config(&path, config).expect("second cold boot");
3378 assert_eq!(
3379 cold.collection_root_page("red.config"),
3380 Some(root_after_recovery),
3381 "an idempotent cold boot must reuse the published data root"
3382 );
3383 assert_eq!(
3384 cold.pager()
3385 .expect("pager")
3386 .page_count()
3387 .expect("page count"),
3388 pages_after_recovery,
3389 "cold boot must not allocate replacement index trees"
3390 );
3391 assert_eq!(CHECKPOINT_SERIALIZED_ROWS.with(|count| count.get()), 0);
3392 assert_eq!(
3393 cold.secondary_index("red.config", "by_id", &index_identity)
3394 .expect("secondary index after cold boot")
3395 .root_page_id(),
3396 baseline_index_root
3397 );
3398 assert!(
3399 crate::storage::operational_manifest::OperationalManifest::for_db_path(&path)
3400 .recover_or_bootstrap(&["red.config".to_string()])
3401 .expect("canonical manifest")
3402 .is_empty()
3403 );
3404 }
3405
3406 struct DuplicateSystemCatalogFixture {
3407 _directory: tempfile::TempDir,
3408 path: std::path::PathBuf,
3409 config: UnifiedStoreConfig,
3410 pages_before: u32,
3411 legacy_root: Option<u32>,
3412 legacy_key_root: Option<u32>,
3413 legacy_secondary_root: Option<u32>,
3414 index_identity: super::super::secondary_index::SecondaryIndexIdentity,
3415 }
3416
3417 fn duplicate_system_catalog_fixture(
3418 legacy_populated: bool,
3419 canonical_populated: bool,
3420 index_legacy: bool,
3421 ) -> DuplicateSystemCatalogFixture {
3422 duplicate_system_catalog_fixture_with_ref(
3423 legacy_populated,
3424 canonical_populated,
3425 index_legacy,
3426 false,
3427 )
3428 }
3429
3430 fn duplicate_system_catalog_fixture_with_ref(
3431 legacy_populated: bool,
3432 canonical_populated: bool,
3433 index_legacy: bool,
3434 cross_reference_legacy: bool,
3435 ) -> DuplicateSystemCatalogFixture {
3436 duplicate_system_catalog_fixture_with_key(
3437 legacy_populated,
3438 canonical_populated,
3439 index_legacy,
3440 cross_reference_legacy,
3441 None,
3442 )
3443 }
3444
3445 fn config_recovery_row(id: u64, key: &str, value: i64) -> UnifiedEntity {
3446 UnifiedEntity::new(
3447 EntityId::new(id),
3448 EntityKind::TableRow {
3449 table: Arc::from("red.config"),
3450 row_id: id,
3451 },
3452 EntityData::Row(crate::storage::RowData::with_names(
3453 vec![Value::text(key), Value::Integer(value)],
3454 vec!["key".into(), "value".into()],
3455 )),
3456 )
3457 }
3458
3459 fn duplicate_system_catalog_fixture_with_key(
3460 legacy_populated: bool,
3461 canonical_populated: bool,
3462 index_legacy: bool,
3463 cross_reference_legacy: bool,
3464 config_key: Option<&str>,
3465 ) -> DuplicateSystemCatalogFixture {
3466 duplicate_system_catalog_fixture_with_aliases(
3467 legacy_populated,
3468 canonical_populated,
3469 index_legacy,
3470 cross_reference_legacy,
3471 config_key,
3472 None,
3473 )
3474 }
3475
3476 fn duplicate_system_catalog_fixture_with_aliases(
3477 legacy_populated: bool,
3478 canonical_populated: bool,
3479 index_legacy: bool,
3480 cross_reference_legacy: bool,
3481 config_key: Option<&str>,
3482 alias_case: Option<&str>,
3483 ) -> DuplicateSystemCatalogFixture {
3484 let directory = tempfile::tempdir().expect("temporary store");
3485 let path = directory.path().join("data.rdb");
3486 let config = UnifiedStoreConfig {
3487 page_cache_slots: Some(32),
3488 resident_memory_bytes_max: Some(2 * 1024 * 1024),
3489 ..UnifiedStoreConfig::default()
3490 };
3491 let index_identity = super::super::secondary_index::SecondaryIndexIdentity {
3492 codec_version: 1,
3493 definition: [73; 32],
3494 };
3495 let (legacy_root, legacy_key_root, legacy_secondary_root);
3496 {
3497 let store = UnifiedStore::open_with_config(&path, config.clone()).expect("paged store");
3498 store
3499 .create_collection("cfg_legacy")
3500 .expect("legacy source collection");
3501 if legacy_populated {
3502 store
3503 .insert_auto(
3504 "cfg_legacy",
3505 config_key.map_or_else(
3506 || {
3507 UnifiedEntity::table_row(
3508 EntityId::new(1),
3509 "red_config",
3510 1,
3511 vec![Value::text("legacy")],
3512 )
3513 },
3514 |key| config_recovery_row(1, key, 1),
3515 ),
3516 )
3517 .expect("legacy row");
3518 }
3519 store
3520 .create_collection("cfg.canon_")
3521 .expect("canonical source collection");
3522 if canonical_populated {
3523 store
3524 .insert_auto(
3525 "cfg.canon_",
3526 config_key.map_or_else(
3527 || {
3528 UnifiedEntity::table_row(
3529 EntityId::new(2),
3530 "red.config",
3531 2,
3532 vec![Value::text("canonical")],
3533 )
3534 },
3535 |key| config_recovery_row(2, key, 2),
3536 ),
3537 )
3538 .expect("canonical row");
3539 }
3540 if cross_reference_legacy {
3541 store
3542 .add_cross_ref(
3543 "cfg.canon_",
3544 EntityId::new(2),
3545 "cfg_legacy",
3546 EntityId::new(1),
3547 RefType::RowToNode,
3548 1.0,
3549 )
3550 .expect("reference to legacy config");
3551 }
3552 if index_legacy {
3553 store
3554 .register_secondary_index(
3555 "cfg_legacy",
3556 "by_id",
3557 index_identity.clone(),
3558 Arc::new(SystemNameProjection),
3559 )
3560 .expect("legacy secondary index");
3561 }
3562
3563 if let Some(case) = alias_case {
3564 use crate::storage::{EntityData, RowData};
3565 let pairs = [
3566 (
3567 "old_stats",
3568 "new.query.stats_catalog",
3569 "red_stats",
3570 "red.query.stats_catalog",
3571 ),
3572 (
3573 "old_index_registry",
3574 "new.index.registry",
3575 "red_index_registry",
3576 "red.index.registry",
3577 ),
3578 (
3579 "old_queue_meta",
3580 "new.queue.meta",
3581 "red_queue_meta",
3582 "red.queue.meta",
3583 ),
3584 (
3585 "old_materialized_view_defs",
3586 "new.materialized_view.definitions",
3587 "red_materialized_view_defs",
3588 "red.materialized_view.definitions",
3589 ),
3590 ];
3591 for (pair_index, (old, new, legacy, canonical)) in pairs.iter().enumerate() {
3592 for (is_old, name, table) in [(true, *old, *legacy), (false, *new, *canonical)]
3593 {
3594 store.create_collection(name).unwrap();
3595 let text = |s: &str| Value::text(s);
3596 let mut fields = match pair_index {
3597 0 => vec![
3598 ("kind", text("table")),
3599 ("table", text("users")),
3600 ("row_count", Value::Integer(if is_old { 1 } else { 2 })),
3601 ("avg_row_size", Value::Integer(20)),
3602 ("page_count", Value::Integer(1)),
3603 ],
3604 1 => vec![
3605 ("collection", text("users")),
3606 (
3607 "name",
3608 text(if is_old {
3609 "legacy_only"
3610 } else {
3611 "canonical_only"
3612 }),
3613 ),
3614 ("columns", text("id")),
3615 ("method", text("hash")),
3616 ("resolution", Value::Integer(0)),
3617 ("unique", Value::Boolean(false)),
3618 ("dropped", Value::Boolean(false)),
3619 ],
3620 2 => vec![
3621 ("kind", text("queue_group")),
3622 ("queue", text("jobs")),
3623 ("group", text("workers")),
3624 (
3625 "created_at_ns",
3626 Value::UnsignedInteger(if is_old { 1 } else { 2 }),
3627 ),
3628 ],
3629 _ => vec![
3630 ("name", text("view")),
3631 ("refresh_every_ms", Value::UnsignedInteger(1000)),
3632 ("retention_duration_ms", Value::UnsignedInteger(0)),
3633 ("source_collections", text("users")),
3634 (
3635 "source_sql",
3636 text(if case == "view-conflict" && !is_old {
3637 "SELECT 2"
3638 } else {
3639 "SELECT 1"
3640 }),
3641 ),
3642 ],
3643 };
3644 if case == "queue-delivery" && pair_index == 2 && is_old {
3645 fields[0].1 = text("queue_pending");
3646 }
3647 let id = if is_old {
3648 10000 + pair_index as u64
3649 } else {
3650 100 + pair_index as u64
3651 };
3652 let entity = UnifiedEntity::new(
3653 EntityId::new(id),
3654 EntityKind::TableRow {
3655 table: Arc::from(table),
3656 row_id: id,
3657 },
3658 EntityData::Row(RowData {
3659 columns: vec![],
3660 named: Some(
3661 fields.into_iter().map(|(k, v)| (k.to_owned(), v)).collect(),
3662 ),
3663 schema: None,
3664 }),
3665 );
3666 store.insert_auto(name, entity).unwrap();
3667 }
3668 }
3669 }
3670 store.persist().expect("checkpoint duplicate catalog");
3671 legacy_root = store.collection_root_page("cfg_legacy");
3672 legacy_key_root = store
3673 .key_indices
3674 .read()
3675 .get("cfg_legacy")
3676 .map(|index| index.tree.root_page_id());
3677 legacy_secondary_root = index_legacy.then(|| {
3678 store
3679 .secondary_index("cfg_legacy", "by_id", &index_identity)
3680 .expect("legacy secondary root")
3681 .root_page_id()
3682 });
3683 }
3684
3685 {
3690 let pager = Pager::open(
3691 &path,
3692 PagerConfig {
3693 disk_residency: true,
3694 ..PagerConfig::default()
3695 },
3696 )
3697 .expect("open pager");
3698 let mut page = pager.read_page(1).expect("catalog page");
3699 let bytes = page.as_bytes_mut();
3700 let mut names = vec![("cfg_legacy", "red_config"), ("cfg.canon_", "red.config")];
3701 if alias_case.is_some() {
3702 names.extend([
3703 ("old_stats", "red_stats"),
3704 ("new.query.stats_catalog", "red.query.stats_catalog"),
3705 ("old_index_registry", "red_index_registry"),
3706 ("new.index.registry", "red.index.registry"),
3707 ("old_queue_meta", "red_queue_meta"),
3708 ("new.queue.meta", "red.queue.meta"),
3709 ("old_materialized_view_defs", "red_materialized_view_defs"),
3710 (
3711 "new.materialized_view.definitions",
3712 "red.materialized_view.definitions",
3713 ),
3714 ]);
3715 }
3716 for (source, destination) in names {
3717 let (source, destination) = (source.as_bytes(), destination.as_bytes());
3718 let mut replacements = 0;
3719 for offset in 0..=bytes.len() - source.len() {
3720 if &bytes[offset..offset + source.len()] == source {
3721 bytes[offset..offset + destination.len()].copy_from_slice(destination);
3722 replacements += 1;
3723 }
3724 }
3725 assert!(replacements >= 1, "catalog must contain the source name");
3726 }
3727 pager.write_page(1, page).expect("write duplicate catalog");
3728 pager.sync().expect("sync duplicate catalog");
3729 if alias_case.is_some() {
3730 let mut metadata = read_meta_payload(&pager).unwrap().unwrap();
3731 let resume = reddb_file::native_resume::decode_catalog_resume(&metadata)
3732 .unwrap()
3733 .unwrap();
3734 let trailer = metadata
3735 .windows(4)
3736 .rposition(|bytes| bytes == b"RDRS")
3737 .unwrap();
3738 metadata.truncate(trailer);
3739 reddb_file::native_resume::encode_catalog_resume(&mut metadata, &resume).unwrap();
3740 publish_paged_metadata(&pager, &metadata).unwrap();
3741 pager.sync().unwrap();
3742 }
3743 }
3744 crate::storage::operational_manifest::OperationalManifest::for_db_path(&path)
3745 .rename_collections(&[("cfg_legacy", "red_config"), ("cfg.canon_", "red.config")])
3746 .expect("publish duplicate operational names");
3747
3748 if alias_case.is_some() {
3749 crate::storage::operational_manifest::OperationalManifest::for_db_path(&path)
3750 .rename_collections(&[
3751 ("old_stats", "red_stats"),
3752 ("new.query.stats_catalog", "red.query.stats_catalog"),
3753 ("old_index_registry", "red_index_registry"),
3754 ("new.index.registry", "red.index.registry"),
3755 ("old_queue_meta", "red_queue_meta"),
3756 ("new.queue.meta", "red.queue.meta"),
3757 ("old_materialized_view_defs", "red_materialized_view_defs"),
3758 (
3759 "new.materialized_view.definitions",
3760 "red.materialized_view.definitions",
3761 ),
3762 ])
3763 .unwrap();
3764 }
3765
3766 let pages_before = Pager::open(
3767 &path,
3768 PagerConfig {
3769 disk_residency: true,
3770 ..PagerConfig::default()
3771 },
3772 )
3773 .expect("inspect pager")
3774 .page_count()
3775 .expect("page count");
3776 DuplicateSystemCatalogFixture {
3777 _directory: directory,
3778 path,
3779 config,
3780 pages_before,
3781 legacy_root,
3782 legacy_key_root,
3783 legacy_secondary_root,
3784 index_identity,
3785 }
3786 }
3787
3788 fn alias_duplicate_system_catalog_state_with(
3789 fixture: &DuplicateSystemCatalogFixture,
3790 mutate_canonical: impl FnOnce(&mut reddb_file::native_resume::CollectionResume),
3791 ) {
3792 let pager = Pager::open(
3793 &fixture.path,
3794 PagerConfig {
3795 disk_residency: true,
3796 ..PagerConfig::default()
3797 },
3798 )
3799 .expect("open duplicate catalog pager");
3800 let mut metadata = read_meta_payload(&pager)
3801 .expect("read duplicate catalog")
3802 .expect("published duplicate catalog");
3803 let mut resume = reddb_file::native_resume::decode_catalog_resume(&metadata)
3804 .expect("decode duplicate resume")
3805 .expect("prepared duplicate resume");
3806 let legacy_state = resume
3807 .collections
3808 .get("red_config")
3809 .expect("legacy resume state")
3810 .clone();
3811 let mut canonical_state = legacy_state.clone();
3812 mutate_canonical(&mut canonical_state);
3813 resume
3814 .collections
3815 .insert("red.config".to_string(), canonical_state);
3816
3817 let header = reddb_file::decode_native_paged_metadata_header(&metadata)
3818 .expect("decode metadata header")
3819 .expect("native metadata header");
3820 let mut position = reddb_file::METADATA_HEADER_BYTES;
3821 let mut replaced_root = false;
3822 for _ in 0..header.collection_count {
3823 let root = reddb_file::decode_native_paged_collection_root(&metadata, &mut position)
3824 .expect("decode collection root");
3825 if root.collection == "red.config" {
3826 metadata[position - 4..position]
3827 .copy_from_slice(&legacy_state.root_page.to_le_bytes());
3828 replaced_root = true;
3829 }
3830 }
3831 assert!(replaced_root, "canonical catalog root must be present");
3832
3833 let trailer = metadata
3834 .windows(4)
3835 .rposition(|bytes| bytes == b"RDRS")
3836 .expect("catalog resume trailer");
3837 metadata.truncate(trailer);
3838 reddb_file::native_resume::encode_catalog_resume(&mut metadata, &resume)
3839 .expect("encode aliased resume");
3840 publish_paged_metadata(&pager, &metadata).expect("publish aliased catalog");
3841 }
3842
3843 fn alias_duplicate_system_catalog_state(fixture: &DuplicateSystemCatalogFixture) {
3844 alias_duplicate_system_catalog_state_with(fixture, |_| {});
3845 }
3846
3847 #[test]
3848 fn populated_legacy_system_collection_replaces_empty_canonical_duplicate() {
3849 let fixture = duplicate_system_catalog_fixture(true, false, true);
3850 let recovered = UnifiedStore::open_with_config(&fixture.path, fixture.config.clone())
3851 .expect("resolve empty duplicate");
3852 assert_eq!(recovered.list_collections(), vec!["red.config".to_string()]);
3853 assert_eq!(
3854 recovered
3855 .get("red.config", EntityId::new(1))
3856 .expect("preserved legacy row")
3857 .kind
3858 .collection(),
3859 "red.config"
3860 );
3861 assert_eq!(
3862 recovered.collection_root_page("red.config"),
3863 fixture.legacy_root,
3864 "the populated data root must be reused"
3865 );
3866 assert_eq!(
3867 recovered
3868 .key_indices
3869 .read()
3870 .get("red.config")
3871 .map(|index| index.tree.root_page_id()),
3872 fixture.legacy_key_root,
3873 "the populated key-index root must be reused"
3874 );
3875 assert_eq!(
3876 recovered
3877 .secondary_index("red.config", "by_id", &fixture.index_identity)
3878 .expect("surviving secondary index")
3879 .root_page_id(),
3880 fixture
3881 .legacy_secondary_root
3882 .expect("fixture secondary root")
3883 );
3884 drop(recovered);
3885
3886 let cold = UnifiedStore::open_with_config(&fixture.path, fixture.config)
3887 .expect("idempotent cold boot");
3888 assert_eq!(cold.list_collections(), vec!["red.config".to_string()]);
3889 assert_eq!(
3890 cold.pager()
3891 .expect("pager")
3892 .page_count()
3893 .expect("page count"),
3894 fixture.pages_before,
3895 "resolving an empty duplicate must not extend the file"
3896 );
3897 }
3898
3899 #[test]
3900 fn populated_canonical_system_collection_replaces_empty_legacy_duplicate() {
3901 let fixture = duplicate_system_catalog_fixture(false, true, false);
3902 let recovered = UnifiedStore::open_with_config(&fixture.path, fixture.config)
3903 .expect("retain populated canonical duplicate");
3904 assert_eq!(recovered.list_collections(), vec!["red.config".to_string()]);
3905 assert_eq!(
3906 recovered
3907 .get("red.config", EntityId::new(2))
3908 .expect("preserved canonical row")
3909 .kind
3910 .collection(),
3911 "red.config"
3912 );
3913 assert!(recovered.get("red.config", EntityId::new(1)).is_none());
3914 }
3915
3916 #[test]
3917 fn empty_canonical_system_collection_replaces_empty_legacy_duplicate() {
3918 let fixture = duplicate_system_catalog_fixture(false, false, false);
3919 let recovered = UnifiedStore::open_with_config(&fixture.path, fixture.config)
3920 .expect("prefer canonical empty duplicate");
3921 assert_eq!(recovered.list_collections(), vec!["red.config".to_string()]);
3922 assert_eq!(
3923 recovered
3924 .get_collection("red.config")
3925 .expect("canonical collection")
3926 .count(),
3927 0
3928 );
3929 }
3930
3931 #[test]
3932 fn populated_system_collection_duplicates_still_fail_closed() {
3933 let fixture = duplicate_system_catalog_fixture(true, true, false);
3934 let error = match UnifiedStore::open_with_config(&fixture.path, fixture.config) {
3935 Ok(_) => panic!("two populated identities are ambiguous"),
3936 Err(error) => error,
3937 };
3938 assert!(
3939 error
3940 .to_string()
3941 .contains("both populated legacy collection 'red_config' and canonical collection 'red.config'"),
3942 "unexpected error: {error}"
3943 );
3944 assert!(
3945 error.to_string().contains(
3946 "data_root_match=false, key_index_root_match=true, secondary_index_state_match=true"
3947 ),
3948 "the error must identify which physical state diverged without exposing page IDs: {error}"
3949 );
3950 }
3951
3952 #[test]
3953 fn explicit_compaction_retires_populated_legacy_system_config() {
3954 let fixture = duplicate_system_catalog_fixture(true, true, false);
3955 let destination = fixture._directory.path().join("canonical-system-config");
3956
3957 let report = crate::storage::compact::compact_paged_canonical_system_config(
3958 &fixture.path,
3959 &destination,
3960 )
3961 .expect("rewrite duplicate system config");
3962 assert!(report.records > 0);
3963
3964 let rewritten_path = destination.join("data.rdb");
3965 let rewritten = UnifiedStore::open_with_config(&rewritten_path, fixture.config.clone())
3966 .expect("open canonical rewrite");
3967 assert_eq!(rewritten.list_collections(), vec!["red.config".to_string()]);
3968 assert!(
3969 rewritten.get("red.config", EntityId::new(1)).is_none(),
3970 "the obsolete legacy tree must not leak into the canonical collection"
3971 );
3972 assert_eq!(
3973 rewritten
3974 .get("red.config", EntityId::new(2))
3975 .expect("canonical row survives")
3976 .kind
3977 .collection(),
3978 "red.config"
3979 );
3980 drop(rewritten);
3981
3982 let cold = UnifiedStore::open_with_config(&rewritten_path, fixture.config)
3983 .expect("cold reopen canonical rewrite");
3984 assert_eq!(cold.list_collections(), vec!["red.config".to_string()]);
3985 assert_eq!(
3986 cold.get_collection("red.config")
3987 .expect("canonical collection")
3988 .count(),
3989 1
3990 );
3991 assert!(
3992 crate::storage::operational_manifest::OperationalManifest::for_db_path(&rewritten_path)
3993 .recover_or_bootstrap(&["red.config".to_string()])
3994 .expect("canonical operational manifest")
3995 .is_empty()
3996 );
3997 }
3998
3999 #[test]
4000 fn canonical_system_config_recovery_replays_pending_unrelated_wal() {
4001 use crate::storage::wal::{WalRecord, WalWriter};
4002
4003 let fixture = duplicate_system_catalog_fixture(true, true, true);
4004 let wal = reddb_file::unified_wal_path(&fixture.path);
4005 let action = reddb_file::encode_store_wal_action_frame(
4006 &reddb_file::StoreWalActionFrame::CreateCollection {
4007 name: "pending_collection".into(),
4008 },
4009 )
4010 .expect("encode pending action");
4011 let canonical_update = reddb_file::encode_store_wal_action_frame(
4012 &reddb_file::StoreWalActionFrame::UpsertEntityRecord {
4013 collection: "red.config".into(),
4014 record: UnifiedStore::serialize_entity_record(
4015 &UnifiedEntity::table_row(
4016 EntityId::new(3),
4017 "red.config",
4018 3,
4019 vec![Value::text("canonical WAL")],
4020 ),
4021 None,
4022 STORE_VERSION_CURRENT,
4023 ),
4024 },
4025 )
4026 .expect("encode canonical update");
4027 {
4028 let mut writer = WalWriter::open(&wal).expect("open WAL");
4029 writer
4030 .append(&WalRecord::TxCommitBatch {
4031 tx_id: 999,
4032 actions: vec![action, canonical_update],
4033 })
4034 .expect("append committed WAL");
4035 writer.sync().expect("sync WAL");
4036 }
4037 let original = std::fs::read(&fixture.path).expect("source bytes");
4038 let original_wal = std::fs::read(&wal).expect("WAL bytes");
4039 let destination = fixture._directory.path().join("recovered-canonical-config");
4040
4041 let default_destination = fixture._directory.path().join("default-recovery");
4042 let error = crate::storage::compact::recover_paged_wal(&fixture.path, &default_destination)
4043 .expect_err("ordinary recovery must reject divergent identities");
4044 assert!(error
4045 .to_string()
4046 .contains("both populated legacy collection"));
4047
4048 crate::storage::compact::recover_paged_wal_canonical_system_config(
4049 &fixture.path,
4050 &destination,
4051 )
4052 .expect("recover pending WAL and retain canonical config");
4053
4054 assert_eq!(std::fs::read(&fixture.path).unwrap(), original);
4055 assert_eq!(std::fs::read(&wal).unwrap(), original_wal);
4056 for _ in 0..2 {
4057 let recovered = UnifiedStore::open_with_config(
4058 destination.join("data.rdb"),
4059 fixture.config.clone(),
4060 )
4061 .expect("open recovered store");
4062 assert!(recovered.get_collection("pending_collection").is_some());
4063 assert!(recovered.get("red.config", EntityId::new(1)).is_none());
4064 assert!(recovered.get("red.config", EntityId::new(2)).is_some());
4065 assert!(recovered.get("red.config", EntityId::new(3)).is_some());
4066 }
4067 }
4068
4069 #[test]
4070 fn canonical_system_catalog_recovery_reconciles_all_metadata_and_keeps_missing_index() {
4071 use crate::storage::wal::{WalRecord, WalWriter};
4072 for case in [
4073 "valid",
4074 "view-conflict",
4075 "queue-delivery",
4076 "legacy-wal",
4077 "canonical-wal",
4078 ] {
4079 let fixture = duplicate_system_catalog_fixture_with_aliases(
4080 true,
4081 true,
4082 true,
4083 false,
4084 Some("red.system.pid"),
4085 Some(case),
4086 );
4087 let wal = reddb_file::unified_wal_path(&fixture.path);
4088 {
4089 let mut writer = WalWriter::open(&wal).unwrap();
4090 let name = match case {
4091 "legacy-wal" => "red_index_registry",
4092 "canonical-wal" => "red.index.registry",
4093 _ => "durable_rows",
4094 };
4095 writer
4096 .append(&WalRecord::TxCommitBatch {
4097 tx_id: 1001,
4098 actions: vec![reddb_file::encode_store_wal_action_frame(
4099 &reddb_file::StoreWalActionFrame::CreateCollection {
4100 name: name.into(),
4101 },
4102 )
4103 .unwrap()],
4104 })
4105 .unwrap();
4106 writer.sync().unwrap();
4107 }
4108 let original = std::fs::read(&fixture.path).unwrap();
4109 let original_wal = std::fs::read(&wal).unwrap();
4110 let baseline = UnifiedStore::open_with_config_for_recovery(
4111 &fixture.path,
4112 fixture.config.clone(),
4113 true,
4114 None,
4115 None,
4116 )
4117 .err()
4118 .expect("unproved aliases must still fail");
4119 assert!(baseline.to_string().contains("red_stats"), "{baseline}");
4120 assert_eq!(std::fs::read(&fixture.path).unwrap(), original);
4121 let destination = fixture._directory.path().join("catalog-recovered");
4122 let result = crate::storage::compact::recover_paged_wal_canonical_system_config(
4123 &fixture.path,
4124 &destination,
4125 );
4126 assert_eq!(std::fs::read(&fixture.path).unwrap(), original);
4127 assert_eq!(std::fs::read(&wal).unwrap(), original_wal);
4128 if case != "valid" {
4129 let error = result.expect_err(case).to_string();
4130 let expected = match case {
4131 "view-conflict" => "conflicting canonical definition",
4132 "queue-delivery" => "unsupported legacy metadata",
4133 _ => "WAL changes metadata",
4134 };
4135 assert!(error.contains(expected), "{case}: {error}");
4136 assert!(!destination.join("data.rdb").exists());
4137 continue;
4138 }
4139 let report = result.expect("recover all supported aliases together");
4140 assert_eq!(report.reconciled_system_collections, 4);
4141 assert_eq!(report.imported_system_records, 1);
4142 for _ in 0..2 {
4143 let store = UnifiedStore::open_with_config(
4144 destination.join("data.rdb"),
4145 fixture.config.clone(),
4146 )
4147 .unwrap();
4148 assert!(store.get_collection("durable_rows").is_some());
4149 let mut imported = Vec::new();
4150 store
4151 .get_collection("red.index.registry")
4152 .unwrap()
4153 .try_for_each_entity(4 * 1024 * 1024, |entity| {
4154 if entity.data.as_row().and_then(|r| r.get_field("name"))
4155 == Some(&Value::text("legacy_only"))
4156 {
4157 imported.push(entity.clone());
4158 }
4159 true
4160 })
4161 .unwrap();
4162 assert_eq!(imported.len(), 1, "legacy-only index must survive");
4163 assert!(
4164 imported[0].id.raw() > 10003,
4165 "all retired identities must be reserved first"
4166 );
4167 assert!(
4168 matches!(imported[0].kind, EntityKind::TableRow{row_id,..} if row_id>10001)
4169 );
4170 assert_eq!(imported[0].logical_id(), imported[0].id);
4171 assert!(store
4172 .get("red.index.registry", EntityId::new(10001))
4173 .is_none());
4174 assert!(store
4175 .get("red.index.registry", EntityId::new(101))
4176 .is_some());
4177 assert!(store
4178 .get("red.query.stats_catalog", EntityId::new(100))
4179 .is_some());
4180 assert!(store
4181 .get("red.query.stats_catalog", EntityId::new(10000))
4182 .is_none());
4183 assert_eq!(store.get_collection("red.queue.meta").unwrap().count(), 1);
4184 assert_eq!(
4185 store
4186 .get_collection("red.materialized_view.definitions")
4187 .unwrap()
4188 .count(),
4189 1
4190 );
4191 assert!(
4192 store
4193 .get_collection("red.index.registry")
4194 .unwrap()
4195 .identity_counters()
4196 .0
4197 > 10001
4198 );
4199 }
4200 }
4201 }
4202
4203 #[test]
4204 fn canonical_system_config_recovery_retires_boot_wal_without_losing_other_actions() {
4205 use crate::storage::wal::{WalRecord, WalWriter};
4206 use reddb_file::StoreWalActionFrame as Action;
4207 for pending_dwb in [false, true] {
4208 let fixture = duplicate_system_catalog_fixture_with_key(
4209 true,
4210 true,
4211 true,
4212 false,
4213 Some("red.system.pid"),
4214 );
4215 let encode = |action| reddb_file::encode_store_wal_action_frame(&action).unwrap();
4216 let upsert = |id, value| {
4217 encode(Action::UpsertEntityRecord {
4218 collection: "red_config".into(),
4219 record: UnifiedStore::serialize_entity_record(
4220 &config_recovery_row(id, "red.system.pid", value),
4221 None,
4222 STORE_VERSION_CURRENT,
4223 ),
4224 })
4225 };
4226 let delete = |id| {
4227 encode(Action::DeleteEntityRecord {
4228 collection: "red_config".into(),
4229 entity_id: id,
4230 })
4231 };
4232 let wal = reddb_file::unified_wal_path(&fixture.path);
4233 {
4234 let mut writer = WalWriter::open(&wal).unwrap();
4235 writer
4236 .append(&WalRecord::TxCommitBatch {
4237 tx_id: 1001,
4238 actions: vec![
4239 upsert(10000, 3),
4240 delete(1),
4241 encode(Action::CreateCollection {
4242 name: "durable_rows".into(),
4243 }),
4244 ],
4245 })
4246 .unwrap();
4247 writer
4248 .append(&WalRecord::TxCommitBatch {
4249 tx_id: 1002,
4250 actions: vec![
4251 upsert(10001, 4),
4252 delete(10000),
4253 encode(Action::UpsertEntityRecord {
4254 collection: "durable_rows".into(),
4255 record: UnifiedStore::serialize_entity_record(
4256 &UnifiedEntity::table_row(
4257 EntityId::new(3),
4258 "durable_rows",
4259 1,
4260 vec![Value::text("must survive")],
4261 ),
4262 None,
4263 STORE_VERSION_CURRENT,
4264 ),
4265 }),
4266 ],
4267 })
4268 .unwrap();
4269 writer.sync().unwrap();
4270 }
4271 if pending_dwb {
4272 use std::io::{Seek, SeekFrom, Write};
4273 let bytes = std::fs::read(&fixture.path).unwrap();
4274 let root = fixture.legacy_root.unwrap();
4275 let offset = root as usize * PAGE_SIZE;
4276 let page: &[u8; PAGE_SIZE] = bytes[offset..offset + PAGE_SIZE].try_into().unwrap();
4277 let frame = reddb_file::encode_paged_dwb_frame([(root, page)]);
4278 let mut file = std::fs::OpenOptions::new()
4279 .write(true)
4280 .open(&fixture.path)
4281 .unwrap();
4282 file.seek(SeekFrom::Start(3 * PAGE_SIZE as u64)).unwrap();
4283 file.write_all(&frame).unwrap();
4284 file.seek(SeekFrom::Start(offset as u64)).unwrap();
4287 file.write_all(&[0u8; PAGE_SIZE]).unwrap();
4288 file.sync_all().unwrap();
4289 }
4290 let original = std::fs::read(&fixture.path).unwrap();
4291 let original_wal = std::fs::read(&wal).unwrap();
4292 let destination = fixture._directory.path().join("boot-config-recovered");
4293 let report = crate::storage::compact::recover_paged_wal_canonical_system_config(
4294 &fixture.path,
4295 &destination,
4296 )
4297 .expect("retire only proved boot metadata actions on the isolated copy");
4298 assert_eq!(report.retired_boot_system_wal_actions, 4);
4299 assert_eq!(std::fs::read(&fixture.path).unwrap(), original);
4300 assert_eq!(std::fs::read(&wal).unwrap(), original_wal);
4301 for _ in 0..2 {
4302 let recovered = UnifiedStore::open_with_config(
4303 destination.join("data.rdb"),
4304 fixture.config.clone(),
4305 )
4306 .unwrap();
4307 let manager = recovered.get_collection("red.config").unwrap();
4308 assert!(manager.identity_counters().0 > 10001);
4309 assert!(manager.identity_counters().1 > 10001);
4310 assert_eq!(
4311 recovered.get_config("red.system.pid"),
4312 Some(Value::Integer(2))
4313 );
4314 assert!(recovered.get("red.config", EntityId::new(1)).is_none());
4315 assert!(recovered.get("red.config", EntityId::new(10000)).is_none());
4316 assert!(recovered.get("red.config", EntityId::new(10001)).is_none());
4317 assert_eq!(
4318 recovered
4319 .get("durable_rows", EntityId::new(3))
4320 .unwrap()
4321 .data
4322 .as_row()
4323 .unwrap()
4324 .columns,
4325 vec![Value::text("must survive")]
4326 );
4327 }
4328 }
4329 }
4330
4331 #[test]
4332 fn canonical_system_config_recovery_requires_stable_boot_identity_evidence() {
4333 use crate::storage::wal::{WalRecord, WalWriter};
4334 let fixture = duplicate_system_catalog_fixture_with_key(
4335 true,
4336 true,
4337 false,
4338 false,
4339 Some("red.system.pid"),
4340 );
4341 let original = std::fs::read(&fixture.path).unwrap();
4342 let wal = reddb_file::unified_wal_path(&fixture.path);
4343 {
4344 let mut writer = WalWriter::open(&wal).unwrap();
4345 writer
4346 .append(&WalRecord::TxCommitBatch {
4347 tx_id: 1004,
4348 actions: vec![reddb_file::encode_store_wal_action_frame(
4349 &reddb_file::StoreWalActionFrame::DeleteEntityRecord {
4350 collection: "red_config".into(),
4351 entity_id: 1,
4352 },
4353 )
4354 .unwrap()],
4355 })
4356 .unwrap();
4357 writer
4358 .append(&WalRecord::FullPageImage {
4359 tx_id: 1004,
4360 page_id: 1,
4361 ckpt_epoch: 1,
4362 data: original[PAGE_SIZE..2 * PAGE_SIZE].to_vec(),
4363 })
4364 .unwrap();
4365 writer.sync().unwrap();
4366 }
4367 let original_wal = std::fs::read(&wal).unwrap();
4368 let destination = fixture._directory.path().join("unproved-checkpoint");
4369 let error = crate::storage::compact::recover_paged_wal_canonical_system_config(
4370 &fixture.path,
4371 &destination,
4372 )
4373 .expect_err("undo images must be recovered before proving legacy identities");
4374 assert!(error
4375 .to_string()
4376 .contains("requires a stable checkpoint image"));
4377 assert!(!destination.exists());
4378 assert_eq!(std::fs::read(&fixture.path).unwrap(), original);
4379 assert_eq!(std::fs::read(&wal).unwrap(), original_wal);
4380 }
4381
4382 #[test]
4383 fn canonical_system_config_recovery_rejects_unproved_boot_wal() {
4384 use crate::storage::wal::{WalRecord, WalWriter};
4385 use reddb_file::StoreWalActionFrame as Action;
4386 for (case, checkpoint_key, wal_key, delete_id) in [
4387 (
4388 "unknown-delete",
4389 "red.system.pid",
4390 "red.system.pid",
4391 Some(90000),
4392 ),
4393 (
4394 "user-key",
4395 "red.system.customer_setting",
4396 "red.system.customer_setting",
4397 None,
4398 ),
4399 (
4400 "identity-reuse",
4401 "red.config.customer_setting",
4402 "red.system.pid",
4403 None,
4404 ),
4405 ] {
4406 let fixture = duplicate_system_catalog_fixture_with_key(
4407 true,
4408 true,
4409 false,
4410 false,
4411 Some(checkpoint_key),
4412 );
4413 let action = if let Some(entity_id) = delete_id {
4414 Action::DeleteEntityRecord {
4415 collection: "red_config".into(),
4416 entity_id,
4417 }
4418 } else {
4419 Action::UpsertEntityRecord {
4420 collection: "red_config".into(),
4421 record: UnifiedStore::serialize_entity_record(
4422 &config_recovery_row(1, wal_key, 99),
4423 None,
4424 STORE_VERSION_CURRENT,
4425 ),
4426 }
4427 };
4428 let wal = reddb_file::unified_wal_path(&fixture.path);
4429 {
4430 let mut writer = WalWriter::open(&wal).unwrap();
4431 writer
4432 .append(&WalRecord::TxCommitBatch {
4433 tx_id: 1003,
4434 actions: vec![reddb_file::encode_store_wal_action_frame(&action).unwrap()],
4435 })
4436 .unwrap();
4437 writer.sync().unwrap();
4438 }
4439 let original = std::fs::read(&fixture.path).unwrap();
4440 let original_wal = std::fs::read(&wal).unwrap();
4441 let destination = fixture._directory.path().join(case);
4442 let error = crate::storage::compact::recover_paged_wal_canonical_system_config(
4443 &fixture.path,
4444 &destination,
4445 )
4446 .expect_err(case);
4447 assert!(
4448 error
4449 .to_string()
4450 .contains("WAL references divergent legacy red_config"),
4451 "{case}: {error}"
4452 );
4453 assert!(!destination.exists());
4454 assert_eq!(std::fs::read(&fixture.path).unwrap(), original);
4455 assert_eq!(std::fs::read(&wal).unwrap(), original_wal);
4456 }
4457 }
4458
4459 #[test]
4460 fn canonical_system_config_recovery_rejects_legacy_wal_actions() {
4461 use crate::storage::wal::{WalRecord, WalWriter};
4462
4463 let fixture = duplicate_system_catalog_fixture(true, true, false);
4464 let wal = reddb_file::unified_wal_path(&fixture.path);
4465 let action = reddb_file::encode_store_wal_action_frame(
4466 &reddb_file::StoreWalActionFrame::DeleteEntityRecord {
4467 collection: "red_config".into(),
4468 entity_id: 1,
4469 },
4470 )
4471 .unwrap();
4472 {
4473 let mut writer = WalWriter::open(&wal).unwrap();
4474 writer
4475 .append(&WalRecord::TxCommitBatch {
4476 tx_id: 999,
4477 actions: vec![action],
4478 })
4479 .unwrap();
4480 writer.sync().unwrap();
4481 }
4482 let original = std::fs::read(&fixture.path).unwrap();
4483 let original_wal = std::fs::read(&wal).unwrap();
4484 let destination = fixture._directory.path().join("rejected-legacy-wal");
4485 let error = crate::storage::compact::recover_paged_wal_canonical_system_config(
4486 &fixture.path,
4487 &destination,
4488 )
4489 .expect_err("legacy WAL must not be replayed against the canonical tree");
4490 assert!(error
4491 .to_string()
4492 .contains("WAL references divergent legacy red_config"));
4493 assert!(!destination.exists());
4494 assert_eq!(std::fs::read(&fixture.path).unwrap(), original);
4495 assert_eq!(std::fs::read(&wal).unwrap(), original_wal);
4496 }
4497
4498 #[test]
4499 fn canonical_system_config_recovery_rejects_legacy_cross_references() {
4500 use crate::storage::wal::{WalRecord, WalWriter};
4501
4502 let fixture = duplicate_system_catalog_fixture_with_ref(true, true, false, true);
4503 let wal = reddb_file::unified_wal_path(&fixture.path);
4504 {
4505 let mut writer = WalWriter::open(&wal).unwrap();
4506 writer.append(&WalRecord::Begin { tx_id: 999 }).unwrap();
4507 writer.sync().unwrap();
4508 }
4509 let original = std::fs::read(&fixture.path).unwrap();
4510 let original_wal = std::fs::read(&wal).unwrap();
4511 let destination = fixture._directory.path().join("rejected-legacy-reference");
4512 let error = crate::storage::compact::recover_paged_wal_canonical_system_config(
4513 &fixture.path,
4514 &destination,
4515 )
4516 .expect_err("legacy references must not be retargeted silently");
4517 assert!(error
4518 .to_string()
4519 .contains("legacy red_config has cross-references"));
4520 assert!(!destination.join("data.rdb").exists());
4521 assert_eq!(std::fs::read(&fixture.path).unwrap(), original);
4522 assert_eq!(std::fs::read(&wal).unwrap(), original_wal);
4523 }
4524
4525 #[test]
4526 fn populated_system_collection_aliases_reconcile_without_rebuild() {
4527 let fixture = duplicate_system_catalog_fixture(true, false, true);
4528 alias_duplicate_system_catalog_state(&fixture);
4529
4530 let recovered = UnifiedStore::open_with_config(&fixture.path, fixture.config.clone())
4531 .expect("reconcile identical populated aliases");
4532 assert_eq!(recovered.list_collections(), vec!["red.config".to_string()]);
4533 assert_eq!(
4534 recovered
4535 .get("red.config", EntityId::new(1))
4536 .expect("preserved aliased row")
4537 .kind
4538 .collection(),
4539 "red.config"
4540 );
4541 assert_eq!(
4542 recovered.collection_root_page("red.config"),
4543 fixture.legacy_root,
4544 "the shared data root must be reused"
4545 );
4546 assert_eq!(
4547 recovered
4548 .key_indices
4549 .read()
4550 .get("red.config")
4551 .map(|index| index.tree.root_page_id()),
4552 fixture.legacy_key_root,
4553 "the shared key-index root must be reused"
4554 );
4555 assert_eq!(
4556 recovered
4557 .secondary_index("red.config", "by_id", &fixture.index_identity)
4558 .expect("shared secondary index")
4559 .root_page_id(),
4560 fixture
4561 .legacy_secondary_root
4562 .expect("fixture secondary root")
4563 );
4564 drop(recovered);
4565
4566 CHECKPOINT_SERIALIZED_ROWS.with(|count| count.set(0));
4567 let cold = UnifiedStore::open_with_config(&fixture.path, fixture.config)
4568 .expect("idempotent cold boot");
4569 assert_eq!(cold.list_collections(), vec!["red.config".to_string()]);
4570 assert_eq!(CHECKPOINT_SERIALIZED_ROWS.with(|count| count.get()), 0);
4571 assert_eq!(
4572 cold.pager()
4573 .expect("pager")
4574 .page_count()
4575 .expect("page count"),
4576 fixture.pages_before,
4577 "reconciling a physical alias must not extend the file"
4578 );
4579 }
4580
4581 #[test]
4582 fn populated_system_collection_aliases_merge_resume_counter_drift() {
4583 let fixture = duplicate_system_catalog_fixture(true, false, false);
4584 alias_duplicate_system_catalog_state_with(&fixture, |canonical| {
4585 canonical.entities += 7;
4586 canonical.entity_id_next += 100;
4587 canonical.row_id_next += 200;
4588 canonical.sequence_id_next += 300;
4589 });
4590
4591 let recovered = UnifiedStore::open_with_config(&fixture.path, fixture.config.clone())
4592 .expect("reconcile shared roots with conservative counters");
4593 let manager = recovered
4594 .get_collection("red.config")
4595 .expect("canonical collection");
4596 assert_eq!(manager.count(), 1, "the shared tree owns the exact count");
4597 assert_eq!(manager.identity_counters(), (102, 202));
4598 assert_eq!(manager.sequence_id_next(), 301);
4599 assert_eq!(
4600 recovered.collection_root_page("red.config"),
4601 fixture.legacy_root,
4602 "counter reconciliation must keep the shared data root"
4603 );
4604 drop(manager);
4605 drop(recovered);
4606
4607 CHECKPOINT_SERIALIZED_ROWS.with(|count| count.set(0));
4608 let cold = UnifiedStore::open_with_config(&fixture.path, fixture.config)
4609 .expect("idempotent cold boot after counter reconciliation");
4610 assert_eq!(cold.list_collections(), vec!["red.config".to_string()]);
4611 assert_eq!(CHECKPOINT_SERIALIZED_ROWS.with(|count| count.get()), 0);
4612 assert_eq!(
4613 cold.pager()
4614 .expect("pager")
4615 .page_count()
4616 .expect("page count"),
4617 fixture.pages_before,
4618 "counter reconciliation must not extend the file"
4619 );
4620 }
4621
4622 fn row(id: u64, value: i64) -> UnifiedEntity {
4623 UnifiedEntity::table_row(EntityId::new(id), "rows", id, vec![Value::Integer(value)])
4624 }
4625
4626 #[test]
4627 fn checkpoint_reuse_tracks_manager_updates_metadata_deletes_and_bulk_inserts() {
4628 let directory = tempfile::tempdir().expect("temporary store");
4629 let path = directory.path().join("data.rdb");
4630 let store = UnifiedStore::open(&path).expect("paged store");
4631 store.create_collection("rows").expect("collection");
4632 store.insert("rows", row(1, 1)).expect("seed");
4633 store.persist().expect("initial checkpoint");
4634 let manager = store.get_collection("rows").expect("rows");
4635 manager.update(row(1, 2)).expect("direct update");
4636 CHECKPOINT_SERIALIZED_ROWS.with(|count| count.set(0));
4637 store.persist().expect("updated row checkpoint");
4638 assert_eq!(CHECKPOINT_SERIALIZED_ROWS.with(|count| count.get()), 1);
4639 let mut metadata = Metadata::new();
4640 metadata.set("verified", MetadataValue::Bool(true));
4641 manager
4642 .set_metadata(EntityId::new(1), metadata)
4643 .expect("metadata");
4644 CHECKPOINT_SERIALIZED_ROWS.with(|count| count.set(0));
4645 store.persist().expect("metadata-only checkpoint");
4646 assert_eq!(CHECKPOINT_SERIALIZED_ROWS.with(|count| count.get()), 1);
4647 manager
4648 .bulk_insert(vec![row(2, 2), row(3, 3)])
4649 .expect("bulk insert");
4650 CHECKPOINT_SERIALIZED_ROWS.with(|count| count.set(0));
4651 store.persist().expect("bulk insert checkpoint");
4652 assert_eq!(CHECKPOINT_SERIALIZED_ROWS.with(|count| count.get()), 3);
4653 manager.delete(EntityId::new(2)).expect("delete");
4654 CHECKPOINT_SERIALIZED_ROWS.with(|count| count.set(0));
4655 store.persist().expect("changed checkpoint");
4656 assert_eq!(CHECKPOINT_SERIALIZED_ROWS.with(|count| count.get()), 2);
4657 drop(store);
4658 let recovered = UnifiedStore::open(&path).expect("recover changed rows");
4659 let manager = recovered.get_collection("rows").expect("rows");
4660 assert_eq!(manager.count(), 2);
4661 assert_eq!(
4662 manager
4663 .get(EntityId::new(1))
4664 .expect("row")
4665 .data
4666 .as_row()
4667 .expect("table")
4668 .columns,
4669 vec![Value::Integer(2)]
4670 );
4671 assert!(manager.get(EntityId::new(2)).is_none());
4672 assert!(manager
4673 .get_metadata(EntityId::new(1))
4674 .expect("metadata")
4675 .get("verified")
4676 .is_some());
4677 }
4678
4679 #[test]
4680 fn unrelated_visibility_changes_do_not_serialize_collection_rows() {
4681 let directory = tempfile::tempdir().expect("temporary store");
4682 let store = UnifiedStore::open(&directory.path().join("data.rdb")).expect("paged store");
4683 let snapshots = Arc::new(crate::storage::transaction::snapshot::SnapshotManager::new());
4684 assert!(store.snapshot_manager.set(Arc::clone(&snapshots)).is_ok());
4685 store.create_collection("rows").expect("collection");
4686 for id in 1..=40 {
4687 store.insert("rows", row(id, id as i64)).expect("seed");
4688 }
4689 store.persist().expect("initial checkpoint");
4690 let unrelated = snapshots.begin();
4691 snapshots.rollback(unrelated);
4692 CHECKPOINT_SERIALIZED_ROWS.with(|count| count.set(0));
4693 store.persist().expect("unrelated rollback checkpoint");
4694 assert_eq!(CHECKPOINT_SERIALIZED_ROWS.with(|count| count.get()), 0);
4695
4696 store
4697 .pending_vault_versions
4698 .write()
4699 .insert(EntityId::new(100));
4700 CHECKPOINT_SERIALIZED_ROWS.with(|count| count.set(0));
4701 store.persist().expect("unrelated vault checkpoint");
4702 assert_eq!(CHECKPOINT_SERIALIZED_ROWS.with(|count| count.get()), 0);
4703 store.pending_vault_versions.write().clear();
4704 CHECKPOINT_SERIALIZED_ROWS.with(|count| count.set(0));
4705 store
4706 .persist()
4707 .expect("unrelated vault publication checkpoint");
4708 assert_eq!(CHECKPOINT_SERIALIZED_ROWS.with(|count| count.get()), 0);
4709 }
4710
4711 #[test]
4712 fn low_level_rollback_of_a_non_active_xid_invalidates_sparse_visibility() {
4713 let directory = tempfile::tempdir().expect("temporary store");
4714 let path = directory.path().join("data.rdb");
4715 let store = UnifiedStore::open(&path).expect("paged store");
4716 let snapshots = Arc::new(crate::storage::transaction::snapshot::SnapshotManager::new());
4717 assert!(store.snapshot_manager.set(Arc::clone(&snapshots)).is_ok());
4718 store.create_collection("rows").expect("collection");
4719 let xid = snapshots.begin();
4720 let mut entity = row(1, 1);
4721 entity.xmin = xid;
4722 store.insert("rows", entity).expect("committed row");
4723 snapshots.commit(xid);
4724 store.persist().expect("committed baseline");
4725 snapshots.rollback(xid);
4726 store
4727 .persist()
4728 .expect("untracked outcome requires validation");
4729 drop(store);
4730 let recovered = UnifiedStore::open(&path).expect("recover");
4731 assert_eq!(recovered.get_collection("rows").expect("rows").count(), 0);
4732 }
4733
4734 #[test]
4735 fn exhausted_deferred_row_cache_keeps_full_validation() {
4736 let directory = tempfile::tempdir().expect("temporary store");
4737 let path = directory.path().join("data.rdb");
4738 let store = UnifiedStore::open(&path).expect("paged store");
4739 let snapshots = Arc::new(crate::storage::transaction::snapshot::SnapshotManager::new());
4740 assert!(store.snapshot_manager.set(Arc::clone(&snapshots)).is_ok());
4741 store.create_collection("rows").expect("collection");
4742 let xid = snapshots.begin();
4743 for id in 1..=1025 {
4744 let mut entity = row(id, id as i64);
4745 entity.xmin = xid;
4746 store.insert("rows", entity).expect("pending row");
4747 }
4748 store.persist().expect("exclude pending rows");
4749 assert!(store.checkpoint_versions.read()["rows"]
4750 .deferred_rows
4751 .is_none());
4752 snapshots.commit(xid);
4753 store
4754 .persist()
4755 .expect("publish committed rows conservatively");
4756 drop(store);
4757 let recovered = UnifiedStore::open(&path).expect("recover");
4758 assert_eq!(
4759 recovered.get_collection("rows").expect("rows").count(),
4760 1025
4761 );
4762 }
4763
4764 #[test]
4765 fn rollback_invalidates_reuse_without_a_segment_mutation() {
4766 let directory = tempfile::tempdir().expect("temporary store");
4767 let path = directory.path().join("data.rdb");
4768 let store = UnifiedStore::open(&path).expect("paged store");
4769 let snapshots = Arc::new(crate::storage::transaction::snapshot::SnapshotManager::new());
4770 assert!(store.snapshot_manager.set(Arc::clone(&snapshots)).is_ok());
4771 store.create_collection("rows").expect("collection");
4772 let xid = snapshots.begin();
4773 let mut pending = row(1, 1);
4774 pending.xmin = xid;
4775 store.insert("rows", pending).expect("version");
4776 store.persist().expect("before rollback");
4777 snapshots.rollback(xid);
4778 store.persist().expect("after rollback");
4779 drop(store);
4780 let recovered = UnifiedStore::open(&path).expect("recover");
4781 assert_eq!(recovered.get_collection("rows").expect("rows").count(), 0);
4782 }
4783
4784 #[test]
4785 fn rolled_back_delete_is_normalized_without_a_segment_mutation() {
4786 let directory = tempfile::tempdir().expect("temporary store");
4787 let path = directory.path().join("data.rdb");
4788 let store = UnifiedStore::open(&path).expect("paged store");
4789 let snapshots = Arc::new(crate::storage::transaction::snapshot::SnapshotManager::new());
4790 assert!(store.snapshot_manager.set(Arc::clone(&snapshots)).is_ok());
4791 store.create_collection("rows").expect("collection");
4792 let xid = snapshots.begin();
4793 let mut deleted = row(1, 1);
4794 deleted.xmax = xid;
4795 store.insert("rows", deleted).expect("version");
4796 store.persist().expect("before rollback");
4797 snapshots.rollback(xid);
4798 store.persist().expect("after rollback");
4799 drop(store);
4800 let recovered = UnifiedStore::open(&path).expect("recover");
4801 let manager = recovered.get_collection("rows").expect("rows");
4802 assert_eq!(manager.get(EntityId::new(1)).expect("retained row").xmax, 0);
4803 }
4804
4805 #[test]
4806 fn publishing_a_pending_vault_version_invalidates_reuse() {
4807 let directory = tempfile::tempdir().expect("temporary store");
4808 let path = directory.path().join("data.rdb");
4809 let store = UnifiedStore::open(&path).expect("paged store");
4810 store.create_collection("rows").expect("collection");
4811 store.insert("rows", row(1, 1)).expect("row");
4812 store
4813 .pending_vault_versions
4814 .write()
4815 .insert(EntityId::new(1));
4816 store.persist().expect("exclude pending vault version");
4817 store
4818 .pending_vault_versions
4819 .write()
4820 .remove(&EntityId::new(1));
4821 store.persist().expect("include published vault version");
4822 drop(store);
4823 let recovered = UnifiedStore::open(&path).expect("recover");
4824 assert_eq!(recovered.get_collection("rows").expect("rows").count(), 1);
4825 }
4826}