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