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