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