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