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