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