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 state = resume
2182 .as_ref()
2183 .and_then(|resume| resume.collections.get(&stored_name))
2184 .expect("present recovery config identity was checked");
2185 if state.root_page != root_page {
2186 return Err(page_integrity(
2187 1,
2188 None,
2189 "config collection root differs from resume catalog",
2190 ));
2191 }
2192 }
2193 }
2194 if ignored_collections.contains(&stored_name) {
2195 if resume.is_none() && root_page == 0 {
2196 continue;
2197 }
2198 let shared_physical_state = outcome
2199 .collisions
2200 .iter()
2201 .find(|collision| collision.loser == stored_name)
2202 .is_some_and(|collision| collision.shared_physical_state);
2203 if shared_physical_state {
2204 continue;
2205 }
2206 let state = resume
2207 .as_ref()
2208 .and_then(|resume| resume.collections.get(&stored_name))
2209 .expect("ignored collection was proven by catalog resume");
2210 for retired_root in std::iter::once(state.root_page)
2211 .chain(state.key_index_root)
2212 .chain(
2213 state
2214 .secondary_indices
2215 .values()
2216 .map(|index| index.root_page),
2217 )
2218 .filter(|root| *root != 0)
2219 {
2220 empty_duplicate_roots.insert(retired_root);
2221 }
2222 continue;
2223 }
2224 let name = reddb_engine::storage::system_collection_names::canonical_or_self(
2225 &stored_name,
2226 )
2227 .to_owned();
2228 if let Some(previous) = source_names.insert(name.clone(), stored_name.clone()) {
2229 if previous != stored_name {
2230 return Err(page_integrity(
2231 1,
2232 None,
2233 format!(
2234 "catalog contains both legacy collection '{previous}' and canonical collection '{name}'"
2235 ),
2236 ));
2237 }
2238 }
2239 migrated_catalog |= name != stored_name;
2240
2241 let _ = self.create_collection_in_memory(&name);
2245
2246 let shared_alias = outcome.collisions.iter().find(|collision| {
2247 collision.survivor == stored_name && collision.shared_physical_state
2248 });
2249 let counters = shared_alias
2250 .and_then(|collision| collision.merged_resume.as_ref())
2251 .or_else(|| {
2252 resume
2253 .as_ref()
2254 .and_then(|resume| resume.collections.get(&stored_name))
2255 });
2256 let manager = self.get_collection(&name);
2257 let mut count_seed =
2258 if counters.is_none() && self.commit.is_some() && !self.is_read_only() {
2259 let schema =
2260 manager.as_ref().and_then(|manager| manager.column_schema());
2261 super::count_index::CountProjectionSeedCollector::new(
2262 schema,
2263 count_seed_bytes_remaining,
2264 )
2265 } else {
2266 None
2267 };
2268 if let (Some(counters), Some(manager)) = (counters, self.get_collection(&name))
2269 {
2270 manager.restore_identity_counters(
2271 counters.entity_id_next,
2272 counters.row_id_next,
2273 );
2274 manager.restore_sequence_id_next(counters.sequence_id_next);
2275 let expiry_entries = counters
2276 .metadata_expiries
2277 .iter()
2278 .map(|entry| (entry.deadline_ms, EntityId::new(entry.entity_id)))
2279 .collect::<Vec<_>>();
2280 manager.restore_metadata_expiry_projection(
2281 &expiry_entries,
2282 resume
2283 .as_ref()
2284 .is_some_and(|resume| resume.metadata_expiry_prepared),
2285 );
2286 }
2287 if root_page > 0 || counters.is_some() {
2289 let btree = restore_published_tree(pager, root_page, proofs.as_ref())?;
2290
2291 let mut cursor = btree.cursor_first().map_err(|err| {
2293 page_integrity(root_page, Some(name.clone()), err.to_string())
2294 })?;
2295 let hydrate_read_only =
2296 self.is_read_only() && self.config.resident_memory_bytes_max.is_none();
2297 let mut persisted_entities = if hydrate_read_only {
2298 0
2299 } else {
2300 counters.map_or(0, |state| state.entities)
2301 };
2302 while counters.is_none() || hydrate_read_only {
2303 let next = cursor
2304 .next_with_budget(
2305 self.config
2306 .resident_memory_bytes_max
2307 .and_then(|limit| usize::try_from(limit).ok())
2308 .unwrap_or(usize::MAX),
2309 )
2310 .map_err(|err| {
2311 page_integrity(root_page, Some(name.clone()), err.to_string())
2312 })?;
2313 let Some((key, value)) = next else {
2314 break;
2315 };
2316 persisted_entities += 1;
2317 let key_bytes: [u8; 8] = key.as_slice().try_into().map_err(|_| {
2319 page_integrity(
2320 root_page,
2321 Some(name.clone()),
2322 "physical entity key must contain eight bytes",
2323 )
2324 })?;
2325 let record = reddb_engine::storage::collection_read::EncodedRecord {
2326 id: EntityId::new(u64::from_be_bytes(key_bytes)),
2327 bytes: value,
2328 };
2329 let decoded = if let Some(limit) = self.config.resident_memory_bytes_max
2330 {
2331 record.decode_with_budget(
2332 self.format_version(),
2333 usize::try_from(limit).unwrap_or(usize::MAX),
2334 )
2335 } else {
2336 record.decode(self.format_version())
2337 };
2338 let (entity, metadata) = decoded.map_err(|err| {
2339 page_integrity(root_page, Some(name.clone()), err.to_string())
2340 })?;
2341 scanned_xid_high_water_mark =
2342 scanned_xid_high_water_mark.max(entity.max_transaction_id());
2343 let discard_count_seed = if let Some(seed) = count_seed.as_mut() {
2344 match seed.push(&entity) {
2345 Ok(complete) => !complete,
2346 Err(error) => {
2347 return Err(page_integrity(
2348 root_page,
2349 Some(name.clone()),
2350 error.to_string(),
2351 ));
2352 }
2353 }
2354 } else {
2355 false
2356 };
2357 if discard_count_seed {
2358 count_seed = None;
2359 }
2360 if let Some(m) = &manager {
2361 let id = entity.id;
2362 if let EntityKind::TableRow { row_id, .. } = &entity.kind {
2363 m.register_row_id(*row_id);
2364 }
2365 if self.config.resident_memory_bytes_max.is_some() {
2366 m.restore_metadata_expiry(&entity, metadata.as_ref());
2367 self.register_entity_id(id);
2368 m.restore_sequence_id_next(
2369 entity.sequence_id.saturating_add(1),
2370 );
2371 m.restore_identity_counters(
2372 id.raw().saturating_add(1),
2373 m.identity_counters().1,
2374 );
2375 continue;
2376 }
2377 self.resident.context_index.index_entity(&name, &entity);
2378 m.restore_snapshot_entity(entity.clone())?;
2379 if let Some(metadata) = metadata {
2380 let _ = m.set_metadata(id, metadata);
2381 }
2382 self.register_entity_id(id);
2383 if self.config.auto_index_refs {
2384 self.index_cross_refs(&entity, &name)?;
2385 }
2386 }
2387 }
2388
2389 if let Some(seed) = count_seed.take() {
2390 count_seed_bytes_remaining =
2391 count_seed_bytes_remaining.saturating_sub(seed.bytes());
2392 self.remember_count_projection_seed(&name, root_page, seed);
2393 }
2394
2395 let btree = Arc::new(btree);
2397 if shared_alias.is_some_and(|collision| collision.resume_counter_drift) {
2398 persisted_entities = u64::try_from(btree.count().map_err(|err| {
2399 page_integrity(root_page, Some(name.clone()), err.to_string())
2400 })?)
2401 .map_err(|_| {
2402 page_integrity(
2403 root_page,
2404 Some(name.clone()),
2405 "shared collection entity count exceeds u64",
2406 )
2407 })?;
2408 }
2409 let key_tree = counters
2410 .and_then(|state| state.key_index_root)
2411 .map(|root| {
2412 restore_published_tree(pager, root, proofs.as_ref()).map(Arc::new)
2413 })
2414 .transpose()?;
2415 if let Some(counters) = counters {
2416 self.key_indices.write().insert(
2417 name.clone(),
2418 PublishedKeyIndex {
2419 indexed: key_tree.is_some(),
2420 secondary: counters.secondary_indices.iter().map(|(name, index)| {
2421 Ok((name.clone(), super::secondary_index::PublishedSecondaryIndex {
2422 identity: super::secondary_index::SecondaryIndexIdentity {
2423 codec_version: index.codec_version,
2424 definition: index.definition,
2425 },
2426 tree: Arc::new(restore_published_tree(pager, index.root_page, proofs.as_ref())?),
2427 }))
2428 }).collect::<Result<_, StoreError>>()?,
2429 source: Arc::downgrade(&btree),
2430 tree: match &key_tree { Some(tree) => Arc::clone(tree), None => Arc::new(restore_published_tree(pager, 0, proofs.as_ref())?) },
2431 },
2432 );
2433 }
2434 if let (Some(manager), Some(limit)) =
2435 (&manager, self.config.resident_memory_bytes_max)
2436 {
2437 manager.install_backing(
2438 Arc::new(
2439 match key_tree {
2440 Some(keys) => reddb_engine::storage::collection_read::PagedCollection::with_key_index(Arc::clone(&btree), keys),
2441 None => reddb_engine::storage::collection_read::PagedCollection::new(Arc::clone(&btree)),
2442 },
2443 ),
2444 btree.root_page_id(),
2445 format_version,
2446 usize::try_from(limit).unwrap_or(usize::MAX),
2447 persisted_entities,
2448 );
2449 }
2450 self.btree_indices.write().insert(name.clone(), btree);
2451 if format_version == STORE_VERSION_V11 {
2452 if let Some(manager) = &manager {
2453 self.checkpoint_versions.write().insert(
2454 name,
2455 CheckpointVersion {
2456 manager: Arc::downgrade(manager),
2457 segments: manager.checkpoint_revisions(),
2458 visibility_revision: 0,
2459 untracked_visibility_revision: 0,
2460 excluded_xids: Arc::default(),
2461 pending_vault: Arc::default(),
2462 format_version,
2463 deferred_rows: Some(Arc::default()),
2464 has_unprojected_rows: false,
2465 },
2466 );
2467 }
2468 }
2469 }
2470 }
2471
2472 let expected_config_roots = recovery_config_identities.map(u8::from);
2473 if retain_canonical_system_config && recovery_config_roots != expected_config_roots
2474 {
2475 return Err(page_integrity(
2476 1,
2477 None,
2478 "canonical config recovery requires exactly one root for each resume identity",
2479 ));
2480 }
2481 if retain_canonical_system_config
2482 && (format_version < STORE_VERSION_V2 || pos + 4 > content.len())
2483 {
2484 return Err(page_integrity(
2485 1,
2486 None,
2487 "canonical config recovery requires a complete cross-reference catalog",
2488 ));
2489 }
2490 if format_version >= STORE_VERSION_V2 && pos + 4 <= content.len() {
2491 let cross_ref_count = u32::from_le_bytes([
2492 content[pos],
2493 content[pos + 1],
2494 content[pos + 2],
2495 content[pos + 3],
2496 ]) as usize;
2497 pos += 4;
2498
2499 for _ in 0..cross_ref_count {
2500 let cross_ref =
2501 match reddb_file::decode_native_paged_cross_ref(content, &mut pos) {
2502 Ok(cross_ref) => cross_ref,
2503 Err(error) if retain_canonical_system_config => {
2504 return Err(page_integrity(1, None, error.to_string()));
2505 }
2506 Err(_) => break,
2507 };
2508 if retain_canonical_system_config
2509 && cross_ref.target_collection == "red_config"
2510 {
2511 return Err(page_integrity(
2512 1,
2513 None,
2514 "legacy red_config has cross-references and cannot be retired",
2515 ));
2516 }
2517 let source_id = EntityId::new(cross_ref.source_id);
2518 let target_id = EntityId::new(cross_ref.target_id);
2519 let ref_type = RefType::from_byte(cross_ref.ref_type);
2520 let target_collection =
2521 reddb_engine::storage::system_collection_names::canonical_owned(
2522 cross_ref.target_collection,
2523 );
2524
2525 self.resident
2526 .cross_refs
2527 .write()
2528 .entry(source_id)
2529 .or_default()
2530 .push((target_id, ref_type, target_collection.clone()));
2531
2532 if let Some((collection, mut entity)) = self.get_any(source_id) {
2533 let exists = entity.cross_refs().iter().any(|xref| {
2534 xref.target == target_id
2535 && xref.ref_type == ref_type
2536 && xref.target_collection == target_collection
2537 });
2538 if !exists {
2539 entity.cross_refs_mut().push(CrossRef::new(
2540 source_id,
2541 target_id,
2542 target_collection.clone(),
2543 ref_type,
2544 ));
2545 if let Some(manager) = self.get_collection(&collection) {
2546 let _ = manager.update(entity);
2547 }
2548 }
2549 }
2550 }
2551 }
2552 let retired = reddb_file::decode_native_paged_retired_trees(content, &mut pos)
2553 .map_err(|e| StoreError::Serialization(e.to_string()))?;
2554 let already_retired = retired
2555 .iter()
2556 .copied()
2557 .collect::<std::collections::HashSet<_>>();
2558 let mut live_roots: std::collections::HashSet<_> = self
2559 .btree_indices
2560 .read()
2561 .values()
2562 .map(|tree| tree.root_page_id())
2563 .collect();
2564 live_roots.extend(
2565 self.key_indices
2566 .read()
2567 .values()
2568 .flat_map(PublishedKeyIndex::trees)
2569 .map(|tree| tree.root_page_id()),
2570 );
2571 for root in retired {
2572 if live_roots.contains(&root)
2573 || root
2574 >= pager
2575 .page_count()
2576 .map_err(|e| StoreError::Io(std::io::Error::other(e.to_string())))?
2577 {
2578 return Err(page_integrity(
2579 root,
2580 None,
2581 "retired root aliases a live or missing page",
2582 ));
2583 }
2584 self.retired_btrees.lock().push(RetiredBTree {
2585 tree: Arc::new(restore_published_tree(pager, root, proofs.as_ref())?),
2586 published: true,
2587 });
2588 }
2589 for root in empty_duplicate_roots
2590 .iter()
2591 .copied()
2592 .filter(|root| !already_retired.contains(root))
2593 {
2594 self.retire_btree(Arc::new(restore_published_tree(
2595 pager,
2596 root,
2597 proofs.as_ref(),
2598 )?));
2599 }
2600 if proofs.as_ref().is_some_and(|proofs| {
2601 proofs.iter().any(|(root, proof)| {
2602 let owns = |id| {
2603 live_roots.contains(id)
2604 || already_retired.contains(id)
2605 || empty_duplicate_roots.contains(id)
2606 };
2607 !owns(root) || owns(&proof.reference.page_id)
2608 })
2609 }) {
2610 return Err(page_integrity(
2611 1,
2612 None,
2613 "unowned or aliased tree generation proof",
2614 ));
2615 }
2616 let deferred = reddb_file::decode_native_paged_deferred_pages(content, &mut pos)
2617 .map_err(|e| StoreError::Serialization(e.to_string()))?;
2618 if deferred
2619 .iter()
2620 .any(|id| live_roots.contains(id) || empty_duplicate_roots.contains(id))
2621 {
2622 return Err(page_integrity(1, None, "deferred page aliases a live root"));
2623 }
2624 pager
2625 .restore_deferred_reclamation(&deferred)
2626 .map_err(|e| StoreError::Io(std::io::Error::other(e.to_string())))?;
2627 let ownership_epoch =
2628 reddb_file::native_store::decode_native_paged_ownership_epoch(
2629 content, &mut pos,
2630 )
2631 .map_err(|e| StoreError::Serialization(e.to_string()))?;
2632 self.ownership_catalog_epoch
2633 .store(ownership_epoch.unwrap_or(0), Ordering::Release);
2634 if migrated_catalog {
2635 self.mark_paged_registry_dirty();
2636 }
2637 if self.format_version() < STORE_VERSION_V11 {
2638 self.set_format_version(STORE_VERSION_V11);
2639 }
2640 if self.config.resident_memory_bytes_max.is_some()
2641 && resume.is_none()
2642 && !migrated_catalog
2643 && !retain_canonical_system_config
2644 {
2645 self.remember_startup_xid_high_water_mark(scanned_xid_high_water_mark);
2646 }
2647 outcome.migrated = migrated_catalog;
2648 return Ok(outcome);
2649 }
2650 }
2651
2652 if retain_canonical_system_config {
2653 return Err(page_integrity(
2654 1,
2655 None,
2656 "canonical config recovery requires a complete catalog",
2657 ));
2658 }
2659 if self.format_version() < STORE_VERSION_V11 {
2660 self.set_format_version(STORE_VERSION_V11);
2661 }
2662
2663 Ok(CatalogLoadOutcome::default())
2664 }
2665
2666 pub(crate) fn deserialize_entity(
2668 data: &[u8],
2669 format_version: u32,
2670 ) -> Result<UnifiedEntity, StoreError> {
2671 let mut pos = 0;
2672 Self::read_entity_binary(data, &mut pos, format_version)
2673 .map_err(|e| StoreError::Serialization(e.to_string()))
2674 }
2675
2676 pub(crate) fn serialize_entity(entity: &UnifiedEntity, format_version: u32) -> Vec<u8> {
2678 let mut buf = Vec::with_capacity(256);
2683 Self::write_entity_binary(&mut buf, entity, format_version);
2684 buf
2685 }
2686
2687 pub(crate) fn serialize_entity_record(
2688 entity: &UnifiedEntity,
2689 metadata: Option<&Metadata>,
2690 format_version: u32,
2691 ) -> Vec<u8> {
2692 reddb_engine::storage::metadata_codec::serialize_entity_record(
2693 entity,
2694 metadata,
2695 format_version,
2696 )
2697 }
2698
2699 pub(crate) fn deserialize_entity_record(
2700 data: &[u8],
2701 format_version: u32,
2702 ) -> Result<(UnifiedEntity, Option<Metadata>), StoreError> {
2703 reddb_engine::storage::metadata_codec::deserialize_entity_record(data, format_version)
2704 .map_err(StoreError::Serialization)
2705 }
2706
2707 pub fn persist(&self) -> Result<(), StoreError> {
2712 self.persist_inner(false)
2713 }
2714
2715 pub(crate) fn persist_after_runtime_snapshots(&self) -> Result<(), StoreError> {
2716 self.persist_inner(true)
2717 }
2718
2719 fn persist_inner(&self, runtime_snapshots_durable: bool) -> Result<(), StoreError> {
2720 if self.is_read_only() {
2721 return Ok(());
2722 }
2723 self.ensure_pager_writeback_available()?;
2724 let _vault_guard = self.vault_publication_lock.lock();
2725 let pending_vault_versions = self.pending_vault_versions.read();
2726 let pager = match &self.pager {
2727 Some(p) => p,
2728 None => {
2729 if let Some(path) = &self.db_path {
2731 return self
2732 .save_to_file(path)
2733 .map_err(|e| StoreError::Serialization(e.to_string()));
2734 }
2735 return Err(StoreError::Io(std::io::Error::other(
2736 "No pager or path configured for persistence",
2737 )));
2738 }
2739 };
2740 let _ownership_guard = pager.lock_ownership_mutations();
2743
2744 match pager.read_manifest_page() {
2745 Ok(_) => {}
2746 Err(PagerError::PageNotFound(_)) => {
2747 let meta_page = pager
2748 .allocate_page(crate::storage::engine::PageType::Header)
2749 .map_err(|e| StoreError::Io(std::io::Error::other(e.to_string())))?;
2750 pager
2751 .write_page(meta_page.page_id(), meta_page)
2752 .map_err(|e| StoreError::Io(std::io::Error::other(e.to_string())))?;
2753 }
2754 Err(e) => {
2755 return Err(StoreError::Io(std::io::Error::other(e.to_string())));
2756 }
2757 }
2758
2759 if let Some(commit) = &self.commit {
2760 commit.force_sync().map_err(StoreError::Io)?;
2761 }
2762
2763 struct InterruptedOwnership<'a> {
2764 pager: &'a Pager,
2765 active: bool,
2766 }
2767 impl Drop for InterruptedOwnership<'_> {
2768 fn drop(&mut self) {
2769 if self.active {
2770 self.pager.fence_interrupted_ownership();
2771 }
2772 }
2773 }
2774 let mut ownership = InterruptedOwnership {
2775 pager,
2776 active: self.commit.is_some(),
2777 };
2778 let epoch = if let Some(commit) = &self.commit {
2779 let epoch = commit
2780 .next_page_ownership_epoch(self)
2781 .map_err(StoreError::Io)?;
2782 commit
2783 .begin_page_ownership_epoch(self, epoch)
2784 .map_err(StoreError::Io)?;
2785 Some(epoch)
2786 } else {
2787 None
2788 };
2789
2790 let (visibility_revision, excluded, untracked_visibility_revision) =
2791 self.snapshot_manager.get().map_or_else(
2792 || (0, Default::default(), 0),
2793 |manager| manager.checkpoint_projection_state(),
2794 );
2795 let hidden_graph_ids = self
2796 .resident
2797 .snapshot_excluded_graph_ids(&pending_vault_versions, &excluded);
2798 let collections = self.resident.collections.read();
2799 let mut btree_indices = self.btree_indices.write();
2800
2801 let mut collection_roots: Vec<(String, u32)> = Vec::new();
2803
2804 let mut rebuilt_indices = HashMap::with_capacity(collections.len());
2807 let previous_versions = self.checkpoint_versions.read();
2808 let mut checkpoint_versions = HashMap::with_capacity(collections.len());
2809 let pending_vault = Arc::new(pending_vault_versions.clone());
2810 let excluded = Arc::new(excluded);
2811 for (name, manager) in collections.iter() {
2812 let mut version = CheckpointVersion {
2813 manager: Arc::downgrade(manager),
2814 segments: manager.checkpoint_revisions(),
2815 visibility_revision,
2816 untracked_visibility_revision,
2817 excluded_xids: Arc::clone(&excluded),
2818 pending_vault: Arc::clone(&pending_vault),
2819 format_version: self.format_version(),
2820 deferred_rows: Some(Arc::default()),
2821 has_unprojected_rows: false,
2822 };
2823 if let (Some(previous), Some(tree)) = (
2824 previous_versions.get(name),
2825 btree_indices
2826 .get(name)
2827 .filter(|tree| tree.has_page_proofs()),
2828 ) {
2829 if hidden_graph_ids.is_empty()
2830 && previous.manager.ptr_eq(&version.manager)
2831 && previous.segments == version.segments
2832 && previous.format_version == version.format_version
2833 && !previous.has_unprojected_rows
2834 && checkpoint_visibility_matches(previous, &version)
2835 {
2836 version.deferred_rows = previous.deferred_rows.clone();
2837 collection_roots.push((name.clone(), tree.root_page_id()));
2838 rebuilt_indices.insert(name.clone(), Arc::clone(tree));
2839 checkpoint_versions.insert(name.clone(), version);
2840 continue;
2841 }
2842 }
2843 if let (Some(previous), Some(tree)) = (
2844 previous_versions.get(name),
2845 btree_indices
2846 .get(name)
2847 .filter(|tree| tree.has_page_proofs()),
2848 ) {
2849 if hidden_graph_ids.is_empty()
2850 && previous.manager.ptr_eq(&version.manager)
2851 && previous.format_version == version.format_version
2852 {
2853 if let Some(tree) =
2854 self.sparse_checkpoint_tree(name, manager, tree, previous, &mut version)?
2855 {
2856 collection_roots.push((name.clone(), tree.root_page_id()));
2857 rebuilt_indices.insert(name.clone(), tree);
2858 checkpoint_versions.insert(name.clone(), version);
2859 continue;
2860 }
2861 }
2862 }
2863 let btree = self.checkpoint_tree(
2864 pager,
2865 manager,
2866 btree_indices.get(name),
2867 |entity, metadata| {
2868 #[cfg(test)]
2869 CHECKPOINT_SERIALIZED_ROWS.with(|count| count.set(count.get() + 1));
2870 let hidden = pending_vault_versions.contains(&entity.id)
2871 || excluded.contains(&entity.xmin);
2872 let normalize_xmax = excluded.contains(&entity.xmax);
2873 let normalize_branch = entity.branch_version().is_some_and(|branch| {
2874 branch
2875 .deletions
2876 .iter()
2877 .any(|(_, xid)| excluded.contains(xid))
2878 });
2879 let hide_refs = entity.cross_refs().iter().any(|reference| {
2880 hidden_graph_ids.contains(&reference.source)
2881 || hidden_graph_ids.contains(&reference.target)
2882 });
2883 if hidden || normalize_xmax || normalize_branch || hide_refs {
2884 version.remember_deferred_row(entity.id);
2885 }
2886 if hidden {
2887 return None;
2888 }
2889 let mut normalized = std::borrow::Cow::Borrowed(entity);
2890 if normalize_xmax {
2891 normalized.to_mut().xmax = 0;
2892 }
2893 if normalize_branch {
2894 normalized
2895 .to_mut()
2896 .retain_branch_deletions(|xid| !excluded.contains(&xid));
2897 }
2898 if hide_refs {
2899 normalized.to_mut().cross_refs_mut().retain(|reference| {
2900 !hidden_graph_ids.contains(&reference.source)
2901 && !hidden_graph_ids.contains(&reference.target)
2902 });
2903 }
2904 Some(Self::serialize_entity_record(
2905 &normalized,
2906 metadata.as_ref(),
2907 self.format_version(),
2908 ))
2909 },
2910 )?;
2911 checkpoint_versions.insert(name.clone(), version);
2912
2913 collection_roots.push((name.clone(), btree.root_page_id()));
2914 rebuilt_indices.insert(name.clone(), btree);
2915 }
2916
2917 let mut meta_data = Vec::with_capacity(4096);
2919
2920 let format_version = self.format_version().max(STORE_VERSION_V11);
2921 self.set_format_version(format_version);
2922
2923 reddb_file::encode_native_paged_metadata_header(
2924 &mut meta_data,
2925 reddb_file::NativePagedMetadataHeader {
2926 format_version,
2927 collection_count: collection_roots.len() as u32,
2928 },
2929 );
2930
2931 for (name, root_page) in &collection_roots {
2933 reddb_file::encode_native_paged_collection_root(&mut meta_data, name, *root_page);
2934 }
2935
2936 let cross_refs = self.resident.cross_refs.read();
2938 let total_refs: usize = cross_refs
2939 .iter()
2940 .map(|(source, refs)| {
2941 if hidden_graph_ids.contains(source) {
2942 0
2943 } else {
2944 refs.iter()
2945 .filter(|(target, _, _)| !hidden_graph_ids.contains(target))
2946 .count()
2947 }
2948 })
2949 .sum();
2950 meta_data.extend_from_slice(&(total_refs as u32).to_le_bytes());
2951 for (source_id, refs) in cross_refs.iter() {
2952 if hidden_graph_ids.contains(source_id) {
2953 continue;
2954 }
2955 for (target_id, ref_type, collection) in refs {
2956 if hidden_graph_ids.contains(target_id) {
2957 continue;
2958 }
2959 reddb_file::encode_native_paged_cross_ref(
2960 &mut meta_data,
2961 source_id.raw(),
2962 target_id.raw(),
2963 ref_type.to_byte(),
2964 collection,
2965 );
2966 }
2967 }
2968
2969 for (name, previous) in btree_indices.iter() {
2970 if !rebuilt_indices
2971 .get(name)
2972 .is_some_and(|tree| Arc::ptr_eq(tree, previous))
2973 {
2974 self.retire_btree(Arc::clone(previous));
2975 }
2976 }
2977 #[cfg(test)]
2978 if std::env::var_os("REDDB_EXPERIMENTAL_EXIT_DURING_CHECKPOINT_REBUILD").is_some() {
2979 pager
2980 .sync()
2981 .map_err(|error| StoreError::Io(std::io::Error::other(error.to_string())))?;
2982 eprintln!("experimental checkpoint exit before manifest");
2983 std::process::exit(183);
2984 }
2985 let rebuilt_keys = self.checkpoint_key_indices(pager, &rebuilt_indices)?;
2986 #[cfg(test)]
2987 if std::env::var_os("REDDB_TEST_EXIT_AFTER_SECONDARY_INDEX_BUILD").is_some() {
2988 pager
2989 .sync()
2990 .map_err(|error| StoreError::Io(std::io::Error::other(error.to_string())))?;
2991 std::process::exit(196);
2992 }
2993 self.publish_reclaimable_trees(pager, &mut meta_data, &rebuilt_indices, &rebuilt_keys)?;
2994
2995 #[cfg(test)]
2996 if std::env::var_os("REDDB_TEST_EXIT_AFTER_UNDO_CATALOG_PUBLICATION").is_some() {
2997 std::process::exit(195);
2998 }
2999
3000 *btree_indices = rebuilt_indices;
3003 *self.key_indices.write() = rebuilt_keys;
3004 drop(previous_versions);
3005 *self.checkpoint_versions.write() = checkpoint_versions;
3006
3007 if let Some(commit) = &self.commit {
3008 commit
3009 .finish_page_ownership_epoch(self, epoch.expect("WAL ownership epoch"))
3010 .map_err(StoreError::Io)?;
3011 ownership.active = false;
3012 if runtime_snapshots_durable {
3013 commit
3014 .truncate_after_runtime_checkpoint()
3015 .map_err(StoreError::Io)?;
3016 } else {
3017 commit.truncate().map_err(StoreError::Io)?;
3018 }
3019 }
3020 *self.checkpoint_durable_rows.write() = Default::default();
3021
3022 if let Some(limit) = self.config.resident_memory_bytes_max {
3023 let mut versions = self.checkpoint_versions.write();
3024 for (name, manager) in collections.iter() {
3025 if versions
3026 .get(name)
3027 .is_some_and(|version| !version.has_unprojected_rows)
3028 {
3029 if let Some(tree) = btree_indices.get(name) {
3030 manager.install_backing(
3031 Arc::new(
3032 {
3033 let keys = self.key_indices.read();
3034 let index = keys.get(name).expect("invariant: prepared checkpoint classifies its key index");
3035 if index.indexed {
3036 reddb_engine::storage::collection_read::PagedCollection::with_key_index(Arc::clone(tree), Arc::clone(&index.tree))
3037 } else {
3038 reddb_engine::storage::collection_read::PagedCollection::new(Arc::clone(tree))
3039 }
3040 },
3041 ),
3042 tree.root_page_id(),
3043 self.format_version(),
3044 usize::try_from(limit).unwrap_or(usize::MAX),
3045 manager.count() as u64,
3046 );
3047 if let Some(version) = versions.get_mut(name) {
3048 version.segments = manager.checkpoint_revisions();
3049 }
3050 }
3051 }
3052 }
3053 }
3054
3055 Ok(())
3056 }
3057
3058 pub fn is_paged(&self) -> bool {
3060 self.pager.is_some()
3061 }
3062
3063 pub(crate) fn collection_root_page(&self, collection: &str) -> Option<u32> {
3066 self.btree_indices
3067 .read()
3068 .get(Self::canonical_collection(collection))
3069 .map(|btree| btree.root_page_id())
3070 .filter(|root| *root != 0)
3071 }
3072
3073 pub(crate) fn operational_manifest_for_path(
3076 &self,
3077 path: &Path,
3078 ) -> reddb_file::OperationalManifest {
3079 let mut cached = self.operational_manifest.lock();
3080 if let Some((current, manifest)) = cached.as_ref() {
3081 if current == path {
3082 return manifest.clone();
3083 }
3084 }
3085 let manifest = reddb_file::OperationalManifest::for_db_path(path);
3086 *cached = Some((path.to_owned(), manifest.clone()));
3087 manifest
3088 }
3089
3090 pub fn db_path(&self) -> Option<&Path> {
3092 self.db_path.as_deref()
3093 }
3094}
3095
3096#[cfg(test)]
3097mod checkpoint_work_tests {
3098 use super::*;
3099 use reddb_file::PAGED_PAGE_SIZE as PAGE_SIZE;
3100
3101 struct SystemNameProjection;
3102
3103 impl super::super::secondary_index::SecondaryIndexProjector for SystemNameProjection {
3104 fn project(
3105 &self,
3106 entity: &UnifiedEntity,
3107 key: &mut [u8; super::super::secondary_index::SECONDARY_KEY_BYTES_MAX],
3108 ) -> Result<Option<usize>, StoreError> {
3109 key[..8].copy_from_slice(&entity.id.raw().to_be_bytes());
3110 Ok(Some(8))
3111 }
3112 }
3113
3114 #[cfg(target_os = "linux")]
3115 #[test]
3116 fn interrupted_inline_sparse_checkpoint_replays_durable_rows() {
3117 const CHILD: &str = "REDDB_TEST_INLINE_SPARSE_PATH";
3118 if let Some(path) = std::env::var_os(CHILD) {
3119 let insert = std::env::var_os("REDDB_TEST_INLINE_SPARSE_INSERT").is_some();
3120 let multi = std::env::var_os("REDDB_TEST_INLINE_SPARSE_MULTI").is_some();
3121 let store = UnifiedStore::open(path).expect("open store");
3122 let initial_count = if multi { 600 } else { 1 };
3123 for id in 1..=initial_count {
3124 store.insert_auto("rows", row(id, 1)).expect("seed");
3125 }
3126 store.persist().expect("baseline");
3127 let manager = store.get_collection("rows").expect("collection");
3128 let ids: Vec<u64> = if multi {
3129 (1..=initial_count).collect()
3130 } else {
3131 vec![if insert { 2 } else { 1 }]
3132 };
3133 let mut actions = Vec::new();
3134 for id in ids {
3135 if insert {
3136 manager.insert(row(id, 9)).expect("insert version");
3137 } else {
3138 manager.update(row(id, 9)).expect("update version");
3139 }
3140 let entity = manager.get(EntityId::new(id)).expect("changed entity");
3141 actions.push(StoreWalAction::upsert_entity(
3142 "rows",
3143 &entity,
3144 None,
3145 store.format_version(),
3146 ));
3147 }
3148 let references = store
3149 .commit
3150 .as_ref()
3151 .expect("coordinator")
3152 .append_checkpoint_actions(&actions)
3153 .expect("durable WAL");
3154 store.remember_checkpoint_rows(actions, references);
3155 store.persist().expect("exit after sparse write");
3156 panic!("inline sparse crash hook was not reached");
3157 }
3158 for (insert, multi) in [(false, false), (true, false), (false, true)] {
3159 let directory = tempfile::tempdir().expect("temporary store");
3160 let path = directory.path().join("data.rdb");
3161 let mut command =
3162 std::process::Command::new(std::env::current_exe().expect("test executable"));
3163 command
3164 .args(["--exact", "storage::unified::store::impl_pages::checkpoint_work_tests::interrupted_inline_sparse_checkpoint_replays_durable_rows", "--nocapture"])
3165 .env(CHILD, &path)
3166 .env("REDDB_TEST_EXIT_AFTER_INLINE_SPARSE_WRITE", "1");
3167 if insert {
3168 command.env("REDDB_TEST_INLINE_SPARSE_INSERT", "1");
3169 }
3170 if multi {
3171 command.env("REDDB_TEST_INLINE_SPARSE_MULTI", "1");
3172 }
3173 let output = command.output().expect("crash child");
3174 assert_eq!(
3175 output.status.code(),
3176 Some(190),
3177 "insert={insert} multi={multi}: {}",
3178 String::from_utf8_lossy(&output.stderr)
3179 );
3180 let recovered = UnifiedStore::open(&path).expect("recover");
3181 let ids: Vec<u64> = if multi {
3182 (1..=600).collect()
3183 } else {
3184 vec![if insert { 2 } else { 1 }]
3185 };
3186 for id in ids {
3187 assert_eq!(
3188 recovered
3189 .get("rows", EntityId::new(id))
3190 .expect("recovered row")
3191 .data
3192 .as_row()
3193 .expect("row")
3194 .columns,
3195 vec![Value::Integer(9)]
3196 );
3197 }
3198 assert_eq!(
3199 recovered.get_collection("rows").expect("rows").count(),
3200 if multi {
3201 600
3202 } else if insert {
3203 2
3204 } else {
3205 1
3206 }
3207 );
3208 }
3209 }
3210
3211 #[test]
3212 fn catalog_ownership_epoch_survives_reopen_and_later_publication() {
3213 let directory = tempfile::tempdir().expect("temporary store");
3214 let path = directory.path().join("data.rdb");
3215 {
3216 let store = UnifiedStore::open(&path).expect("open store");
3217 store.create_collection("rows").expect("create collection");
3218 store.persist().expect("publish ownership epoch");
3219 assert_eq!(store.ownership_catalog_epoch.load(Ordering::Acquire), 1);
3220 store.persist().expect("publish next ownership epoch");
3221 }
3222 let reopened = UnifiedStore::open(&path).expect("reopen store");
3223 assert_eq!(reopened.ownership_catalog_epoch.load(Ordering::Acquire), 2);
3224 }
3225
3226 #[test]
3227 fn metadata_overflow_with_missing_continuation_fails_closed() {
3228 use crate::storage::engine::{Page, PageType, HEADER_SIZE};
3229
3230 let directory = tempfile::tempdir().expect("temporary store");
3231 let store = UnifiedStore::open(directory.path().join("data.rdb")).expect("paged store");
3232 let pager = store.pager.as_ref().unwrap();
3233 let valid_payload = vec![42; META_PAGE_CONTENT_CAP * 3];
3234 let (valid_manifest, _) = build_meta_page1_with_overflow(pager, &valid_payload, 1).unwrap();
3235 pager.write_page(1, valid_manifest).unwrap();
3236 assert!(!existing_overflow_chain(pager).unwrap().is_empty());
3237 assert_eq!(read_meta_payload(pager).unwrap().unwrap(), valid_payload);
3238
3239 let missing = pager.page_count().unwrap() + 100;
3240 let mut manifest = Page::new(PageType::Header, 1);
3241 reddb_file::encode_native_metadata_overflow_header(
3242 &mut manifest.as_bytes_mut()[HEADER_SIZE..],
3243 reddb_file::NativeMetadataOverflowHeader {
3244 format_version: STORE_VERSION_V11,
3245 total_payload_bytes: META_PAGE_CONTENT_CAP as u32 + 1,
3246 next_overflow_page_id: missing,
3247 },
3248 )
3249 .unwrap();
3250 pager.write_page(1, manifest).unwrap();
3251
3252 assert!(
3253 existing_overflow_chain(pager).is_err(),
3254 "a missing continuation must not be treated as an empty chain"
3255 );
3256
3257 let wrong_type = pager.allocate_page(PageType::BTreeLeaf).unwrap();
3258 let mut manifest = Page::new(PageType::Header, 1);
3259 reddb_file::encode_native_metadata_overflow_header(
3260 &mut manifest.as_bytes_mut()[HEADER_SIZE..],
3261 reddb_file::NativeMetadataOverflowHeader {
3262 format_version: STORE_VERSION_V11,
3263 total_payload_bytes: META_PAGE_CONTENT_CAP as u32 + 1,
3264 next_overflow_page_id: wrong_type.page_id(),
3265 },
3266 )
3267 .unwrap();
3268 pager.write_page(1, manifest).unwrap();
3269 assert!(
3270 existing_overflow_chain(pager).is_err(),
3271 "a live page must not be mistaken for metadata overflow"
3272 );
3273 assert!(
3274 read_meta_payload(pager).is_err(),
3275 "a live page must not be read as metadata overflow"
3276 );
3277
3278 let mut cycle = pager.allocate_page(PageType::Overflow).unwrap();
3279 let cycle_id = cycle.page_id();
3280 reddb_file::encode_native_metadata_overflow_continuation_header(
3281 &mut cycle.as_bytes_mut()[HEADER_SIZE..],
3282 reddb_file::NativeMetadataOverflowContinuationHeader {
3283 next_overflow_page_id: cycle_id,
3284 chunk_bytes: 1,
3285 },
3286 )
3287 .unwrap();
3288 pager.write_page(cycle_id, cycle).unwrap();
3289 let mut manifest = Page::new(PageType::Header, 1);
3290 reddb_file::encode_native_metadata_overflow_header(
3291 &mut manifest.as_bytes_mut()[HEADER_SIZE..],
3292 reddb_file::NativeMetadataOverflowHeader {
3293 format_version: STORE_VERSION_V11,
3294 total_payload_bytes: META_V3_FIRST_PAYLOAD_CAP as u32 + 3,
3295 next_overflow_page_id: cycle_id,
3296 },
3297 )
3298 .unwrap();
3299 pager.write_page(1, manifest).unwrap();
3300 assert!(
3301 read_meta_payload(pager).is_err(),
3302 "cyclic overflow must not be accepted"
3303 );
3304
3305 let mut manifest = Page::new(PageType::Header, 1);
3306 reddb_file::encode_native_metadata_overflow_header(
3307 &mut manifest.as_bytes_mut()[HEADER_SIZE..],
3308 reddb_file::NativeMetadataOverflowHeader {
3309 format_version: STORE_VERSION_V11,
3310 total_payload_bytes: META_PAGE_CONTENT_CAP as u32 + 1,
3311 next_overflow_page_id: 0,
3312 },
3313 )
3314 .unwrap();
3315 pager.write_page(1, manifest).unwrap();
3316 assert!(
3317 read_meta_payload(pager).is_err(),
3318 "a truncated metadata payload must not be accepted"
3319 );
3320 }
3321
3322 #[test]
3323 fn retired_tree_pages_remain_pinned_by_a_cursor() {
3324 let directory = tempfile::tempdir().expect("temporary store");
3325 let store = UnifiedStore::open(&directory.path().join("data.rdb")).expect("paged store");
3326 for id in 1..=100 {
3327 store.insert_auto("rows", row(id, 1)).expect("seed");
3328 }
3329 store.persist().expect("initial checkpoint");
3330 let tree = store
3331 .btree_indices
3332 .read()
3333 .get("rows")
3334 .cloned()
3335 .expect("tree");
3336 let mut cursor = tree.cursor_first().expect("cursor");
3337 drop(tree);
3338 let manager = store.get_collection("rows").expect("rows");
3339 for value in 2..=6 {
3340 manager.update(row(1, value)).expect("change");
3341 store.persist().expect("checkpoint with old reader");
3342 }
3343 let mut count = 0;
3344 while let Some((_, bytes)) = cursor.next().expect("pinned tree must stay readable") {
3345 let (entity, _) =
3346 UnifiedStore::deserialize_entity_record(&bytes, store.format_version())
3347 .expect("decode");
3348 assert_eq!(
3349 entity.data.as_row().expect("row").columns,
3350 [Value::Integer(1)]
3351 );
3352 count += 1;
3353 }
3354 assert_eq!(count, 100);
3355 drop(cursor);
3356 store.persist().expect("reclaim after reader release");
3357 assert!(store.retired_btrees.lock().is_empty());
3358 }
3359
3360 #[test]
3361 fn checkpoint_payload_buffer_is_bounded_for_a_large_collection() {
3362 let directory = tempfile::tempdir().expect("temporary store");
3363 let store = UnifiedStore::open(&directory.path().join("data.rdb")).expect("paged store");
3364 store.create_collection("rows").expect("collection");
3365 let manager = store.get_collection("rows").expect("rows");
3366 for id in (1..=1200).rev() {
3367 manager
3368 .insert(UnifiedEntity::table_row(
3369 EntityId::new(id),
3370 "rows",
3371 id,
3372 vec![Value::text("x".repeat(4096))],
3373 ))
3374 .expect("seed");
3375 }
3376 CHECKPOINT_BUFFERED_BYTES.with(|peak| peak.set(0));
3377 store.persist().expect("bounded checkpoint");
3378 assert!(CHECKPOINT_BUFFERED_BYTES.with(|peak| peak.get()) <= CHECKPOINT_BATCH_BYTES);
3379 assert!(CHECKPOINT_BUFFERED_BYTES.with(|peak| peak.get()) > 0);
3380 assert_eq!(store.get_collection("rows").expect("rows").count(), 1200);
3381 }
3382
3383 #[test]
3384 fn unchanged_checkpoint_does_not_serialize_collection_rows() {
3385 let directory = tempfile::tempdir().expect("temporary store");
3386 let store = UnifiedStore::open(&directory.path().join("data.rdb")).expect("paged store");
3387 store.create_collection("rows").expect("collection");
3388 for id in 1..=40 {
3389 store
3390 .insert(
3391 "rows",
3392 UnifiedEntity::table_row(
3393 EntityId::new(id),
3394 "rows",
3395 id,
3396 vec![Value::Integer(id as i64)],
3397 ),
3398 )
3399 .expect("seed row");
3400 }
3401 store.persist().expect("initial checkpoint");
3402 CHECKPOINT_SERIALIZED_ROWS.with(|count| count.set(0));
3403 store.persist().expect("unchanged checkpoint");
3404 assert_eq!(
3405 CHECKPOINT_SERIALIZED_ROWS.with(|count| count.get()),
3406 0,
3407 "unchanged collections must not hold the writer fence while serializing every row"
3408 );
3409 drop(store);
3410 let reopened = UnifiedStore::open(&directory.path().join("data.rdb")).expect("reopen");
3411 CHECKPOINT_SERIALIZED_ROWS.with(|count| count.set(0));
3412 reopened.persist().expect("unchanged recovered generation");
3413 assert_eq!(CHECKPOINT_SERIALIZED_ROWS.with(|count| count.get()), 0);
3414 }
3415
3416 #[test]
3417 fn sparse_wal_replay_does_not_reencode_complete_collections() {
3418 let directory = tempfile::tempdir().expect("temporary store");
3419 let path = directory.path().join("data.rdb");
3420 let store = UnifiedStore::open(&path).expect("paged store");
3421 for (name, first) in [("rows", 1), ("cold", 129)] {
3422 store.create_collection(name).expect("collection");
3423 for id in first..first + 128 {
3424 store
3425 .insert(
3426 name,
3427 UnifiedEntity::table_row(
3428 EntityId::new(id),
3429 name,
3430 id,
3431 vec![Value::text(format!("{id}:{}", "x".repeat(8192)))],
3432 ),
3433 )
3434 .expect("seed");
3435 }
3436 }
3437 store.persist().expect("durable baseline");
3438 let action = super::super::commit::StoreWalAction::upsert_entity(
3439 "rows",
3440 &row(1, 42),
3441 None,
3442 STORE_VERSION_V11,
3443 );
3444 store
3446 .commit
3447 .as_ref()
3448 .unwrap()
3449 .append_actions(&[action])
3450 .unwrap();
3451 drop(store);
3452
3453 REPLAY_SERIALIZED_ROWS.with(|count| count.set(0));
3454 let recovered = UnifiedStore::open(&path).expect("replay committed change");
3455 assert_eq!(
3456 recovered
3457 .get("rows", EntityId::new(1))
3458 .unwrap()
3459 .data
3460 .as_row()
3461 .unwrap()
3462 .columns,
3463 vec![Value::Integer(42)]
3464 );
3465 for name in ["rows", "cold"] {
3466 assert_eq!(recovered.get_collection(name).unwrap().count(), 128);
3467 }
3468 assert_eq!(
3469 REPLAY_SERIALIZED_ROWS.with(|count| count.get()),
3470 0,
3471 "one WAL-backed row must not reencode its whole collection or unrelated cold rows"
3472 );
3473 for (name, first) in [("rows", 2), ("cold", 129)] {
3474 let end = if name == "rows" { 129 } else { 257 };
3475 for id in first..end {
3476 assert_eq!(
3477 recovered
3478 .get(name, EntityId::new(id))
3479 .unwrap()
3480 .data
3481 .as_row()
3482 .unwrap()
3483 .columns,
3484 vec![Value::text(format!("{id}:{}", "x".repeat(8192)))],
3485 );
3486 }
3487 }
3488
3489 drop(recovered);
3490 let reopened = UnifiedStore::open(&path).expect("durable sparse replay");
3491 assert_eq!(
3492 reopened
3493 .get("rows", EntityId::new(1))
3494 .unwrap()
3495 .data
3496 .as_row()
3497 .unwrap()
3498 .columns,
3499 vec![Value::Integer(42)]
3500 );
3501 }
3502
3503 fn replay_test_payload(bytes: usize) -> String {
3504 let mut state = 0x1234_5678_u32;
3505 (0..bytes)
3506 .map(|_| {
3507 state ^= state << 13;
3508 state ^= state >> 17;
3509 state ^= state << 5;
3510 char::from(b'!' + (state % 90) as u8)
3511 })
3512 .collect()
3513 }
3514
3515 #[test]
3516 fn wal_replay_with_exhausted_row_proof_keeps_full_validation() {
3517 let directory = tempfile::tempdir().unwrap();
3518 let path = directory.path().join("data.rdb");
3519 let config = || UnifiedStoreConfig::default().with_page_cache_slots(4);
3520 let store = UnifiedStore::open_with_config(&path, config()).unwrap();
3521 for id in 1..=16 {
3522 store.insert_auto("rows", row(id, 1)).unwrap();
3523 }
3524 store.persist().unwrap();
3525 let payload = replay_test_payload(64 * 1024);
3527 let changed =
3528 UnifiedEntity::table_row(EntityId::new(1), "rows", 1, vec![Value::text(payload)]);
3529 let action = StoreWalAction::upsert_entity("rows", &changed, None, store.format_version());
3530 store
3531 .commit
3532 .as_ref()
3533 .unwrap()
3534 .append_actions(&[action])
3535 .unwrap();
3536 drop(store);
3537 REPLAY_SERIALIZED_ROWS.with(|count| count.set(0));
3538 let recovered = UnifiedStore::open_with_config(&path, config()).unwrap();
3539 assert!(REPLAY_SERIALIZED_ROWS.with(|count| count.get()) >= 16);
3540 assert_eq!(
3541 recovered
3542 .get("rows", EntityId::new(1))
3543 .unwrap()
3544 .data
3545 .as_row()
3546 .unwrap()
3547 .columns,
3548 changed.data.as_row().unwrap().columns
3549 );
3550 drop(recovered);
3551 let reopened = UnifiedStore::open_with_config(&path, config()).unwrap();
3552 assert_eq!(reopened.get_collection("rows").unwrap().count(), 16);
3553 assert_eq!(
3554 reopened
3555 .get("rows", EntityId::new(1))
3556 .unwrap()
3557 .data
3558 .as_row()
3559 .unwrap()
3560 .columns,
3561 changed.data.as_row().unwrap().columns
3562 );
3563 }
3564
3565 #[cfg(target_os = "linux")]
3566 #[test]
3567 fn interrupted_sparse_replay_preserves_rows_and_bounds_growth() {
3568 const CHILD: &str = "REDDB_TEST_SPARSE_REPLAY_PATH";
3569 if let Some(path) = std::env::var_os(CHILD) {
3570 let _store = UnifiedStore::open(path).unwrap();
3571 panic!("replay crash hook was not reached");
3572 }
3573 for overflow in [false, true] {
3574 for (hook, code) in [
3575 ("REDDB_TEST_EXIT_AFTER_SPARSE_BATCH", 193),
3576 ("REDDB_EXPERIMENTAL_EXIT_AFTER_REPLAY_PUBLICATION", 182),
3577 ] {
3578 let directory = tempfile::tempdir().unwrap();
3579 let path = directory.path().join("data.rdb");
3580 let store = UnifiedStore::open(&path).unwrap();
3581 for id in 1..=128 {
3582 store.insert_auto("rows", row(id, 1)).unwrap();
3583 }
3584 store.persist().unwrap();
3585 let changed = if overflow {
3586 UnifiedEntity::table_row(
3587 EntityId::new(1),
3588 "rows",
3589 1,
3590 vec![Value::text(replay_test_payload(16 * 1024))],
3591 )
3592 } else {
3593 row(1, 42)
3594 };
3595 let action =
3596 StoreWalAction::upsert_entity("rows", &changed, None, store.format_version());
3597 store
3598 .commit
3599 .as_ref()
3600 .unwrap()
3601 .append_actions(&[action])
3602 .unwrap();
3603 drop(store);
3604 let mut first_size = 0;
3605 for cycle in 0..12 {
3606 let output = std::process::Command::new(std::env::current_exe().unwrap())
3607 .args(["--exact", "storage::unified::store::impl_pages::checkpoint_work_tests::interrupted_sparse_replay_preserves_rows_and_bounds_growth", "--nocapture"])
3608 .env(CHILD, &path).env(hook, "1").output().unwrap();
3609 assert_eq!(
3610 output.status.code(),
3611 Some(code),
3612 "overflow={overflow} hook={hook} cycle={cycle}: {}",
3613 String::from_utf8_lossy(&output.stderr)
3614 );
3615 let size = std::fs::metadata(&path).unwrap().len();
3616 if cycle == 0 {
3617 first_size = size;
3618 }
3619 assert!(
3620 size <= first_size + 2 * crate::storage::engine::PAGE_SIZE as u64,
3621 "repeated recovery must reuse reservations: {first_size} -> {size}"
3622 );
3623 }
3624 for _ in 0..2 {
3625 let recovered = UnifiedStore::open(&path).unwrap();
3626 assert_eq!(recovered.get_collection("rows").unwrap().count(), 128);
3627 for id in 1..=128 {
3628 let expected = if id == 1 { changed.clone() } else { row(id, 1) };
3629 assert_eq!(
3630 recovered
3631 .get("rows", EntityId::new(id))
3632 .unwrap()
3633 .data
3634 .as_row()
3635 .unwrap()
3636 .columns,
3637 expected.data.as_row().unwrap().columns
3638 );
3639 }
3640 }
3641 }
3642 }
3643 }
3644
3645 #[test]
3646 fn paged_reopen_preserves_stored_sequence_identity() {
3647 let directory = tempfile::tempdir().expect("temporary store");
3648 let path = directory.path().join("data.rdb");
3649 let store = UnifiedStore::open(&path).expect("paged store");
3650 store.create_collection("rows").expect("collection");
3651 for id in [30, 10, 20] {
3652 store.insert("rows", row(id, id as i64)).expect("insert");
3653 }
3654 store.persist().expect("checkpoint");
3655 let sequences = [10, 20, 30].map(|id| {
3656 store
3657 .get("rows", EntityId::new(id))
3658 .expect("row")
3659 .sequence_id
3660 });
3661 drop(store);
3662 let reopened = UnifiedStore::open(&path).expect("reopen");
3663 let recovered = [10, 20, 30].map(|id| {
3664 reopened
3665 .get("rows", EntityId::new(id))
3666 .expect("row")
3667 .sequence_id
3668 });
3669 assert_eq!(
3670 recovered, sequences,
3671 "opening a checkpoint must not rewrite stored row identities"
3672 );
3673 }
3674
3675 #[test]
3676 fn auto_insert_persists_the_assigned_sequence_identity() {
3677 let directory = tempfile::tempdir().expect("temporary store");
3678 let store = UnifiedStore::open(&directory.path().join("data.rdb")).expect("paged store");
3679 for id in 1u64..=3 {
3680 store
3681 .insert_auto("rows", row(id, id as i64))
3682 .expect("insert");
3683 }
3684 let manager = store.get_collection("rows").expect("rows");
3685 let tree = store
3686 .btree_indices
3687 .read()
3688 .get("rows")
3689 .cloned()
3690 .expect("tree");
3691 for id in 1u64..=3 {
3692 let bytes = tree
3693 .get(&id.to_be_bytes())
3694 .expect("lookup")
3695 .expect("stored row");
3696 let (persisted, _) =
3697 UnifiedStore::deserialize_entity_record(&bytes, store.format_version())
3698 .expect("decode");
3699 assert_eq!(
3700 persisted.sequence_id,
3701 manager
3702 .get(EntityId::new(id))
3703 .expect("live row")
3704 .sequence_id
3705 );
3706 }
3707 }
3708
3709 #[test]
3710 fn completed_inline_insert_reuses_history_but_not_untracked_edits() {
3711 let directory = tempfile::tempdir().expect("temporary store");
3712 let path = directory.path().join("data.rdb");
3713 let store = UnifiedStore::open(&path).expect("paged store");
3714 store.insert_auto("rows", row(1, 1)).expect("seed");
3715 store.persist().expect("checkpoint");
3716 store.insert_auto("rows", row(2, 2)).expect("append");
3717 CHECKPOINT_SERIALIZED_ROWS.with(|count| count.set(0));
3718 store.persist().expect("inline append checkpoint");
3719 assert_eq!(CHECKPOINT_SERIALIZED_ROWS.with(|count| count.get()), 0);
3720 store
3721 .get_collection("rows")
3722 .expect("rows")
3723 .update(row(1, 99))
3724 .expect("untracked edit");
3725 store.insert_auto("rows", row(3, 3)).expect("next append");
3726 CHECKPOINT_SERIALIZED_ROWS.with(|count| count.set(0));
3727 store.persist().expect("untracked edit checkpoint");
3728 assert!(CHECKPOINT_SERIALIZED_ROWS.with(|count| count.get()) > 0);
3729 drop(store);
3730 let reopened = UnifiedStore::open(&path).expect("reopen");
3731 assert_eq!(
3732 reopened
3733 .get("rows", EntityId::new(1))
3734 .expect("row")
3735 .data
3736 .as_row()
3737 .expect("table")
3738 .columns,
3739 vec![Value::Integer(99)]
3740 );
3741 }
3742
3743 #[test]
3744 fn replayed_trees_are_reusable_after_durable_reconciliation() {
3745 let directory = tempfile::tempdir().expect("temporary store");
3746 let path = directory.path().join("data.rdb");
3747 let store = UnifiedStore::open(&path).expect("paged store");
3748 store.insert_auto("rows", row(1, 1)).expect("seed");
3749 store.persist().expect("checkpoint");
3750 store
3751 .insert_auto("rows", row(2, 2))
3752 .expect("WAL-backed append");
3753 drop(store);
3754 let reopened = UnifiedStore::open(&path).expect("replay");
3755 assert!(reopened.get("rows", EntityId::new(2)).is_some());
3756 CHECKPOINT_SERIALIZED_ROWS.with(|count| count.set(0));
3757 reopened.persist().expect("checkpoint reconciled tree");
3758 assert_eq!(CHECKPOINT_SERIALIZED_ROWS.with(|count| count.get()), 0);
3759 }
3760
3761 #[test]
3762 fn legacy_system_catalog_and_wal_migrate_without_cold_boot_rebuild() {
3763 use crate::storage::wal::{WalRecord, WalWriter};
3764
3765 let directory = tempfile::tempdir().expect("temporary store");
3766 let path = directory.path().join("data.rdb");
3767 let config = UnifiedStoreConfig {
3768 page_cache_slots: Some(32),
3769 resident_memory_bytes_max: Some(2 * 1024 * 1024),
3770 ..UnifiedStoreConfig::default()
3771 };
3772 let index_identity = super::super::secondary_index::SecondaryIndexIdentity {
3773 codec_version: 1,
3774 definition: [91; 32],
3775 };
3776 let baseline_index_root;
3777 {
3778 let store = UnifiedStore::open_with_config(&path, config.clone()).expect("paged store");
3779 store
3780 .create_collection("red_config")
3781 .expect("system collection");
3782 store
3783 .insert_auto(
3784 "red_config",
3785 UnifiedEntity::table_row(
3786 EntityId::new(1),
3787 "red_config",
3788 1,
3789 vec![Value::text("checkpoint")],
3790 ),
3791 )
3792 .expect("legacy-compatible insert");
3793 store
3794 .register_secondary_index(
3795 "red_config",
3796 "by_id",
3797 index_identity.clone(),
3798 Arc::new(SystemNameProjection),
3799 )
3800 .expect("secondary index");
3801 store.persist().expect("baseline checkpoint");
3802 baseline_index_root = store
3803 .secondary_index("red.config", "by_id", &index_identity)
3804 .expect("published secondary index")
3805 .root_page_id();
3806 }
3807
3808 {
3812 let pager = Pager::open(
3813 &path,
3814 PagerConfig {
3815 disk_residency: true,
3816 ..PagerConfig::default()
3817 },
3818 )
3819 .expect("open pager");
3820 let mut page = pager.read_page(1).expect("catalog page");
3821 let bytes = page.as_bytes_mut();
3822 let canonical = b"red.config";
3823 let legacy = b"red_config";
3824 let mut replacements = 0;
3825 for offset in 0..=bytes.len() - canonical.len() {
3826 if &bytes[offset..offset + canonical.len()] == canonical {
3827 bytes[offset..offset + legacy.len()].copy_from_slice(legacy);
3828 replacements += 1;
3829 }
3830 }
3831 assert!(replacements >= 1, "catalog must contain the canonical name");
3832 pager.write_page(1, page).expect("write legacy catalog");
3833 pager.sync().expect("sync legacy catalog");
3834 }
3835 crate::storage::operational_manifest::OperationalManifest::for_db_path(&path)
3836 .rename_collections(&[("red.config", "red_config")])
3837 .expect("restore legacy operational name");
3838
3839 let wal_upsert = reddb_file::encode_store_wal_action_frame(
3842 &reddb_file::StoreWalActionFrame::UpsertEntityRecord {
3843 collection: "red_config".to_string(),
3844 record: UnifiedStore::serialize_entity_record(
3845 &UnifiedEntity::table_row(
3846 EntityId::new(2),
3847 "red_config",
3848 2,
3849 vec![Value::text("legacy WAL")],
3850 ),
3851 None,
3852 STORE_VERSION_CURRENT,
3853 ),
3854 },
3855 )
3856 .expect("legacy WAL action");
3857 let wal_delete = reddb_file::encode_store_wal_action_frame(
3858 &reddb_file::StoreWalActionFrame::DeleteEntityRecord {
3859 collection: "red_config".to_string(),
3860 entity_id: EntityId::new(1).raw(),
3861 },
3862 )
3863 .expect("legacy WAL delete");
3864 let wal_path = reddb_file::layout::unified_wal_path(&path);
3865 {
3866 let mut writer = WalWriter::open(&wal_path).expect("open WAL");
3867 writer
3868 .append(&WalRecord::TxCommitBatch {
3869 tx_id: 77,
3870 actions: vec![wal_upsert, wal_delete],
3871 })
3872 .expect("append legacy transaction");
3873 writer.sync().expect("sync legacy transaction");
3874 }
3875
3876 let (root_after_recovery, pages_after_recovery) = {
3877 let recovered =
3878 UnifiedStore::open_with_config(&path, config.clone()).expect("migrate and recover");
3879 assert_eq!(recovered.list_collections(), vec!["red.config".to_string()]);
3880 assert!(
3881 recovered.get("red.config", EntityId::new(1)).is_none(),
3882 "legacy WAL deletes must target the canonical collection"
3883 );
3884 assert_eq!(
3885 recovered
3886 .get("red.config", EntityId::new(2))
3887 .expect("WAL row")
3888 .kind
3889 .collection(),
3890 "red.config"
3891 );
3892 let root = recovered
3893 .collection_root_page("red.config")
3894 .expect("published canonical root");
3895 assert_eq!(
3896 recovered
3897 .secondary_index("red.config", "by_id", &index_identity)
3898 .expect("recovered secondary index")
3899 .root_page_id(),
3900 baseline_index_root,
3901 "name migration must reuse the secondary-index root"
3902 );
3903 let pages = recovered
3904 .pager()
3905 .expect("pager")
3906 .page_count()
3907 .expect("page count");
3908 (root, pages)
3909 };
3910
3911 crate::storage::operational_manifest::OperationalManifest::for_db_path(&path)
3914 .rename_collections(&[("red.config", "red_config")])
3915 .expect("restore stale manifest identity");
3916
3917 CHECKPOINT_SERIALIZED_ROWS.with(|count| count.set(0));
3918 let cold = UnifiedStore::open_with_config(&path, config).expect("second cold boot");
3919 assert_eq!(
3920 cold.collection_root_page("red.config"),
3921 Some(root_after_recovery),
3922 "an idempotent cold boot must reuse the published data root"
3923 );
3924 assert_eq!(
3925 cold.pager()
3926 .expect("pager")
3927 .page_count()
3928 .expect("page count"),
3929 pages_after_recovery,
3930 "cold boot must not allocate replacement index trees"
3931 );
3932 assert_eq!(CHECKPOINT_SERIALIZED_ROWS.with(|count| count.get()), 0);
3933 assert_eq!(
3934 cold.secondary_index("red.config", "by_id", &index_identity)
3935 .expect("secondary index after cold boot")
3936 .root_page_id(),
3937 baseline_index_root
3938 );
3939 assert!(
3940 crate::storage::operational_manifest::OperationalManifest::for_db_path(&path)
3941 .recover_or_bootstrap(&["red.config".to_string()])
3942 .expect("canonical manifest")
3943 .is_empty()
3944 );
3945 }
3946
3947 struct DuplicateSystemCatalogFixture {
3948 _directory: tempfile::TempDir,
3949 path: std::path::PathBuf,
3950 config: UnifiedStoreConfig,
3951 pages_before: u32,
3952 legacy_root: Option<u32>,
3953 legacy_key_root: Option<u32>,
3954 legacy_secondary_root: Option<u32>,
3955 index_identity: super::super::secondary_index::SecondaryIndexIdentity,
3956 }
3957
3958 fn duplicate_system_catalog_fixture(
3959 legacy_populated: bool,
3960 canonical_populated: bool,
3961 index_legacy: bool,
3962 ) -> DuplicateSystemCatalogFixture {
3963 duplicate_system_catalog_fixture_with_ref(
3964 legacy_populated,
3965 canonical_populated,
3966 index_legacy,
3967 false,
3968 )
3969 }
3970
3971 fn duplicate_system_catalog_fixture_with_ref(
3972 legacy_populated: bool,
3973 canonical_populated: bool,
3974 index_legacy: bool,
3975 cross_reference_legacy: bool,
3976 ) -> DuplicateSystemCatalogFixture {
3977 duplicate_system_catalog_fixture_with_key(
3978 legacy_populated,
3979 canonical_populated,
3980 index_legacy,
3981 cross_reference_legacy,
3982 None,
3983 )
3984 }
3985
3986 fn config_recovery_row(id: u64, key: &str, value: i64) -> UnifiedEntity {
3987 UnifiedEntity::new(
3988 EntityId::new(id),
3989 EntityKind::TableRow {
3990 table: Arc::from("red.config"),
3991 row_id: id,
3992 },
3993 EntityData::Row(crate::storage::RowData::with_names(
3994 vec![Value::text(key), Value::Integer(value)],
3995 vec!["key".into(), "value".into()],
3996 )),
3997 )
3998 }
3999
4000 fn authorize_fixture_dwb(path: &Path, frame: &[u8]) {
4001 use reddb_file::{file_format as f, PAGED_SUPERBLOCK_SLOT_SIZE};
4002 use std::io::{Seek, SeekFrom, Write};
4003
4004 let bytes = std::fs::read(path).expect("fixture headers");
4005 let selected = reddb_file::select_paged_superblock(&bytes).expect("valid superblock");
4006 let start = selected.copy_index * PAGED_SUPERBLOCK_SLOT_SIZE;
4007 let mut image = [0; PAGED_SUPERBLOCK_SLOT_SIZE];
4008 image.copy_from_slice(&bytes[start..start + PAGED_SUPERBLOCK_SLOT_SIZE]);
4009 f::set_database_header_dwb_publication(
4010 &mut image,
4011 Some(f::DwbPublication {
4012 phase: f::DwbPhase::Ready,
4013 frame_bytes: u32::try_from(frame.len()).expect("bounded fixture frame"),
4014 digest: f::dwb_frame_digest(frame),
4015 }),
4016 )
4017 .expect("known-current DWB authority");
4018 let mut file = std::fs::OpenOptions::new()
4019 .write(true)
4020 .open(path)
4021 .expect("fixture header writer");
4022 for step in 1..=reddb_file::PAGED_SUPERBLOCK_SLOT_COUNT {
4023 let copy = (selected.copy_index + step) % reddb_file::PAGED_SUPERBLOCK_SLOT_COUNT;
4024 reddb_file::seal_paged_superblock_slot(
4025 &mut image,
4026 copy,
4027 selected.generation + step as u64,
4028 )
4029 .expect("fixture header seal");
4030 file.seek(SeekFrom::Start(reddb_file::paged_superblock_slot_offset(
4031 copy,
4032 )))
4033 .expect("fixture header offset");
4034 file.write_all(&image).expect("persist DWB authority");
4035 file.sync_all().expect("durable DWB authority");
4036 }
4037 }
4038
4039 fn duplicate_system_catalog_fixture_with_key(
4040 legacy_populated: bool,
4041 canonical_populated: bool,
4042 index_legacy: bool,
4043 cross_reference_legacy: bool,
4044 config_key: Option<&str>,
4045 ) -> DuplicateSystemCatalogFixture {
4046 let config_keys = config_key.into_iter().collect::<Vec<_>>();
4047 duplicate_system_catalog_fixture_with_keys(
4048 legacy_populated,
4049 canonical_populated,
4050 index_legacy,
4051 cross_reference_legacy,
4052 &config_keys,
4053 )
4054 }
4055
4056 fn duplicate_system_catalog_fixture_with_keys(
4057 legacy_populated: bool,
4058 canonical_populated: bool,
4059 index_legacy: bool,
4060 cross_reference_legacy: bool,
4061 config_keys: &[&str],
4062 ) -> DuplicateSystemCatalogFixture {
4063 duplicate_system_catalog_fixture_with_aliases(
4064 legacy_populated,
4065 canonical_populated,
4066 index_legacy,
4067 cross_reference_legacy,
4068 config_keys,
4069 None,
4070 )
4071 }
4072
4073 fn duplicate_system_catalog_fixture_with_aliases(
4074 legacy_populated: bool,
4075 canonical_populated: bool,
4076 index_legacy: bool,
4077 cross_reference_legacy: bool,
4078 config_keys: &[&str],
4079 alias_case: Option<&str>,
4080 ) -> DuplicateSystemCatalogFixture {
4081 system_catalog_fixture_with_aliases(
4082 legacy_populated,
4083 canonical_populated,
4084 index_legacy,
4085 cross_reference_legacy,
4086 config_keys,
4087 alias_case,
4088 true,
4089 )
4090 }
4091
4092 fn single_legacy_system_catalog_fixture_with_keys(
4093 config_keys: &[&str],
4094 ) -> DuplicateSystemCatalogFixture {
4095 system_catalog_fixture_with_aliases(true, false, false, false, config_keys, None, false)
4096 }
4097
4098 fn system_catalog_fixture_with_aliases(
4099 legacy_populated: bool,
4100 canonical_populated: bool,
4101 index_legacy: bool,
4102 cross_reference_legacy: bool,
4103 config_keys: &[&str],
4104 alias_case: Option<&str>,
4105 canonical_identity: bool,
4106 ) -> DuplicateSystemCatalogFixture {
4107 assert!(canonical_identity || !canonical_populated);
4108 let directory = tempfile::tempdir().expect("temporary store");
4109 let path = directory.path().join("data.rdb");
4110 let config = UnifiedStoreConfig {
4111 page_cache_slots: Some(32),
4112 resident_memory_bytes_max: Some(2 * 1024 * 1024),
4113 ..UnifiedStoreConfig::default()
4114 };
4115 let index_identity = super::super::secondary_index::SecondaryIndexIdentity {
4116 codec_version: 1,
4117 definition: [73; 32],
4118 };
4119 let (legacy_root, legacy_key_root, legacy_secondary_root);
4120 {
4121 let store = UnifiedStore::open_with_config(&path, config.clone()).expect("paged store");
4122 store
4123 .create_collection("cfg_legacy")
4124 .expect("legacy source collection");
4125 if legacy_populated {
4126 if config_keys.is_empty() {
4127 store
4128 .insert_auto(
4129 "cfg_legacy",
4130 UnifiedEntity::table_row(
4131 EntityId::new(1),
4132 "red_config",
4133 1,
4134 vec![Value::text("legacy")],
4135 ),
4136 )
4137 .expect("legacy row");
4138 } else {
4139 for (offset, key) in config_keys.iter().enumerate() {
4140 let id = u64::try_from(offset).unwrap() + 1;
4141 store
4142 .insert_auto(
4143 "cfg_legacy",
4144 config_recovery_row(id, key, i64::try_from(id).unwrap()),
4145 )
4146 .expect("legacy config row");
4147 }
4148 }
4149 }
4150 if canonical_identity {
4151 store
4152 .create_collection("cfg.canon_")
4153 .expect("canonical source collection");
4154 }
4155 if canonical_identity && canonical_populated {
4156 if config_keys.is_empty() {
4157 store
4158 .insert_auto(
4159 "cfg.canon_",
4160 UnifiedEntity::table_row(
4161 EntityId::new(2),
4162 "red.config",
4163 2,
4164 vec![Value::text("canonical")],
4165 ),
4166 )
4167 .expect("canonical row");
4168 } else {
4169 let base = u64::try_from(config_keys.len()).unwrap() + 1;
4170 for (offset, key) in config_keys.iter().enumerate() {
4171 let id = base + u64::try_from(offset).unwrap();
4172 store
4173 .insert_auto(
4174 "cfg.canon_",
4175 config_recovery_row(id, key, i64::try_from(id).unwrap()),
4176 )
4177 .expect("canonical config row");
4178 }
4179 }
4180 }
4181 if cross_reference_legacy {
4182 store
4183 .add_cross_ref(
4184 "cfg.canon_",
4185 EntityId::new(2),
4186 "cfg_legacy",
4187 EntityId::new(1),
4188 RefType::RowToNode,
4189 1.0,
4190 )
4191 .expect("reference to legacy config");
4192 }
4193 if index_legacy {
4194 store
4195 .register_secondary_index(
4196 "cfg_legacy",
4197 "by_id",
4198 index_identity.clone(),
4199 Arc::new(SystemNameProjection),
4200 )
4201 .expect("legacy secondary index");
4202 }
4203
4204 if let Some(case) = alias_case {
4205 use crate::storage::{EntityData, RowData};
4206 let pairs = [
4207 (
4208 "old_stats",
4209 "new.query.stats_catalog",
4210 "red_stats",
4211 "red.query.stats_catalog",
4212 ),
4213 (
4214 "old_index_registry",
4215 "new.index.registry",
4216 "red_index_registry",
4217 "red.index.registry",
4218 ),
4219 (
4220 "old_queue_meta",
4221 "new.queue.meta",
4222 "red_queue_meta",
4223 "red.queue.meta",
4224 ),
4225 (
4226 "old_materialized_view_defs",
4227 "new.materialized_view.definitions",
4228 "red_materialized_view_defs",
4229 "red.materialized_view.definitions",
4230 ),
4231 ];
4232 for (pair_index, (old, new, legacy, canonical)) in pairs.iter().enumerate() {
4233 for (is_old, name, table) in [(true, *old, *legacy), (false, *new, *canonical)]
4234 {
4235 store.create_collection(name).unwrap();
4236 let text = |s: &str| Value::text(s);
4237 let mut fields = match pair_index {
4238 0 => vec![
4239 ("kind", text("table")),
4240 ("table", text("users")),
4241 ("row_count", Value::Integer(if is_old { 1 } else { 2 })),
4242 ("avg_row_size", Value::Integer(20)),
4243 ("page_count", Value::Integer(1)),
4244 ],
4245 1 => vec![
4246 ("collection", text("users")),
4247 (
4248 "name",
4249 text(if is_old {
4250 "legacy_only"
4251 } else {
4252 "canonical_only"
4253 }),
4254 ),
4255 ("columns", text("id")),
4256 ("method", text("hash")),
4257 ("resolution", Value::Integer(0)),
4258 ("unique", Value::Boolean(false)),
4259 ("dropped", Value::Boolean(false)),
4260 ],
4261 2 => vec![
4262 ("kind", text("queue_group")),
4263 ("queue", text("jobs")),
4264 ("group", text("workers")),
4265 (
4266 "created_at_ns",
4267 Value::UnsignedInteger(if is_old { 1 } else { 2 }),
4268 ),
4269 ],
4270 _ => vec![
4271 ("name", text("view")),
4272 ("refresh_every_ms", Value::UnsignedInteger(1000)),
4273 ("retention_duration_ms", Value::UnsignedInteger(0)),
4274 ("source_collections", text("users")),
4275 (
4276 "source_sql",
4277 text(if case == "view-conflict" && !is_old {
4278 "SELECT 2"
4279 } else {
4280 "SELECT 1"
4281 }),
4282 ),
4283 ],
4284 };
4285 if case == "queue-delivery" && pair_index == 2 && is_old {
4286 fields[0].1 = text("queue_pending");
4287 }
4288 let id = if is_old {
4289 10000 + pair_index as u64
4290 } else {
4291 100 + pair_index as u64
4292 };
4293 let entity = UnifiedEntity::new(
4294 EntityId::new(id),
4295 EntityKind::TableRow {
4296 table: Arc::from(table),
4297 row_id: id,
4298 },
4299 EntityData::Row(RowData {
4300 columns: vec![],
4301 named: Some(
4302 fields.into_iter().map(|(k, v)| (k.to_owned(), v)).collect(),
4303 ),
4304 schema: None,
4305 }),
4306 );
4307 store.insert_auto(name, entity).unwrap();
4308 }
4309 }
4310 }
4311 store.persist().expect("checkpoint duplicate catalog");
4312 legacy_root = store.collection_root_page("cfg_legacy");
4313 legacy_key_root = store
4314 .key_indices
4315 .read()
4316 .get("cfg_legacy")
4317 .map(|index| index.tree.root_page_id());
4318 legacy_secondary_root = index_legacy.then(|| {
4319 store
4320 .secondary_index("cfg_legacy", "by_id", &index_identity)
4321 .expect("legacy secondary root")
4322 .root_page_id()
4323 });
4324 }
4325
4326 {
4331 let pager = Pager::open(
4332 &path,
4333 PagerConfig {
4334 disk_residency: true,
4335 ..PagerConfig::default()
4336 },
4337 )
4338 .expect("open pager");
4339 let mut metadata = read_meta_payload(&pager)
4340 .expect("read catalog payload")
4341 .expect("published catalog payload");
4342 let bytes = metadata.as_mut_slice();
4343 let mut names = vec![("cfg_legacy", "red_config")];
4344 if canonical_identity {
4345 names.push(("cfg.canon_", "red.config"));
4346 }
4347 if alias_case.is_some() {
4348 names.extend([
4349 ("old_stats", "red_stats"),
4350 ("new.query.stats_catalog", "red.query.stats_catalog"),
4351 ("old_index_registry", "red_index_registry"),
4352 ("new.index.registry", "red.index.registry"),
4353 ("old_queue_meta", "red_queue_meta"),
4354 ("new.queue.meta", "red.queue.meta"),
4355 ("old_materialized_view_defs", "red_materialized_view_defs"),
4356 (
4357 "new.materialized_view.definitions",
4358 "red.materialized_view.definitions",
4359 ),
4360 ]);
4361 }
4362 for (source, destination) in names {
4363 let (source, destination) = (source.as_bytes(), destination.as_bytes());
4364 let mut replacements = 0;
4365 for offset in 0..=bytes.len() - source.len() {
4366 if &bytes[offset..offset + source.len()] == source {
4367 bytes[offset..offset + destination.len()].copy_from_slice(destination);
4368 replacements += 1;
4369 }
4370 }
4371 assert!(replacements >= 1, "catalog must contain the source name");
4372 }
4373 publish_paged_metadata(&pager, &metadata).expect("write duplicate catalog");
4374 pager.sync().expect("sync duplicate catalog");
4375 if alias_case.is_some() {
4376 let mut metadata = read_meta_payload(&pager).unwrap().unwrap();
4377 let resume = reddb_file::native_resume::decode_catalog_resume(&metadata)
4378 .unwrap()
4379 .unwrap();
4380 let trailer = metadata
4381 .windows(4)
4382 .rposition(|bytes| bytes == b"RDRS")
4383 .unwrap();
4384 metadata.truncate(trailer);
4385 reddb_file::native_resume::encode_catalog_resume(&mut metadata, &resume).unwrap();
4386 publish_paged_metadata(&pager, &metadata).unwrap();
4387 pager.sync().unwrap();
4388 }
4389 }
4390 let mut config_names = vec![("cfg_legacy", "red_config")];
4391 if canonical_identity {
4392 config_names.push(("cfg.canon_", "red.config"));
4393 }
4394 crate::storage::operational_manifest::OperationalManifest::for_db_path(&path)
4395 .rename_collections(&config_names)
4396 .expect("publish duplicate operational names");
4397
4398 if alias_case.is_some() {
4399 crate::storage::operational_manifest::OperationalManifest::for_db_path(&path)
4400 .rename_collections(&[
4401 ("old_stats", "red_stats"),
4402 ("new.query.stats_catalog", "red.query.stats_catalog"),
4403 ("old_index_registry", "red_index_registry"),
4404 ("new.index.registry", "red.index.registry"),
4405 ("old_queue_meta", "red_queue_meta"),
4406 ("new.queue.meta", "red.queue.meta"),
4407 ("old_materialized_view_defs", "red_materialized_view_defs"),
4408 (
4409 "new.materialized_view.definitions",
4410 "red.materialized_view.definitions",
4411 ),
4412 ])
4413 .unwrap();
4414 }
4415
4416 let pages_before = Pager::open(
4417 &path,
4418 PagerConfig {
4419 disk_residency: true,
4420 ..PagerConfig::default()
4421 },
4422 )
4423 .expect("inspect pager")
4424 .page_count()
4425 .expect("page count");
4426 DuplicateSystemCatalogFixture {
4427 _directory: directory,
4428 path,
4429 config,
4430 pages_before,
4431 legacy_root,
4432 legacy_key_root,
4433 legacy_secondary_root,
4434 index_identity,
4435 }
4436 }
4437
4438 fn remove_fixture_catalog_resume(fixture: &DuplicateSystemCatalogFixture) {
4439 let pager = Pager::open(
4440 &fixture.path,
4441 PagerConfig {
4442 disk_residency: true,
4443 ..PagerConfig::default()
4444 },
4445 )
4446 .expect("fixture pager");
4447 let mut metadata = read_meta_payload(&pager).unwrap().unwrap();
4448 assert!(reddb_file::native_resume::decode_catalog_resume(&metadata)
4449 .unwrap()
4450 .is_some());
4451 let trailer = metadata
4452 .windows(4)
4453 .rposition(|bytes| bytes == b"RDRS")
4454 .unwrap();
4455 metadata.truncate(trailer);
4456 publish_paged_metadata(&pager, &metadata).unwrap();
4457 pager.sync().unwrap();
4458 assert!(reddb_file::native_resume::decode_catalog_resume(
4459 &read_meta_payload(&pager).unwrap().unwrap()
4460 )
4461 .unwrap()
4462 .is_none());
4463 }
4464
4465 #[test]
4466 fn no_resume_catalog_keeps_populated_config_beside_zero_root_alias() {
4467 for (legacy_populated, canonical_populated) in
4468 [(true, false), (false, true), (false, false)]
4469 {
4470 let fixture =
4471 duplicate_system_catalog_fixture(legacy_populated, canonical_populated, false);
4472 remove_fixture_catalog_resume(&fixture);
4473 let store = UnifiedStore::open_with_config(&fixture.path, fixture.config.clone())
4474 .expect("resolve physically empty alias without resume counters");
4475 assert_eq!(store.list_collections(), vec!["red.config".to_owned()]);
4476 assert_eq!(
4477 store.get("red.config", EntityId::new(1)).is_some(),
4478 legacy_populated
4479 );
4480 assert_eq!(
4481 store.get("red.config", EntityId::new(2)).is_some(),
4482 canonical_populated
4483 );
4484 if legacy_populated {
4485 assert_eq!(
4486 store.collection_root_page("red.config"),
4487 fixture.legacy_root
4488 );
4489 }
4490 store.persist().expect("checkpoint canonical catalog");
4491 drop(store);
4492 let cold = UnifiedStore::open_with_config(&fixture.path, fixture.config)
4493 .expect("cold reopen canonical catalog");
4494 assert_eq!(cold.list_collections(), vec!["red.config".to_owned()]);
4495 assert_eq!(
4496 cold.get("red.config", EntityId::new(1)).is_some(),
4497 legacy_populated
4498 );
4499 assert_eq!(
4500 cold.get("red.config", EntityId::new(2)).is_some(),
4501 canonical_populated
4502 );
4503 }
4504 }
4505
4506 #[test]
4507 fn no_resume_catalog_refuses_two_populated_config_roots() {
4508 let fixture = duplicate_system_catalog_fixture(true, true, false);
4509 remove_fixture_catalog_resume(&fixture);
4510 let error = UnifiedStore::open_with_config(&fixture.path, fixture.config)
4511 .err()
4512 .expect("two populated roots require reconciliation");
4513 assert!(error.to_string().contains("both legacy collection"));
4514 }
4515
4516 fn alias_duplicate_system_catalog_state_with(
4517 fixture: &DuplicateSystemCatalogFixture,
4518 mutate_canonical: impl FnOnce(&mut reddb_file::native_resume::CollectionResume),
4519 ) {
4520 let pager = Pager::open(
4521 &fixture.path,
4522 PagerConfig {
4523 disk_residency: true,
4524 ..PagerConfig::default()
4525 },
4526 )
4527 .expect("open duplicate catalog pager");
4528 let mut metadata = read_meta_payload(&pager)
4529 .expect("read duplicate catalog")
4530 .expect("published duplicate catalog");
4531 let mut resume = reddb_file::native_resume::decode_catalog_resume(&metadata)
4532 .expect("decode duplicate resume")
4533 .expect("prepared duplicate resume");
4534 let legacy_state = resume
4535 .collections
4536 .get("red_config")
4537 .expect("legacy resume state")
4538 .clone();
4539 let mut canonical_state = legacy_state.clone();
4540 mutate_canonical(&mut canonical_state);
4541 resume
4542 .collections
4543 .insert("red.config".to_string(), canonical_state);
4544
4545 let header = reddb_file::decode_native_paged_metadata_header(&metadata)
4546 .expect("decode metadata header")
4547 .expect("native metadata header");
4548 let mut position = reddb_file::METADATA_HEADER_BYTES;
4549 let mut replaced_root = false;
4550 for _ in 0..header.collection_count {
4551 let root = reddb_file::decode_native_paged_collection_root(&metadata, &mut position)
4552 .expect("decode collection root");
4553 if root.collection == "red.config" {
4554 metadata[position - 4..position]
4555 .copy_from_slice(&legacy_state.root_page.to_le_bytes());
4556 replaced_root = true;
4557 }
4558 }
4559 assert!(replaced_root, "canonical catalog root must be present");
4560
4561 let trailer = metadata
4562 .windows(4)
4563 .rposition(|bytes| bytes == b"RDRS")
4564 .expect("catalog resume trailer");
4565 metadata.truncate(trailer);
4566 reddb_file::native_resume::encode_catalog_resume(&mut metadata, &resume)
4567 .expect("encode aliased resume");
4568 publish_paged_metadata(&pager, &metadata).expect("publish aliased catalog");
4569 }
4570
4571 fn alias_duplicate_system_catalog_state(fixture: &DuplicateSystemCatalogFixture) {
4572 alias_duplicate_system_catalog_state_with(fixture, |_| {});
4573 }
4574
4575 #[test]
4576 fn populated_legacy_system_collection_replaces_empty_canonical_duplicate() {
4577 let fixture = duplicate_system_catalog_fixture(true, false, true);
4578 let recovered = UnifiedStore::open_with_config(&fixture.path, fixture.config.clone())
4579 .expect("resolve empty duplicate");
4580 assert_eq!(recovered.list_collections(), vec!["red.config".to_string()]);
4581 assert_eq!(
4582 recovered
4583 .get("red.config", EntityId::new(1))
4584 .expect("preserved legacy row")
4585 .kind
4586 .collection(),
4587 "red.config"
4588 );
4589 assert_eq!(
4590 recovered.collection_root_page("red.config"),
4591 fixture.legacy_root,
4592 "the populated data root must be reused"
4593 );
4594 assert_eq!(
4595 recovered
4596 .key_indices
4597 .read()
4598 .get("red.config")
4599 .map(|index| index.tree.root_page_id()),
4600 fixture.legacy_key_root,
4601 "the populated key-index root must be reused"
4602 );
4603 assert_eq!(
4604 recovered
4605 .secondary_index("red.config", "by_id", &fixture.index_identity)
4606 .expect("surviving secondary index")
4607 .root_page_id(),
4608 fixture
4609 .legacy_secondary_root
4610 .expect("fixture secondary root")
4611 );
4612 drop(recovered);
4613
4614 let cold = UnifiedStore::open_with_config(&fixture.path, fixture.config)
4615 .expect("idempotent cold boot");
4616 assert_eq!(cold.list_collections(), vec!["red.config".to_string()]);
4617 assert_eq!(
4618 cold.pager()
4619 .expect("pager")
4620 .page_count()
4621 .expect("page count"),
4622 fixture.pages_before,
4623 "resolving an empty duplicate must not extend the file"
4624 );
4625 }
4626
4627 #[test]
4628 fn populated_canonical_system_collection_replaces_empty_legacy_duplicate() {
4629 let fixture = duplicate_system_catalog_fixture(false, true, false);
4630 let recovered = UnifiedStore::open_with_config(&fixture.path, fixture.config)
4631 .expect("retain populated canonical duplicate");
4632 assert_eq!(recovered.list_collections(), vec!["red.config".to_string()]);
4633 assert_eq!(
4634 recovered
4635 .get("red.config", EntityId::new(2))
4636 .expect("preserved canonical row")
4637 .kind
4638 .collection(),
4639 "red.config"
4640 );
4641 assert!(recovered.get("red.config", EntityId::new(1)).is_none());
4642 }
4643
4644 #[test]
4645 fn empty_canonical_system_collection_replaces_empty_legacy_duplicate() {
4646 let fixture = duplicate_system_catalog_fixture(false, false, false);
4647 let recovered = UnifiedStore::open_with_config(&fixture.path, fixture.config)
4648 .expect("prefer canonical empty duplicate");
4649 assert_eq!(recovered.list_collections(), vec!["red.config".to_string()]);
4650 assert_eq!(
4651 recovered
4652 .get_collection("red.config")
4653 .expect("canonical collection")
4654 .count(),
4655 0
4656 );
4657 }
4658
4659 #[test]
4660 fn populated_system_collection_duplicates_still_fail_closed() {
4661 let fixture = duplicate_system_catalog_fixture(true, true, false);
4662 let error = match UnifiedStore::open_with_config(&fixture.path, fixture.config) {
4663 Ok(_) => panic!("two populated identities are ambiguous"),
4664 Err(error) => error,
4665 };
4666 assert!(
4667 error
4668 .to_string()
4669 .contains("both populated legacy collection 'red_config' and canonical collection 'red.config'"),
4670 "unexpected error: {error}"
4671 );
4672 assert!(
4673 error.to_string().contains(
4674 "data_root_match=false, key_index_root_match=true, secondary_index_state_match=true"
4675 ),
4676 "the error must identify which physical state diverged without exposing page IDs: {error}"
4677 );
4678 }
4679
4680 #[test]
4681 fn explicit_compaction_promotes_populated_legacy_system_config() {
4682 let reported_keys = super::super::config_keys::tests::REPORTED_DATABASE_KEYS;
4683 let fixture =
4684 duplicate_system_catalog_fixture_with_keys(true, false, false, false, reported_keys);
4685 let destination = fixture._directory.path().join("canonical-system-config");
4686
4687 let report = crate::storage::compact::compact_paged_canonical_system_config(
4688 &fixture.path,
4689 &destination,
4690 )
4691 .expect("rewrite duplicate system config");
4692 assert!(report.records > 0);
4693 assert_eq!(report.reconciled_system_collections, 1);
4694 assert_eq!(report.imported_system_records, reported_keys.len());
4695
4696 let rewritten_path = destination.join("data.rdb");
4697 let rewritten = UnifiedStore::open_with_config(&rewritten_path, fixture.config.clone())
4698 .expect("open canonical rewrite");
4699 assert_eq!(rewritten.list_collections(), vec!["red.config".to_string()]);
4700 for (offset, legacy) in reported_keys.iter().enumerate() {
4701 let canonical = legacy
4702 .strip_prefix("red.config.")
4703 .or_else(|| legacy.strip_prefix("red."))
4704 .unwrap_or(legacy);
4705 assert_eq!(
4706 rewritten.get_config(canonical),
4707 Some(Value::Integer(i64::try_from(offset).unwrap() + 1)),
4708 "the legacy config value must survive as {canonical}"
4709 );
4710 }
4711 assert_eq!(rewritten.legacy_red_config_key_count().unwrap(), 0);
4712 drop(rewritten);
4713
4714 let cold = UnifiedStore::open_with_config(&rewritten_path, fixture.config)
4715 .expect("cold reopen canonical rewrite");
4716 assert_eq!(cold.list_collections(), vec!["red.config".to_string()]);
4717 assert!(
4718 cold.get_collection("red.config")
4719 .expect("canonical collection")
4720 .count()
4721 >= reported_keys.len()
4722 );
4723 for (offset, legacy) in reported_keys.iter().enumerate() {
4724 let canonical = legacy
4725 .strip_prefix("red.config.")
4726 .or_else(|| legacy.strip_prefix("red."))
4727 .unwrap_or(legacy);
4728 assert_eq!(
4729 cold.get_config(canonical),
4730 Some(Value::Integer(i64::try_from(offset).unwrap() + 1)),
4731 "cold reopen lost {canonical}"
4732 );
4733 }
4734 assert!(
4735 crate::storage::operational_manifest::OperationalManifest::for_db_path(&rewritten_path)
4736 .recover_or_bootstrap(&["red.config".to_string()])
4737 .expect("canonical operational manifest")
4738 .is_empty()
4739 );
4740 }
4741
4742 #[test]
4743 fn explicit_compaction_refuses_two_populated_system_config_trees() {
4744 let fixture = duplicate_system_catalog_fixture(true, true, false);
4745 let destination = fixture._directory.path().join("ambiguous-system-config");
4746
4747 let error = crate::storage::compact::compact_paged_canonical_system_config(
4748 &fixture.path,
4749 &destination,
4750 )
4751 .expect_err("two populated config trees must not be discarded");
4752 assert!(error.to_string().contains("refusing lossy migration"));
4753 assert!(!destination.exists());
4754 }
4755
4756 #[test]
4757 fn canonical_system_config_recovery_promotes_single_legacy_root_with_pending_wal() {
4758 use crate::storage::wal::{WalRecord, WalWriter};
4759
4760 let fixture = single_legacy_system_catalog_fixture_with_keys(&["red.auth.require_auth"]);
4761 let wal = reddb_file::unified_wal_path(&fixture.path);
4762 let action = reddb_file::encode_store_wal_action_frame(
4763 &reddb_file::StoreWalActionFrame::CreateCollection {
4764 name: "pending_collection".into(),
4765 },
4766 )
4767 .expect("encode pending action");
4768 {
4769 let mut writer = WalWriter::open(&wal).expect("open WAL");
4770 writer
4771 .append(&WalRecord::TxCommitBatch {
4772 tx_id: 999,
4773 actions: vec![action],
4774 })
4775 .expect("append committed WAL");
4776 writer.sync().expect("sync WAL");
4777 }
4778 let original = std::fs::read(&fixture.path).expect("source bytes");
4779 let original_wal = std::fs::read(&wal).expect("WAL bytes");
4780 let destination = fixture
4781 ._directory
4782 .path()
4783 .join("recovered-single-legacy-config");
4784
4785 let report = crate::storage::compact::recover_paged_wal_canonical_system_config(
4786 &fixture.path,
4787 &destination,
4788 )
4789 .expect("recover pending WAL and promote the legacy config root");
4790
4791 assert_eq!(report.reconciled_system_collections, 1);
4792 assert_eq!(report.imported_system_records, 1);
4793 assert_eq!(std::fs::read(&fixture.path).unwrap(), original);
4794 assert_eq!(std::fs::read(&wal).unwrap(), original_wal);
4795 for _ in 0..2 {
4796 let recovered = UnifiedStore::open_with_config(
4797 destination.join("data.rdb"),
4798 fixture.config.clone(),
4799 )
4800 .expect("open recovered store");
4801 let mut collections = recovered.list_collections();
4802 collections.sort();
4803 assert_eq!(collections, ["pending_collection", "red.config"]);
4804 assert_eq!(
4805 recovered.get_config("auth.require_auth"),
4806 Some(Value::Integer(1))
4807 );
4808 assert_eq!(recovered.legacy_red_config_key_count().unwrap(), 0);
4809 }
4810 }
4811
4812 #[test]
4813 fn canonical_system_config_recovery_replays_pending_unrelated_wal() {
4814 use crate::storage::wal::{WalRecord, WalWriter};
4815
4816 let fixture = duplicate_system_catalog_fixture(true, true, true);
4817 let wal = reddb_file::unified_wal_path(&fixture.path);
4818 let action = reddb_file::encode_store_wal_action_frame(
4819 &reddb_file::StoreWalActionFrame::CreateCollection {
4820 name: "pending_collection".into(),
4821 },
4822 )
4823 .expect("encode pending action");
4824 let canonical_update = reddb_file::encode_store_wal_action_frame(
4825 &reddb_file::StoreWalActionFrame::UpsertEntityRecord {
4826 collection: "red.config".into(),
4827 record: UnifiedStore::serialize_entity_record(
4828 &UnifiedEntity::table_row(
4829 EntityId::new(3),
4830 "red.config",
4831 3,
4832 vec![Value::text("canonical WAL")],
4833 ),
4834 None,
4835 STORE_VERSION_CURRENT,
4836 ),
4837 },
4838 )
4839 .expect("encode canonical update");
4840 {
4841 let mut writer = WalWriter::open(&wal).expect("open WAL");
4842 writer
4843 .append(&WalRecord::TxCommitBatch {
4844 tx_id: 999,
4845 actions: vec![action, canonical_update],
4846 })
4847 .expect("append committed WAL");
4848 writer.sync().expect("sync WAL");
4849 }
4850 let original = std::fs::read(&fixture.path).expect("source bytes");
4851 let original_wal = std::fs::read(&wal).expect("WAL bytes");
4852 let destination = fixture._directory.path().join("recovered-canonical-config");
4853
4854 let default_destination = fixture._directory.path().join("default-recovery");
4855 let error = crate::storage::compact::recover_paged_wal(&fixture.path, &default_destination)
4856 .expect_err("ordinary recovery must reject divergent identities");
4857 assert!(error
4858 .to_string()
4859 .contains("both populated legacy collection"));
4860
4861 crate::storage::compact::recover_paged_wal_canonical_system_config(
4862 &fixture.path,
4863 &destination,
4864 )
4865 .expect("recover pending WAL and retain canonical config");
4866
4867 assert_eq!(std::fs::read(&fixture.path).unwrap(), original);
4868 assert_eq!(std::fs::read(&wal).unwrap(), original_wal);
4869 for _ in 0..2 {
4870 let recovered = UnifiedStore::open_with_config(
4871 destination.join("data.rdb"),
4872 fixture.config.clone(),
4873 )
4874 .expect("open recovered store");
4875 assert!(recovered.get_collection("pending_collection").is_some());
4876 assert!(recovered.get("red.config", EntityId::new(1)).is_none());
4877 assert!(recovered.get("red.config", EntityId::new(2)).is_some());
4878 assert!(recovered.get("red.config", EntityId::new(3)).is_some());
4879 }
4880 }
4881
4882 #[test]
4883 fn canonical_system_catalog_recovery_reconciles_all_metadata_and_keeps_missing_index() {
4884 use crate::storage::wal::{WalRecord, WalWriter};
4885 for case in [
4886 "valid",
4887 "view-conflict",
4888 "queue-delivery",
4889 "legacy-wal",
4890 "canonical-wal",
4891 ] {
4892 let fixture = duplicate_system_catalog_fixture_with_aliases(
4893 true,
4894 true,
4895 true,
4896 false,
4897 &["system.pid"],
4898 Some(case),
4899 );
4900 let wal = reddb_file::unified_wal_path(&fixture.path);
4901 {
4902 let mut writer = WalWriter::open(&wal).unwrap();
4903 let name = match case {
4904 "legacy-wal" => "red_index_registry",
4905 "canonical-wal" => "red.index.registry",
4906 _ => "durable_rows",
4907 };
4908 writer
4909 .append(&WalRecord::TxCommitBatch {
4910 tx_id: 1001,
4911 actions: vec![reddb_file::encode_store_wal_action_frame(
4912 &reddb_file::StoreWalActionFrame::CreateCollection {
4913 name: name.into(),
4914 },
4915 )
4916 .unwrap()],
4917 })
4918 .unwrap();
4919 writer.sync().unwrap();
4920 }
4921 let original = std::fs::read(&fixture.path).unwrap();
4922 let original_wal = std::fs::read(&wal).unwrap();
4923 let baseline = UnifiedStore::open_with_config_for_recovery(
4924 &fixture.path,
4925 fixture.config.clone(),
4926 true,
4927 None,
4928 None,
4929 )
4930 .err()
4931 .expect("unproved aliases must still fail");
4932 assert!(baseline.to_string().contains("red_stats"), "{baseline}");
4933 assert_eq!(std::fs::read(&fixture.path).unwrap(), original);
4934 let destination = fixture._directory.path().join("catalog-recovered");
4935 let result = crate::storage::compact::recover_paged_wal_canonical_system_config(
4936 &fixture.path,
4937 &destination,
4938 );
4939 assert_eq!(std::fs::read(&fixture.path).unwrap(), original);
4940 assert_eq!(std::fs::read(&wal).unwrap(), original_wal);
4941 if case != "valid" {
4942 let error = result.expect_err(case).to_string();
4943 let expected = match case {
4944 "view-conflict" => "conflicting canonical definition",
4945 "queue-delivery" => "unsupported legacy metadata",
4946 _ => "WAL changes metadata",
4947 };
4948 assert!(error.contains(expected), "{case}: {error}");
4949 assert!(!destination.join("data.rdb").exists());
4950 continue;
4951 }
4952 let report = result.expect("recover all supported aliases together");
4953 assert_eq!(report.reconciled_system_collections, 4);
4954 assert_eq!(report.imported_system_records, 1);
4955 for _ in 0..2 {
4956 let store = UnifiedStore::open_with_config(
4957 destination.join("data.rdb"),
4958 fixture.config.clone(),
4959 )
4960 .unwrap();
4961 assert!(store.get_collection("durable_rows").is_some());
4962 let mut imported = Vec::new();
4963 store
4964 .get_collection("red.index.registry")
4965 .unwrap()
4966 .try_for_each_entity(4 * 1024 * 1024, |entity| {
4967 if entity.data.as_row().and_then(|r| r.get_field("name"))
4968 == Some(&Value::text("legacy_only"))
4969 {
4970 imported.push(entity.clone());
4971 }
4972 true
4973 })
4974 .unwrap();
4975 assert_eq!(imported.len(), 1, "legacy-only index must survive");
4976 assert!(
4977 imported[0].id.raw() > 10003,
4978 "all retired identities must be reserved first"
4979 );
4980 assert!(
4981 matches!(imported[0].kind, EntityKind::TableRow{row_id,..} if row_id>10001)
4982 );
4983 assert_eq!(imported[0].logical_id(), imported[0].id);
4984 assert!(store
4985 .get("red.index.registry", EntityId::new(10001))
4986 .is_none());
4987 assert!(store
4988 .get("red.index.registry", EntityId::new(101))
4989 .is_some());
4990 assert!(store
4991 .get("red.query.stats_catalog", EntityId::new(100))
4992 .is_some());
4993 assert!(store
4994 .get("red.query.stats_catalog", EntityId::new(10000))
4995 .is_none());
4996 assert_eq!(store.get_collection("red.queue.meta").unwrap().count(), 1);
4997 assert_eq!(
4998 store
4999 .get_collection("red.materialized_view.definitions")
5000 .unwrap()
5001 .count(),
5002 1
5003 );
5004 assert!(
5005 store
5006 .get_collection("red.index.registry")
5007 .unwrap()
5008 .identity_counters()
5009 .0
5010 > 10001
5011 );
5012 }
5013 }
5014 }
5015
5016 #[test]
5017 fn canonical_system_config_recovery_retires_boot_wal_without_losing_other_actions() {
5018 use crate::storage::wal::{WalRecord, WalWriter};
5019 use reddb_file::StoreWalActionFrame as Action;
5020 for pending_dwb in [false, true] {
5021 let fixture = duplicate_system_catalog_fixture_with_key(
5022 true,
5023 true,
5024 true,
5025 false,
5026 Some("system.pid"),
5027 );
5028 let encode = |action| reddb_file::encode_store_wal_action_frame(&action).unwrap();
5029 let upsert = |id, value| {
5030 encode(Action::UpsertEntityRecord {
5031 collection: "red_config".into(),
5032 record: UnifiedStore::serialize_entity_record(
5033 &config_recovery_row(id, "system.pid", value),
5034 None,
5035 STORE_VERSION_CURRENT,
5036 ),
5037 })
5038 };
5039 let delete = |id| {
5040 encode(Action::DeleteEntityRecord {
5041 collection: "red_config".into(),
5042 entity_id: id,
5043 })
5044 };
5045 let wal = reddb_file::unified_wal_path(&fixture.path);
5046 {
5047 let mut writer = WalWriter::open(&wal).unwrap();
5048 writer
5049 .append(&WalRecord::TxCommitBatch {
5050 tx_id: 1001,
5051 actions: vec![
5052 upsert(10000, 3),
5053 delete(1),
5054 encode(Action::CreateCollection {
5055 name: "durable_rows".into(),
5056 }),
5057 ],
5058 })
5059 .unwrap();
5060 writer
5061 .append(&WalRecord::TxCommitBatch {
5062 tx_id: 1002,
5063 actions: vec![
5064 upsert(10001, 4),
5065 delete(10000),
5066 encode(Action::UpsertEntityRecord {
5067 collection: "durable_rows".into(),
5068 record: UnifiedStore::serialize_entity_record(
5069 &UnifiedEntity::table_row(
5070 EntityId::new(3),
5071 "durable_rows",
5072 1,
5073 vec![Value::text("must survive")],
5074 ),
5075 None,
5076 STORE_VERSION_CURRENT,
5077 ),
5078 }),
5079 ],
5080 })
5081 .unwrap();
5082 writer.sync().unwrap();
5083 }
5084 if pending_dwb {
5085 use std::io::{Seek, SeekFrom, Write};
5086 let bytes = std::fs::read(&fixture.path).unwrap();
5087 let root = fixture.legacy_root.unwrap();
5088 let offset = root as usize * PAGE_SIZE;
5089 let page: &[u8; PAGE_SIZE] = bytes[offset..offset + PAGE_SIZE].try_into().unwrap();
5090 let frame = reddb_file::encode_paged_dwb_frame([(root, page)]);
5091 let mut file = std::fs::OpenOptions::new()
5092 .write(true)
5093 .open(&fixture.path)
5094 .unwrap();
5095 file.seek(SeekFrom::Start(3 * PAGE_SIZE as u64)).unwrap();
5096 file.write_all(&frame).unwrap();
5097 file.seek(SeekFrom::Start(offset as u64)).unwrap();
5100 file.write_all(&[0u8; PAGE_SIZE]).unwrap();
5101 file.sync_all().unwrap();
5102 authorize_fixture_dwb(&fixture.path, &frame);
5103 }
5104 let original = std::fs::read(&fixture.path).unwrap();
5105 let original_wal = std::fs::read(&wal).unwrap();
5106 let destination = fixture._directory.path().join("boot-config-recovered");
5107 let report = crate::storage::compact::recover_paged_wal_canonical_system_config(
5108 &fixture.path,
5109 &destination,
5110 )
5111 .expect("retire only proved boot metadata actions on the isolated copy");
5112 assert_eq!(report.retired_boot_system_wal_actions, 4);
5113 assert_eq!(std::fs::read(&fixture.path).unwrap(), original);
5114 assert_eq!(std::fs::read(&wal).unwrap(), original_wal);
5115 for _ in 0..2 {
5116 let recovered = UnifiedStore::open_with_config(
5117 destination.join("data.rdb"),
5118 fixture.config.clone(),
5119 )
5120 .unwrap();
5121 let manager = recovered.get_collection("red.config").unwrap();
5122 assert!(manager.identity_counters().0 > 10001);
5123 assert!(manager.identity_counters().1 > 10001);
5124 assert_eq!(recovered.get_config("system.pid"), Some(Value::Integer(2)));
5125 assert!(recovered.get("red.config", EntityId::new(1)).is_none());
5126 assert!(recovered.get("red.config", EntityId::new(10000)).is_none());
5127 assert!(recovered.get("red.config", EntityId::new(10001)).is_none());
5128 assert_eq!(
5129 recovered
5130 .get("durable_rows", EntityId::new(3))
5131 .unwrap()
5132 .data
5133 .as_row()
5134 .unwrap()
5135 .columns,
5136 vec![Value::text("must survive")]
5137 );
5138 }
5139 }
5140 }
5141
5142 #[test]
5143 fn canonical_system_config_recovery_requires_stable_boot_identity_evidence() {
5144 use crate::storage::wal::{WalRecord, WalWriter};
5145 let fixture =
5146 duplicate_system_catalog_fixture_with_key(true, true, false, false, Some("system.pid"));
5147 let original = std::fs::read(&fixture.path).unwrap();
5148 let wal = reddb_file::unified_wal_path(&fixture.path);
5149 {
5150 let mut writer = WalWriter::open(&wal).unwrap();
5151 writer
5152 .append(&WalRecord::TxCommitBatch {
5153 tx_id: 1004,
5154 actions: vec![reddb_file::encode_store_wal_action_frame(
5155 &reddb_file::StoreWalActionFrame::DeleteEntityRecord {
5156 collection: "red_config".into(),
5157 entity_id: 1,
5158 },
5159 )
5160 .unwrap()],
5161 })
5162 .unwrap();
5163 writer
5164 .append(&WalRecord::FullPageImage {
5165 tx_id: 1004,
5166 page_id: 1,
5167 ckpt_epoch: 1,
5168 data: original[PAGE_SIZE..2 * PAGE_SIZE].to_vec(),
5169 })
5170 .unwrap();
5171 writer.sync().unwrap();
5172 }
5173 let original_wal = std::fs::read(&wal).unwrap();
5174 let destination = fixture._directory.path().join("unproved-checkpoint");
5175 let error = crate::storage::compact::recover_paged_wal_canonical_system_config(
5176 &fixture.path,
5177 &destination,
5178 )
5179 .expect_err("undo images must be recovered before proving legacy identities");
5180 assert!(error
5181 .to_string()
5182 .contains("requires a stable checkpoint image"));
5183 assert!(!destination.exists());
5184 assert_eq!(std::fs::read(&fixture.path).unwrap(), original);
5185 assert_eq!(std::fs::read(&wal).unwrap(), original_wal);
5186 }
5187
5188 #[test]
5189 fn canonical_system_config_recovery_rejects_unproved_boot_wal() {
5190 use crate::storage::wal::{WalRecord, WalWriter};
5191 use reddb_file::StoreWalActionFrame as Action;
5192 for (case, checkpoint_key, wal_key, delete_id) in [
5193 ("unknown-delete", "system.pid", "system.pid", Some(90000)),
5194 (
5195 "user-key",
5196 "system.customer_setting",
5197 "system.customer_setting",
5198 None,
5199 ),
5200 (
5201 "identity-reuse",
5202 "red.config.customer_setting",
5203 "system.pid",
5204 None,
5205 ),
5206 ] {
5207 let fixture = duplicate_system_catalog_fixture_with_key(
5208 true,
5209 true,
5210 false,
5211 false,
5212 Some(checkpoint_key),
5213 );
5214 let action = if let Some(entity_id) = delete_id {
5215 Action::DeleteEntityRecord {
5216 collection: "red_config".into(),
5217 entity_id,
5218 }
5219 } else {
5220 Action::UpsertEntityRecord {
5221 collection: "red_config".into(),
5222 record: UnifiedStore::serialize_entity_record(
5223 &config_recovery_row(1, wal_key, 99),
5224 None,
5225 STORE_VERSION_CURRENT,
5226 ),
5227 }
5228 };
5229 let wal = reddb_file::unified_wal_path(&fixture.path);
5230 {
5231 let mut writer = WalWriter::open(&wal).unwrap();
5232 writer
5233 .append(&WalRecord::TxCommitBatch {
5234 tx_id: 1003,
5235 actions: vec![reddb_file::encode_store_wal_action_frame(&action).unwrap()],
5236 })
5237 .unwrap();
5238 writer.sync().unwrap();
5239 }
5240 let original = std::fs::read(&fixture.path).unwrap();
5241 let original_wal = std::fs::read(&wal).unwrap();
5242 let destination = fixture._directory.path().join(case);
5243 let error = crate::storage::compact::recover_paged_wal_canonical_system_config(
5244 &fixture.path,
5245 &destination,
5246 )
5247 .expect_err(case);
5248 assert!(
5249 error
5250 .to_string()
5251 .contains("WAL references divergent legacy red_config"),
5252 "{case}: {error}"
5253 );
5254 assert!(!destination.exists());
5255 assert_eq!(std::fs::read(&fixture.path).unwrap(), original);
5256 assert_eq!(std::fs::read(&wal).unwrap(), original_wal);
5257 }
5258 }
5259
5260 #[test]
5261 fn canonical_system_config_recovery_rejects_legacy_wal_actions() {
5262 use crate::storage::wal::{WalRecord, WalWriter};
5263
5264 let fixture = duplicate_system_catalog_fixture(true, true, false);
5265 let wal = reddb_file::unified_wal_path(&fixture.path);
5266 let action = reddb_file::encode_store_wal_action_frame(
5267 &reddb_file::StoreWalActionFrame::DeleteEntityRecord {
5268 collection: "red_config".into(),
5269 entity_id: 1,
5270 },
5271 )
5272 .unwrap();
5273 {
5274 let mut writer = WalWriter::open(&wal).unwrap();
5275 writer
5276 .append(&WalRecord::TxCommitBatch {
5277 tx_id: 999,
5278 actions: vec![action],
5279 })
5280 .unwrap();
5281 writer.sync().unwrap();
5282 }
5283 let original = std::fs::read(&fixture.path).unwrap();
5284 let original_wal = std::fs::read(&wal).unwrap();
5285 let destination = fixture._directory.path().join("rejected-legacy-wal");
5286 let error = crate::storage::compact::recover_paged_wal_canonical_system_config(
5287 &fixture.path,
5288 &destination,
5289 )
5290 .expect_err("legacy WAL must not be replayed against the canonical tree");
5291 assert!(error
5292 .to_string()
5293 .contains("WAL references divergent legacy red_config"));
5294 assert!(!destination.exists());
5295 assert_eq!(std::fs::read(&fixture.path).unwrap(), original);
5296 assert_eq!(std::fs::read(&wal).unwrap(), original_wal);
5297 }
5298
5299 #[test]
5300 fn canonical_system_config_recovery_rejects_legacy_cross_references() {
5301 use crate::storage::wal::{WalRecord, WalWriter};
5302
5303 let fixture = duplicate_system_catalog_fixture_with_ref(true, true, false, true);
5304 let wal = reddb_file::unified_wal_path(&fixture.path);
5305 {
5306 let mut writer = WalWriter::open(&wal).unwrap();
5307 writer.append(&WalRecord::Begin { tx_id: 999 }).unwrap();
5308 writer.sync().unwrap();
5309 }
5310 let original = std::fs::read(&fixture.path).unwrap();
5311 let original_wal = std::fs::read(&wal).unwrap();
5312 let destination = fixture._directory.path().join("rejected-legacy-reference");
5313 let error = crate::storage::compact::recover_paged_wal_canonical_system_config(
5314 &fixture.path,
5315 &destination,
5316 )
5317 .expect_err("legacy references must not be retargeted silently");
5318 assert!(error
5319 .to_string()
5320 .contains("legacy red_config has cross-references"));
5321 assert!(!destination.join("data.rdb").exists());
5322 assert_eq!(std::fs::read(&fixture.path).unwrap(), original);
5323 assert_eq!(std::fs::read(&wal).unwrap(), original_wal);
5324 }
5325
5326 #[test]
5327 fn populated_system_collection_aliases_reconcile_without_rebuild() {
5328 let fixture = duplicate_system_catalog_fixture(true, false, true);
5329 alias_duplicate_system_catalog_state(&fixture);
5330
5331 let recovered = UnifiedStore::open_with_config(&fixture.path, fixture.config.clone())
5332 .expect("reconcile identical populated aliases");
5333 assert_eq!(recovered.list_collections(), vec!["red.config".to_string()]);
5334 assert_eq!(
5335 recovered
5336 .get("red.config", EntityId::new(1))
5337 .expect("preserved aliased row")
5338 .kind
5339 .collection(),
5340 "red.config"
5341 );
5342 assert_eq!(
5343 recovered.collection_root_page("red.config"),
5344 fixture.legacy_root,
5345 "the shared data root must be reused"
5346 );
5347 assert_eq!(
5348 recovered
5349 .key_indices
5350 .read()
5351 .get("red.config")
5352 .map(|index| index.tree.root_page_id()),
5353 fixture.legacy_key_root,
5354 "the shared key-index root must be reused"
5355 );
5356 assert_eq!(
5357 recovered
5358 .secondary_index("red.config", "by_id", &fixture.index_identity)
5359 .expect("shared secondary index")
5360 .root_page_id(),
5361 fixture
5362 .legacy_secondary_root
5363 .expect("fixture secondary root")
5364 );
5365 drop(recovered);
5366
5367 CHECKPOINT_SERIALIZED_ROWS.with(|count| count.set(0));
5368 let cold = UnifiedStore::open_with_config(&fixture.path, fixture.config)
5369 .expect("idempotent cold boot");
5370 assert_eq!(cold.list_collections(), vec!["red.config".to_string()]);
5371 assert_eq!(CHECKPOINT_SERIALIZED_ROWS.with(|count| count.get()), 0);
5372 assert_eq!(
5373 cold.pager()
5374 .expect("pager")
5375 .page_count()
5376 .expect("page count"),
5377 fixture.pages_before,
5378 "reconciling a physical alias must not extend the file"
5379 );
5380 }
5381
5382 #[test]
5383 fn populated_system_collection_aliases_merge_resume_counter_drift() {
5384 let fixture = duplicate_system_catalog_fixture(true, false, false);
5385 alias_duplicate_system_catalog_state_with(&fixture, |canonical| {
5386 canonical.entities += 7;
5387 canonical.entity_id_next += 100;
5388 canonical.row_id_next += 200;
5389 canonical.sequence_id_next += 300;
5390 });
5391
5392 let recovered = UnifiedStore::open_with_config(&fixture.path, fixture.config.clone())
5393 .expect("reconcile shared roots with conservative counters");
5394 let manager = recovered
5395 .get_collection("red.config")
5396 .expect("canonical collection");
5397 assert_eq!(manager.count(), 1, "the shared tree owns the exact count");
5398 assert_eq!(manager.identity_counters(), (102, 202));
5399 assert_eq!(manager.sequence_id_next(), 301);
5400 assert_eq!(
5401 recovered.collection_root_page("red.config"),
5402 fixture.legacy_root,
5403 "counter reconciliation must keep the shared data root"
5404 );
5405 drop(manager);
5406 drop(recovered);
5407
5408 CHECKPOINT_SERIALIZED_ROWS.with(|count| count.set(0));
5409 let cold = UnifiedStore::open_with_config(&fixture.path, fixture.config)
5410 .expect("idempotent cold boot after counter reconciliation");
5411 assert_eq!(cold.list_collections(), vec!["red.config".to_string()]);
5412 assert_eq!(CHECKPOINT_SERIALIZED_ROWS.with(|count| count.get()), 0);
5413 assert_eq!(
5414 cold.pager()
5415 .expect("pager")
5416 .page_count()
5417 .expect("page count"),
5418 fixture.pages_before,
5419 "counter reconciliation must not extend the file"
5420 );
5421 }
5422
5423 fn row(id: u64, value: i64) -> UnifiedEntity {
5424 UnifiedEntity::table_row(EntityId::new(id), "rows", id, vec![Value::Integer(value)])
5425 }
5426
5427 #[test]
5428 fn paged_checkpoint_excludes_uncommitted_graph_versions() {
5429 let directory = tempfile::tempdir().unwrap();
5430 let path = directory.path().join("graph.rdb");
5431 let store = UnifiedStore::open(&path).unwrap();
5432 let snapshots = Arc::new(crate::storage::transaction::snapshot::SnapshotManager::new());
5433 assert!(store.snapshot_manager.set(Arc::clone(&snapshots)).is_ok());
5434 store.create_collection("graph").unwrap();
5435 let node = UnifiedEntity::graph_node(EntityId::new(1), "a", "person", HashMap::new());
5436 let edge =
5437 UnifiedEntity::graph_edge(EntityId::new(2), "knows", "1", "1", 1.0, HashMap::new());
5438 store.insert("graph", node).unwrap();
5439 store.insert("graph", edge).unwrap();
5440 store.persist().unwrap();
5441
5442 let xid = snapshots.begin();
5443 let manager = store.get_collection("graph").unwrap();
5444 for id in [1, 2] {
5445 let mut entity = manager.get(EntityId::new(id)).unwrap();
5446 entity.xmax = xid;
5447 manager.update(entity).unwrap();
5448 }
5449 let mut node = UnifiedEntity::graph_node(EntityId::new(3), "b", "person", HashMap::new());
5450 let mut edge =
5451 UnifiedEntity::graph_edge(EntityId::new(4), "knows", "1", "3", 1.0, HashMap::new());
5452 node.xmin = xid;
5453 edge.xmin = xid;
5454 store.insert("graph", node).unwrap();
5455 store.insert("graph", edge).unwrap();
5456 store
5457 .add_cross_ref(
5458 "graph",
5459 EntityId::new(1),
5460 "graph",
5461 EntityId::new(4),
5462 RefType::RelatedTo,
5463 1.0,
5464 )
5465 .unwrap();
5466 store.persist().unwrap();
5467 snapshots.rollback(xid);
5468 store.persist().unwrap();
5469 drop(manager);
5470 drop(store);
5471
5472 let recovered = UnifiedStore::open(&path).unwrap();
5473 let manager = recovered.get_collection("graph").unwrap();
5474 assert_eq!(manager.get(EntityId::new(1)).unwrap().xmax, 0);
5475 assert_eq!(manager.get(EntityId::new(2)).unwrap().xmax, 0);
5476 assert!(manager.get(EntityId::new(3)).is_none());
5477 assert!(manager.get(EntityId::new(4)).is_none());
5478 assert!(!manager
5479 .get(EntityId::new(1))
5480 .unwrap()
5481 .cross_refs()
5482 .iter()
5483 .any(|reference| reference.target == EntityId::new(4)));
5484 assert!(!recovered
5485 .get_refs_from(EntityId::new(1))
5486 .iter()
5487 .any(|(target, _, _)| *target == EntityId::new(4)));
5488 }
5489
5490 #[test]
5491 fn paged_checkpoint_restores_graph_references_after_commit() {
5492 let directory = tempfile::tempdir().unwrap();
5493 let path = directory.path().join("graph.rdb");
5494 let store = UnifiedStore::open(&path).unwrap();
5495 let snapshots = Arc::new(crate::storage::transaction::snapshot::SnapshotManager::new());
5496 assert!(store.snapshot_manager.set(Arc::clone(&snapshots)).is_ok());
5497 store.create_collection("graph").unwrap();
5498 store
5499 .insert(
5500 "graph",
5501 UnifiedEntity::graph_node(EntityId::new(1), "a", "person", HashMap::new()),
5502 )
5503 .unwrap();
5504 store.persist().unwrap();
5505 let xid = snapshots.begin();
5506 let mut edge =
5507 UnifiedEntity::graph_edge(EntityId::new(2), "knows", "1", "1", 1.0, HashMap::new());
5508 edge.xmin = xid;
5509 store.insert("graph", edge).unwrap();
5510 store
5511 .add_cross_ref(
5512 "graph",
5513 EntityId::new(1),
5514 "graph",
5515 EntityId::new(2),
5516 RefType::RelatedTo,
5517 1.0,
5518 )
5519 .unwrap();
5520 store.persist().unwrap();
5521 snapshots.commit(xid);
5522 store.persist().unwrap();
5523 drop(store);
5524
5525 let recovered = UnifiedStore::open(&path).unwrap();
5526 let node = recovered.get("graph", EntityId::new(1)).unwrap();
5527 assert!(recovered.get("graph", EntityId::new(2)).is_some());
5528 assert!(node
5529 .cross_refs()
5530 .iter()
5531 .any(|reference| reference.target == EntityId::new(2)));
5532 assert!(recovered
5533 .get_refs_from(EntityId::new(1))
5534 .iter()
5535 .any(|(target, _, _)| *target == EntityId::new(2)));
5536 }
5537
5538 #[test]
5539 fn checkpoint_reuse_tracks_manager_updates_metadata_deletes_and_bulk_inserts() {
5540 let directory = tempfile::tempdir().expect("temporary store");
5541 let path = directory.path().join("data.rdb");
5542 let store = UnifiedStore::open(&path).expect("paged store");
5543 store.create_collection("rows").expect("collection");
5544 store.insert("rows", row(1, 1)).expect("seed");
5545 store.persist().expect("initial checkpoint");
5546 let manager = store.get_collection("rows").expect("rows");
5547 manager.update(row(1, 2)).expect("direct update");
5548 CHECKPOINT_SERIALIZED_ROWS.with(|count| count.set(0));
5549 store.persist().expect("updated row checkpoint");
5550 assert_eq!(CHECKPOINT_SERIALIZED_ROWS.with(|count| count.get()), 1);
5551 let mut metadata = Metadata::new();
5552 metadata.set("verified", MetadataValue::Bool(true));
5553 manager
5554 .set_metadata(EntityId::new(1), metadata)
5555 .expect("metadata");
5556 CHECKPOINT_SERIALIZED_ROWS.with(|count| count.set(0));
5557 store.persist().expect("metadata-only checkpoint");
5558 assert_eq!(CHECKPOINT_SERIALIZED_ROWS.with(|count| count.get()), 1);
5559 manager
5560 .bulk_insert(vec![row(2, 2), row(3, 3)])
5561 .expect("bulk insert");
5562 CHECKPOINT_SERIALIZED_ROWS.with(|count| count.set(0));
5563 store.persist().expect("bulk insert checkpoint");
5564 assert_eq!(CHECKPOINT_SERIALIZED_ROWS.with(|count| count.get()), 3);
5565 manager.delete(EntityId::new(2)).expect("delete");
5566 CHECKPOINT_SERIALIZED_ROWS.with(|count| count.set(0));
5567 store.persist().expect("changed checkpoint");
5568 assert_eq!(CHECKPOINT_SERIALIZED_ROWS.with(|count| count.get()), 2);
5569 drop(store);
5570 let recovered = UnifiedStore::open(&path).expect("recover changed rows");
5571 let manager = recovered.get_collection("rows").expect("rows");
5572 assert_eq!(manager.count(), 2);
5573 assert_eq!(
5574 manager
5575 .get(EntityId::new(1))
5576 .expect("row")
5577 .data
5578 .as_row()
5579 .expect("table")
5580 .columns,
5581 vec![Value::Integer(2)]
5582 );
5583 assert!(manager.get(EntityId::new(2)).is_none());
5584 assert!(manager
5585 .get_metadata(EntityId::new(1))
5586 .expect("metadata")
5587 .get("verified")
5588 .is_some());
5589 }
5590
5591 #[test]
5592 fn unrelated_visibility_changes_do_not_serialize_collection_rows() {
5593 let directory = tempfile::tempdir().expect("temporary store");
5594 let store = UnifiedStore::open(&directory.path().join("data.rdb")).expect("paged store");
5595 let snapshots = Arc::new(crate::storage::transaction::snapshot::SnapshotManager::new());
5596 assert!(store.snapshot_manager.set(Arc::clone(&snapshots)).is_ok());
5597 store.create_collection("rows").expect("collection");
5598 for id in 1..=40 {
5599 store.insert("rows", row(id, id as i64)).expect("seed");
5600 }
5601 store.persist().expect("initial checkpoint");
5602 let unrelated = snapshots.begin();
5603 snapshots.rollback(unrelated);
5604 CHECKPOINT_SERIALIZED_ROWS.with(|count| count.set(0));
5605 store.persist().expect("unrelated rollback checkpoint");
5606 assert_eq!(CHECKPOINT_SERIALIZED_ROWS.with(|count| count.get()), 0);
5607
5608 store
5609 .pending_vault_versions
5610 .write()
5611 .insert(EntityId::new(100));
5612 CHECKPOINT_SERIALIZED_ROWS.with(|count| count.set(0));
5613 store.persist().expect("unrelated vault checkpoint");
5614 assert_eq!(CHECKPOINT_SERIALIZED_ROWS.with(|count| count.get()), 0);
5615 store.pending_vault_versions.write().clear();
5616 CHECKPOINT_SERIALIZED_ROWS.with(|count| count.set(0));
5617 store
5618 .persist()
5619 .expect("unrelated vault publication checkpoint");
5620 assert_eq!(CHECKPOINT_SERIALIZED_ROWS.with(|count| count.get()), 0);
5621 }
5622
5623 #[test]
5624 fn low_level_rollback_of_a_non_active_xid_invalidates_sparse_visibility() {
5625 let directory = tempfile::tempdir().expect("temporary store");
5626 let path = directory.path().join("data.rdb");
5627 let store = UnifiedStore::open(&path).expect("paged store");
5628 let snapshots = Arc::new(crate::storage::transaction::snapshot::SnapshotManager::new());
5629 assert!(store.snapshot_manager.set(Arc::clone(&snapshots)).is_ok());
5630 store.create_collection("rows").expect("collection");
5631 let xid = snapshots.begin();
5632 let mut entity = row(1, 1);
5633 entity.xmin = xid;
5634 store.insert("rows", entity).expect("committed row");
5635 snapshots.commit(xid);
5636 store.persist().expect("committed baseline");
5637 snapshots.rollback(xid);
5638 store
5639 .persist()
5640 .expect("untracked outcome requires validation");
5641 drop(store);
5642 let recovered = UnifiedStore::open(&path).expect("recover");
5643 assert_eq!(recovered.get_collection("rows").expect("rows").count(), 0);
5644 }
5645
5646 #[test]
5647 fn exhausted_deferred_row_cache_keeps_full_validation() {
5648 let directory = tempfile::tempdir().expect("temporary store");
5649 let path = directory.path().join("data.rdb");
5650 let store = UnifiedStore::open(&path).expect("paged store");
5651 let snapshots = Arc::new(crate::storage::transaction::snapshot::SnapshotManager::new());
5652 assert!(store.snapshot_manager.set(Arc::clone(&snapshots)).is_ok());
5653 store.create_collection("rows").expect("collection");
5654 let xid = snapshots.begin();
5655 for id in 1..=1025 {
5656 let mut entity = row(id, id as i64);
5657 entity.xmin = xid;
5658 store.insert("rows", entity).expect("pending row");
5659 }
5660 store.persist().expect("exclude pending rows");
5661 assert!(store.checkpoint_versions.read()["rows"]
5662 .deferred_rows
5663 .is_none());
5664 snapshots.commit(xid);
5665 store
5666 .persist()
5667 .expect("publish committed rows conservatively");
5668 drop(store);
5669 let recovered = UnifiedStore::open(&path).expect("recover");
5670 assert_eq!(
5671 recovered.get_collection("rows").expect("rows").count(),
5672 1025
5673 );
5674 }
5675
5676 #[test]
5677 fn rollback_invalidates_reuse_without_a_segment_mutation() {
5678 let directory = tempfile::tempdir().expect("temporary store");
5679 let path = directory.path().join("data.rdb");
5680 let store = UnifiedStore::open(&path).expect("paged store");
5681 let snapshots = Arc::new(crate::storage::transaction::snapshot::SnapshotManager::new());
5682 assert!(store.snapshot_manager.set(Arc::clone(&snapshots)).is_ok());
5683 store.create_collection("rows").expect("collection");
5684 let xid = snapshots.begin();
5685 let mut pending = row(1, 1);
5686 pending.xmin = xid;
5687 store.insert("rows", pending).expect("version");
5688 store.persist().expect("before rollback");
5689 snapshots.rollback(xid);
5690 store.persist().expect("after rollback");
5691 drop(store);
5692 let recovered = UnifiedStore::open(&path).expect("recover");
5693 assert_eq!(recovered.get_collection("rows").expect("rows").count(), 0);
5694 }
5695
5696 #[test]
5697 fn rolled_back_delete_is_normalized_without_a_segment_mutation() {
5698 let directory = tempfile::tempdir().expect("temporary store");
5699 let path = directory.path().join("data.rdb");
5700 let store = UnifiedStore::open(&path).expect("paged store");
5701 let snapshots = Arc::new(crate::storage::transaction::snapshot::SnapshotManager::new());
5702 assert!(store.snapshot_manager.set(Arc::clone(&snapshots)).is_ok());
5703 store.create_collection("rows").expect("collection");
5704 let xid = snapshots.begin();
5705 let mut deleted = row(1, 1);
5706 deleted.xmax = xid;
5707 store.insert("rows", deleted).expect("version");
5708 store.persist().expect("before rollback");
5709 snapshots.rollback(xid);
5710 store.persist().expect("after rollback");
5711 drop(store);
5712 let recovered = UnifiedStore::open(&path).expect("recover");
5713 let manager = recovered.get_collection("rows").expect("rows");
5714 assert_eq!(manager.get(EntityId::new(1)).expect("retained row").xmax, 0);
5715 }
5716
5717 #[test]
5718 fn publishing_a_pending_vault_version_invalidates_reuse() {
5719 let directory = tempfile::tempdir().expect("temporary store");
5720 let path = directory.path().join("data.rdb");
5721 let store = UnifiedStore::open(&path).expect("paged store");
5722 store.create_collection("rows").expect("collection");
5723 store.insert("rows", row(1, 1)).expect("row");
5724 store
5725 .pending_vault_versions
5726 .write()
5727 .insert(EntityId::new(1));
5728 store.persist().expect("exclude pending vault version");
5729 store
5730 .pending_vault_versions
5731 .write()
5732 .remove(&EntityId::new(1));
5733 store.persist().expect("include published vault version");
5734 drop(store);
5735 let recovered = UnifiedStore::open(&path).expect("recover");
5736 assert_eq!(recovered.get_collection("rows").expect("rows").count(), 1);
5737 }
5738}