1use super::*;
2use crate::api::DurabilityMode;
3use crate::storage::wal::{WalReader, WalRecord, WalWriter};
4use reddb_engine::storage::resident_file::EMBEDDED_WAL_HEADROOM_LIMIT;
5use std::cell::RefCell;
6use std::io;
7use std::path::{Path, PathBuf};
8use std::sync::atomic::{AtomicU64, AtomicU8, Ordering};
9use std::sync::{Arc, Condvar, Mutex};
10
11#[path = "checkpoint_undo.rs"]
12mod checkpoint_undo;
13
14struct NativeReplayTarget<'a> {
15 store: &'a UnifiedStore,
16 update_btrees: bool,
17}
18
19impl reddb_engine::storage::store_wal::StoreReplayTarget for NativeReplayTarget<'_> {
20 fn set_aux_metadata(&self, bytes: &[u8]) -> Result<(), StoreError> {
21 self.store.resident.set_aux_metadata(bytes.to_vec());
22 Ok(())
23 }
24 fn has_collection(&self, name: &str) -> bool {
25 self.store.get_collection(name).is_some()
26 }
27 fn create_collection(&self, name: &str) -> Result<(), StoreError> {
28 self.store.create_collection_in_memory(name)
29 }
30 fn drop_collection(&self, name: &str) -> Result<(), StoreError> {
31 self.store.drop_collection_in_memory(name)
32 }
33 fn upsert(&self, collection: &str, record: &[u8]) -> Result<(), StoreError> {
34 self.store
35 .apply_replayed_upsert(collection, record, self.update_btrees)
36 }
37 fn delete(&self, collection: &str, id: EntityId) -> Result<(), StoreError> {
38 self.store
39 .apply_replayed_delete(collection, id, self.update_btrees)
40 }
41 fn refresh(&self, collection: &str, records: &[Vec<u8>]) -> Result<(), StoreError> {
42 self.store
43 .apply_replayed_refresh_collection(collection, records, self.update_btrees)
44 }
45}
46
47const DEFAULT_ADAPTIVE_WINDOW_US: u64 = 200;
64
65type WalMutex = parking_lot::Mutex<WalWriter>;
72
73type CommitStateMutex = parking_lot::Mutex<CommitState>;
79type CommitStateCondvar = parking_lot::Condvar;
80use std::time::{Duration, Instant};
81
82static NEXT_STORE_TX_ID: AtomicU64 = AtomicU64::new(1);
83
84pub(crate) use reddb_engine::storage::store_wal::StoreWalAction;
85
86#[derive(Debug, Default)]
87pub(crate) struct DeferredStoreWalActions {
88 pub(super) actions: Vec<StoreWalAction>,
89}
90
91pub(super) struct StoreWalCaptureGuard<'a> {
92 active: bool,
93 session: Option<&'a crate::storage::embedded::EmbeddedCommitSession>,
94}
95
96impl<'a> StoreWalCaptureGuard<'a> {
97 pub(super) fn begin(
98 session: Option<&'a crate::storage::embedded::EmbeddedCommitSession>,
99 ) -> Self {
100 assert!(
101 !deferred_store_wal_capture_active(),
102 "store WAL captures cannot nest"
103 );
104 begin_deferred_store_wal_capture();
105 Self {
106 active: true,
107 session,
108 }
109 }
110
111 pub(super) fn finish(mut self) -> DeferredStoreWalActions {
112 let actions = take_deferred_store_wal_capture();
113 self.active = false;
114 actions
115 }
116}
117
118impl Drop for StoreWalCaptureGuard<'_> {
119 fn drop(&mut self) {
120 if self.active {
121 if let Some(session) = self.session {
122 session.fence_interrupted_write();
123 }
124 let _ = take_deferred_store_wal_capture();
125 }
126 }
127}
128
129impl DeferredStoreWalActions {
130 pub(crate) fn is_empty(&self) -> bool {
131 self.actions.is_empty()
132 }
133
134 pub(crate) fn extend(&mut self, other: Self) {
135 self.actions.extend(other.actions);
136 }
137}
138
139thread_local! {
140 static DEFERRED_STORE_WAL_ACTIONS: RefCell<Option<Vec<StoreWalAction>>> =
141 const { RefCell::new(None) };
142}
143
144fn begin_deferred_store_wal_capture() {
145 DEFERRED_STORE_WAL_ACTIONS.with(|cell| {
146 let mut guard = cell.borrow_mut();
147 debug_assert!(guard.is_none());
148 *guard = Some(Vec::new());
149 });
150}
151
152fn capture_deferred_store_wal_actions(actions: Vec<StoreWalAction>) -> bool {
153 DEFERRED_STORE_WAL_ACTIONS.with(|cell| {
154 let mut guard = cell.borrow_mut();
155 if let Some(pending) = guard.as_mut() {
156 pending.extend(actions);
157 true
158 } else {
159 false
160 }
161 })
162}
163
164fn deferred_store_wal_capture_active() -> bool {
165 DEFERRED_STORE_WAL_ACTIONS.with(|cell| cell.borrow().is_some())
166}
167
168fn take_deferred_store_wal_capture() -> DeferredStoreWalActions {
169 DEFERRED_STORE_WAL_ACTIONS.with(|cell| DeferredStoreWalActions {
170 actions: cell.borrow_mut().take().unwrap_or_default(),
171 })
172}
173
174#[derive(Debug)]
175struct CommitState {
176 durable_lsn: u64,
177 pending_target_lsn: u64,
178 pending_statements: usize,
179 pending_wal_bytes: u64,
180 first_pending_at: Option<Instant>,
181 shutdown: bool,
182 last_error: Option<String>,
183}
184
185impl CommitState {
186 fn new(initial_durable_lsn: u64) -> Self {
187 Self {
188 durable_lsn: initial_durable_lsn,
189 pending_target_lsn: initial_durable_lsn,
190 pending_statements: 0,
191 pending_wal_bytes: 0,
192 first_pending_at: None,
193 shutdown: false,
194 last_error: None,
195 }
196 }
197}
198
199pub(crate) struct WalAppendQueue {
213 epoch: parking_lot::RwLock<()>,
216 pending: parking_lot::Mutex<WalQueueState>,
231 queued_bytes: AtomicU64,
236}
237
238struct WalQueueState {
239 next_lsn: u64,
240 entries: Vec<(u64, Vec<u8>)>,
241}
242
243impl WalAppendQueue {
244 fn new(initial_lsn: u64) -> Self {
245 Self {
246 epoch: parking_lot::RwLock::new(()),
247 pending: parking_lot::Mutex::new(WalQueueState {
248 next_lsn: initial_lsn,
249 entries: Vec::with_capacity(64),
250 }),
251 queued_bytes: AtomicU64::new(0),
252 }
253 }
254
255 fn enqueue(&self, bytes: Vec<u8>) -> u64 {
261 let len = bytes.len() as u64;
262 let mut state = self.pending.lock();
263 let start_lsn = state.next_lsn;
264 state.next_lsn = start_lsn + len;
265 state.entries.push((start_lsn, bytes));
266 self.queued_bytes.fetch_add(len, Ordering::Relaxed);
267 start_lsn + len
268 }
269
270 fn drain_sorted(&self) -> Vec<(u64, Vec<u8>)> {
274 let mut state = self.pending.lock();
275 let mut v = std::mem::take(&mut state.entries);
276 let drained: u64 = v.iter().map(|(_, bytes)| bytes.len() as u64).sum();
277 drop(state);
278 let _ = self
282 .queued_bytes
283 .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |queued| {
284 Some(queued.saturating_sub(drained))
285 });
286 v.sort_by_key(|(lsn, _)| *lsn);
287 v
288 }
289
290 fn has_pending(&self) -> bool {
293 !self.pending.lock().entries.is_empty()
294 }
295
296 fn queued_bytes(&self) -> u64 {
298 self.queued_bytes.load(Ordering::Relaxed)
299 }
300
301 fn reset(&self, next_lsn: u64) {
307 let mut state = self.pending.lock();
308 state.next_lsn = next_lsn;
309 state.entries.clear();
310 drop(state);
311 self.queued_bytes.store(0, Ordering::Relaxed);
312 }
313}
314
315pub(crate) struct StoreCommitCoordinator {
316 mode: DurabilityMode,
317 config: crate::api::GroupCommitOptions,
318 wal_path: PathBuf,
319 wal: Arc<WalMutex>,
320 queue: Arc<WalAppendQueue>,
326 state: Arc<(CommitStateMutex, CommitStateCondvar)>,
327 fsync_count: Arc<AtomicU64>,
333 wal_generation: AtomicU64,
334 ownership_last_epoch: AtomicU64,
336 ownership_wal_state: AtomicU8,
338}
339
340#[allow(dead_code)] struct StoreOwnershipRecorder {
343 coordinator: Arc<StoreCommitCoordinator>,
344 epoch: u64,
345 images: parking_lot::Mutex<CheckpointPageImages>,
346}
347
348#[derive(Default)]
349struct CheckpointPageImages {
350 reserved: roaring::RoaringBitmap,
351 captured: roaring::RoaringBitmap,
352}
353
354impl StoreOwnershipRecorder {
355 #[allow(dead_code)]
356 fn begin(coordinator: Arc<StoreCommitCoordinator>, epoch: u64) -> io::Result<Self> {
357 coordinator.append_durable_page_ownership(
358 reddb_file::page_ownership_journal::PageOwnershipRecord::Begin { epoch },
359 )?;
360 Ok(Self {
361 coordinator,
362 epoch,
363 images: parking_lot::Mutex::new(CheckpointPageImages::default()),
364 })
365 }
366
367 fn append_pages(
368 &self,
369 pages: &[u32],
370 make_record: impl Fn(u64, u32) -> reddb_file::page_ownership_journal::PageOwnershipRecord,
371 ) -> io::Result<()> {
372 for chunk in pages.chunks(256) {
373 let records: Vec<_> = chunk
374 .iter()
375 .map(|&id| make_record(self.epoch, id))
376 .collect();
377 self.coordinator
378 .append_durable_page_ownership_batch(&records)?;
379 }
380 Ok(())
381 }
382}
383
384impl crate::storage::engine::pager::PageOwnershipRecorder for StoreOwnershipRecorder {
385 fn needs_page_image(&self, page_id: u32) -> io::Result<bool> {
386 if page_id < reddb_engine::storage::pager_io::FIRST_ALLOCATABLE_PAGE {
389 return Ok(false);
390 }
391 let images = self.images.lock();
392 Ok(!images.reserved.contains(page_id) && !images.captured.contains(page_id))
393 }
394
395 fn capture_page_image(&self, page: &crate::storage::engine::Page) -> io::Result<()> {
396 let mut images = self.images.lock();
397 let page_id = page.page_id();
398 if images.reserved.contains(page_id) || images.captured.contains(page_id) {
399 return Ok(());
400 }
401 let mut bytes = Vec::new();
402 reddb_file::encode_main_wal_record_frame_into(
403 reddb_file::MainWalRecordFrameRef::FullPageImage {
404 tx_id: 0,
405 page_id,
406 ckpt_epoch: self.epoch,
407 data: page.as_bytes(),
408 },
409 crate::storage::wal::record::WAL_DEFAULT_TERM,
410 &mut bytes,
411 )?;
412 self.coordinator.append_durable_ownership_bytes(bytes)?;
413 images.captured.insert(page_id);
414 Ok(())
415 }
416
417 fn reserve_pages(&self, start: u32, count: u32) -> io::Result<()> {
418 use reddb_file::page_ownership_journal::PageOwnershipRecord;
419 let end = start.checked_add(count).ok_or_else(|| {
420 io::Error::new(io::ErrorKind::InvalidInput, "ownership range overflow")
421 })?;
422 let mut next = start;
423 while next < end {
424 let chunk_end = next.saturating_add(256).min(end);
425 let records: Vec<_> = (next..chunk_end)
426 .map(|page_id| PageOwnershipRecord::Reserve {
427 epoch: self.epoch,
428 page_id,
429 })
430 .collect();
431 self.coordinator
432 .append_durable_page_ownership_batch(&records)?;
433 self.images.lock().reserved.insert_range(next..chunk_end);
434 next = chunk_end;
435 }
436 Ok(())
437 }
438
439 fn detach_free_pages(&self, pages: &[u32]) -> io::Result<()> {
440 self.append_pages(pages, |epoch, page_id| {
441 reddb_file::page_ownership_journal::PageOwnershipRecord::DetachFree { epoch, page_id }
442 })
443 }
444
445 fn retire_pages(&self, pages: &[u32]) -> io::Result<()> {
446 self.append_pages(pages, |epoch, page_id| {
447 reddb_file::page_ownership_journal::PageOwnershipRecord::Retire { epoch, page_id }
448 })
449 }
450}
451
452impl StoreCommitCoordinator {
453 pub(crate) fn open(
454 wal_path: impl Into<PathBuf>,
455 mode: DurabilityMode,
456 config: crate::api::GroupCommitOptions,
457 ) -> io::Result<Self> {
458 let wal_path = wal_path.into();
459 let wal = WalWriter::open(&wal_path)?;
460 let initial_durable_lsn = wal.durable_lsn();
461 let initial_current_lsn = wal.current_lsn();
462 let wal = Arc::new(WalMutex::new(wal));
463 let queue = Arc::new(WalAppendQueue::new(initial_current_lsn));
464 let state = Arc::new((
465 CommitStateMutex::new(CommitState::new(initial_durable_lsn)),
466 CommitStateCondvar::new(),
467 ));
468 let fsync_count = Arc::new(AtomicU64::new(0));
469
470 if matches!(
471 mode,
472 DurabilityMode::WalDurableGrouped | DurabilityMode::Async
473 ) {
474 let wal_bg = Arc::clone(&wal);
475 let queue_bg = Arc::clone(&queue);
476 let state_bg = Arc::clone(&state);
477 let fsync_bg = Arc::clone(&fsync_count);
478 let window = Self::resolve_window(&config);
493 let max_statements = config.max_statements.max(1);
494 let max_wal_bytes = config.max_wal_bytes.max(1);
495 std::thread::spawn(move || {
496 Self::run_group_commit_loop(
497 wal_bg,
498 queue_bg,
499 state_bg,
500 fsync_bg,
501 window,
502 max_statements,
503 max_wal_bytes,
504 );
505 });
506 }
507
508 Ok(Self {
509 mode,
510 config,
511 wal_path,
512 wal,
513 queue,
514 state,
515 fsync_count,
516 wal_generation: AtomicU64::new(0),
517 ownership_last_epoch: AtomicU64::new(0),
518 ownership_wal_state: AtomicU8::new(0),
519 })
520 }
521
522 fn resolve_window(config: &crate::api::GroupCommitOptions) -> Duration {
531 if let Ok(raw) = std::env::var("REDDB_GROUP_COMMIT_WINDOW_US") {
532 if let Ok(parsed) = raw.parse::<u64>() {
533 return Duration::from_micros(parsed);
534 }
535 }
536 if config.window_ms != 0 {
537 return Duration::from_millis(config.window_ms);
538 }
539 Duration::from_micros(DEFAULT_ADAPTIVE_WINDOW_US)
540 }
541
542 #[cfg(test)]
546 pub(crate) fn fsync_count(&self) -> u64 {
547 self.fsync_count.load(Ordering::Relaxed)
548 }
549
550 pub(crate) fn buffered_bytes(&self) -> u64 {
554 self.queue.queued_bytes() + crate::storage::wal::WAL_BUFFER_BYTES as u64
555 }
556
557 pub(crate) fn append_actions(&self, actions: &[StoreWalAction]) -> io::Result<()> {
558 self.append_actions_impl(actions, false).map(|_| ())
559 }
560
561 pub(super) fn append_checkpoint_actions(
562 &self,
563 actions: &[StoreWalAction],
564 ) -> io::Result<super::checkpoint_wal::CheckpointWalBatch> {
565 self.append_actions_impl(actions, true)
566 }
567
568 fn append_actions_impl(
569 &self,
570 actions: &[StoreWalAction],
571 retain: bool,
572 ) -> io::Result<super::checkpoint_wal::CheckpointWalBatch> {
573 use super::checkpoint_wal::CheckpointWalBatch;
574 if actions.is_empty() {
575 return Ok(CheckpointWalBatch::default());
576 }
577 let _epoch = self.queue.epoch.read();
578 let tx_id = NEXT_STORE_TX_ID.fetch_add(1, Ordering::SeqCst);
579 let encoded: Vec<Vec<u8>> = actions.iter().map(StoreWalAction::encode).collect();
580 let wal_bytes = encoded.iter().fold(0u64, |total, action| {
581 total.saturating_add(action.len() as u64)
582 });
583 let (blob, mut references) = if retain {
584 CheckpointWalBatch::encode(
585 tx_id,
586 &encoded,
587 self.wal_generation.load(Ordering::Acquire),
588 )?
589 } else {
590 (
591 WalRecord::TxCommitBatch {
592 tx_id,
593 actions: encoded,
594 }
595 .encode(),
596 CheckpointWalBatch::default(),
597 )
598 };
599 let start_lsn = if matches!(self.mode, DurabilityMode::Strict) {
600 let start = self.wal.lock().append_bytes(&blob)?;
601 self.force_sync()?;
602 start
603 } else {
604 let length = blob.len() as u64;
605 let end = self.queue.enqueue(blob);
606 self.wait_until_durable(end, wal_bytes)?;
607 end.checked_sub(length).ok_or_else(|| {
608 io::Error::new(io::ErrorKind::InvalidData, "invalid WAL batch offset")
609 })?
610 };
611 references.relocate(start_lsn)?;
612 Ok(references)
613 }
614
615 pub(super) fn checkpoint_row_matches(
618 &self,
619 reference: super::checkpoint_wal::WalRowReference,
620 expected: &[u8],
621 ) -> io::Result<bool> {
622 if expected.len() != reference.length as usize {
623 return Ok(false);
624 }
625 let mut position = 0;
626 let mut matches = true;
627 let present = self.visit_checkpoint_row(reference, |chunk| {
628 matches &= chunk == &expected[position..position + chunk.len()];
629 position += chunk.len();
630 })?;
631 Ok(present && matches)
632 }
633
634 pub(super) fn checkpoint_row_bytes(
636 &self,
637 reference: super::checkpoint_wal::WalRowReference,
638 ) -> io::Result<Vec<u8>> {
639 let mut bytes = Vec::new();
640 let present = self.visit_checkpoint_row(reference, |chunk| {
641 if bytes.is_empty() {
642 bytes.reserve_exact(reference.length as usize);
643 }
644 bytes.extend_from_slice(chunk);
645 })?;
646 if !present {
647 return Err(io::Error::new(
648 io::ErrorKind::InvalidData,
649 "checkpoint WAL reference changed during projection",
650 ));
651 }
652 Ok(bytes)
653 }
654
655 fn visit_checkpoint_row(
656 &self,
657 reference: super::checkpoint_wal::WalRowReference,
658 mut visit: impl FnMut(&[u8]),
659 ) -> io::Result<bool> {
660 use std::io::{Read, Seek, SeekFrom};
661 let _epoch = self.queue.epoch.read_recursive();
662 if reference.generation != self.wal_generation.load(Ordering::Acquire) {
663 return Ok(false);
664 }
665 let end = reference
666 .offset
667 .checked_add(u64::from(reference.length))
668 .ok_or_else(|| {
669 io::Error::new(io::ErrorKind::InvalidData, "WAL proof offset overflow")
670 })?;
671 let (state_lock, _) = &*self.state;
672 if end > state_lock.lock().durable_lsn {
673 return Ok(false);
674 }
675 let mut file = std::fs::File::open(&self.wal_path)?;
676 file.seek(SeekFrom::Start(reference.offset))?;
677 let mut buffer = [0u8; 16 * 1024];
678 let mut checksum = crc32fast::Hasher::new();
679 let mut remaining = reference.length as usize;
680 while remaining != 0 {
681 let length = remaining.min(buffer.len());
682 let chunk = &mut buffer[..length];
683 file.read_exact(chunk)?;
684 checksum.update(chunk);
685 visit(chunk);
686 remaining -= length;
687 }
688 if checksum.finalize() != reference.checksum {
689 return Err(io::Error::new(
690 io::ErrorKind::InvalidData,
691 "checkpoint WAL proof checksum mismatch",
692 ));
693 }
694 Ok(true)
695 }
696
697 pub(crate) fn append_single_record(&self, record: WalRecord) -> io::Result<()> {
702 if matches!(record, WalRecord::PageOwnership { .. }) {
703 return Err(io::Error::new(
704 io::ErrorKind::InvalidInput,
705 "page ownership requires a durable append",
706 ));
707 }
708 let _epoch = self.queue.epoch.read();
709 let blob = record.encode();
710 let wal_bytes = blob.len() as u64;
711 if matches!(self.mode, DurabilityMode::Strict) {
712 {
713 let mut wal = self.wal.lock();
714 wal.append(&record)?;
715 }
716 self.force_sync()?;
717 return Ok(());
718 }
719 let commit_lsn = self.queue.enqueue(blob);
720 self.wait_until_durable(commit_lsn, wal_bytes)?;
721 Ok(())
722 }
723
724 #[allow(dead_code)] pub(super) fn append_durable_page_ownership(
728 &self,
729 record: reddb_file::page_ownership_journal::PageOwnershipRecord,
730 ) -> io::Result<()> {
731 self.append_durable_page_ownership_batch(&[record])
732 }
733
734 #[allow(dead_code)] pub(super) fn append_durable_page_ownership_batch(
737 &self,
738 records: &[reddb_file::page_ownership_journal::PageOwnershipRecord],
739 ) -> io::Result<()> {
740 if records.is_empty() {
741 return Ok(());
742 }
743 let mut bytes = Vec::new();
744 for &record in records {
745 reddb_file::page_ownership_journal::encode(record)?;
747 WalRecord::PageOwnership { record }.encode_into(&mut bytes);
748 }
749 for record in records {
750 self.ownership_last_epoch
751 .fetch_max(record.epoch(), Ordering::AcqRel);
752 }
753 self.append_durable_ownership_bytes(bytes)
754 }
755
756 fn append_durable_ownership_bytes(&self, bytes: Vec<u8>) -> io::Result<()> {
757 let _epoch = self.queue.epoch.read();
758 self.ownership_wal_state.store(2, Ordering::Release);
761 if matches!(self.mode, DurabilityMode::Strict) {
762 self.wal.lock().append_bytes(&bytes)?;
763 return self.force_sync();
764 }
765
766 let length = bytes.len() as u64;
767 let target = self.queue.enqueue(bytes);
768 let (state_lock, cond) = &*self.state;
769 let mut state = state_lock.lock();
770 state.pending_target_lsn = state.pending_target_lsn.max(target);
771 state.pending_statements = state.pending_statements.saturating_add(1);
772 state.pending_wal_bytes = state.pending_wal_bytes.saturating_add(length);
773 state.first_pending_at.get_or_insert_with(Instant::now);
774 cond.notify_all();
775 loop {
776 if let Some(error) = state.last_error.clone() {
777 return Err(io::Error::other(error));
778 }
779 if state.durable_lsn >= target {
780 return Ok(());
781 }
782 cond.wait(&mut state);
783 }
784 }
785
786 pub(crate) fn force_sync(&self) -> io::Result<()> {
787 let _epoch = self.queue.epoch.read_recursive();
790 {
791 let mut wal = self.wal.lock();
792 wal.sync()?;
793 self.fsync_count.fetch_add(1, Ordering::Relaxed);
794 let durable = wal.durable_lsn();
795 drop(wal);
796 let (state_lock, cond) = &*self.state;
797 let mut state = state_lock.lock();
798 state.durable_lsn = durable;
799 state.pending_target_lsn = durable.max(state.pending_target_lsn);
800 state.pending_statements = 0;
801 state.pending_wal_bytes = 0;
802 state.first_pending_at = None;
803 state.last_error = None;
804 cond.notify_all();
805 }
806 Ok(())
807 }
808
809 pub(crate) fn truncate(&self) -> io::Result<()> {
810 let _epoch = self.queue.epoch.write();
811 if self.ownership_wal_state.load(Ordering::Acquire) == 0 {
812 let reader = WalReader::open(&self.wal_path)?;
813 let mut protected = false;
814 for item in reader.iter() {
815 match item {
816 Ok((_, WalRecord::PageOwnership { .. })) => {
817 protected = true;
818 break;
819 }
820 Ok(_) => {}
821 Err(error) if error.kind() == io::ErrorKind::UnexpectedEof => break,
822 Err(error) => return Err(error),
823 }
824 }
825 self.ownership_wal_state
826 .store(if protected { 2 } else { 1 }, Ordering::Release);
827 }
828 if self.ownership_wal_state.load(Ordering::Acquire) == 2 {
829 return Err(io::Error::other(
832 "cannot truncate WAL while page ownership records require recovery",
833 ));
834 }
835 self.wal_generation.fetch_add(1, Ordering::AcqRel);
836 let mut wal = self.wal.lock();
837 wal.truncate()?;
838 let durable = wal.durable_lsn();
839 let current = wal.current_lsn();
840 drop(wal);
841
842 self.queue.reset(current);
847
848 let (state_lock, cond) = &*self.state;
849 let mut state = state_lock.lock();
850 state.durable_lsn = durable;
851 state.pending_target_lsn = durable;
852 state.pending_statements = 0;
853 state.pending_wal_bytes = 0;
854 state.first_pending_at = None;
855 state.last_error = None;
856 cond.notify_all();
857 Ok(())
858 }
859
860 fn release_completed_ownership_wal(&self) -> io::Result<()> {
861 use reddb_file::page_ownership_journal::{encode, pending_epoch, PageOwnershipRecord};
862 let _epoch = self.queue.epoch.write();
863 let mut journal = Vec::new();
864 let mut last = None;
865 for item in WalReader::open(&self.wal_path)?.iter() {
866 match item {
867 Ok((_, WalRecord::PageOwnership { record })) => {
868 journal.extend_from_slice(&encode(record)?);
869 last = Some(record);
870 }
871 Ok(_) => {}
872 Err(error) if error.kind() == io::ErrorKind::UnexpectedEof => {
873 return Err(io::Error::new(
874 io::ErrorKind::InvalidData,
875 "incomplete WAL before ownership completion",
876 ));
877 }
878 Err(error) => return Err(error),
879 }
880 }
881 if pending_epoch(&journal)?.is_some()
882 || !matches!(last, Some(PageOwnershipRecord::Finish { .. }))
883 {
884 return Err(io::Error::other("page ownership WAL is not complete"));
885 }
886 self.ownership_wal_state.store(1, Ordering::Release);
887 Ok(())
888 }
889
890 fn recover_page_ownership(&self, store: &UnifiedStore, journal: &[u8]) -> io::Result<()> {
891 use reddb_file::page_ownership_journal::{
892 pending_epoch, recovery_plan, PageOwnershipRecord,
893 };
894 if journal.is_empty() {
895 return Ok(());
896 }
897 let pager = store
898 .pager
899 .as_ref()
900 .ok_or_else(|| io::Error::other("ownership WAL requires a pager"))?;
901 let header = pager
902 .header()
903 .map_err(|error| io::Error::other(error.to_string()))?;
904 if header.version < reddb_file::file_format::PAGE_OWNERSHIP_FILE_VERSION {
905 return Err(io::Error::other(
906 "page ownership journal requires recovery with a fenced file version",
907 ));
908 }
909 let pending = pending_epoch(journal).map_err(|error| {
910 io::Error::other(format!("page ownership journal requires recovery: {error}"))
911 })?;
912 if let Some(mut pending) = pending {
913 let catalog_epoch = store.ownership_catalog_epoch.load(Ordering::Acquire);
914 let (_, retained_pages) = pager
915 .deferred_reclamation_pages()
916 .map_err(|error| io::Error::other(error.to_string()))?;
917 if catalog_epoch == pending.epoch {
921 for page in &retained_pages {
922 pending.retired_pages.remove(page);
923 }
924 }
925 let mut plan = recovery_plan(
926 &pending,
927 catalog_epoch,
928 reddb_engine::storage::pager_io::FIRST_ALLOCATABLE_PAGE,
929 header.page_count,
930 )?;
931 if retained_pages
932 .iter()
933 .any(|page| plan.release.binary_search(page).is_ok())
934 {
935 return Err(io::Error::other(
936 "ownership recovery would release a catalog-retained page",
937 ));
938 }
939 plan.preserve.extend(retained_pages);
940 plan.preserve.sort_unstable();
941 plan.preserve.dedup();
942 pager
943 .validate_owned_pages(&plan.preserve)
944 .map_err(|error| io::Error::other(error.to_string()))?;
945 pager
946 .recover_unowned_pages(&plan.release)
947 .map_err(|error| io::Error::other(error.to_string()))?;
948 #[cfg(test)]
949 if std::env::var_os("REDDB_EXPERIMENTAL_EXIT_DURING_OWNERSHIP_RECOVERY").is_some() {
950 std::process::exit(187);
951 }
952 pager
953 .sync_recovered_allocator()
954 .map_err(|error| io::Error::other(error.to_string()))?;
955 #[cfg(test)]
956 if std::env::var_os("REDDB_EXPERIMENTAL_EXIT_AFTER_OWNERSHIP_SYNC").is_some() {
957 std::process::exit(188);
958 }
959 self.append_durable_page_ownership(PageOwnershipRecord::Finish {
960 epoch: pending.epoch,
961 })?;
962 #[cfg(test)]
963 if std::env::var_os("REDDB_EXPERIMENTAL_EXIT_AFTER_OWNERSHIP_FINISH").is_some() {
964 std::process::exit(189);
965 }
966 }
967 self.release_completed_ownership_wal()
968 }
969
970 pub(super) fn next_page_ownership_epoch(&self, store: &UnifiedStore) -> io::Result<u64> {
971 self.ownership_last_epoch
972 .load(Ordering::Acquire)
973 .max(store.ownership_catalog_epoch.load(Ordering::Acquire))
974 .checked_add(1)
975 .ok_or_else(|| io::Error::other("page ownership epoch exhausted"))
976 }
977
978 pub(super) fn begin_page_ownership_epoch(
980 &self,
981 store: &UnifiedStore,
982 epoch: u64,
983 ) -> io::Result<()> {
984 if store.ownership_active_epoch.load(Ordering::Acquire) != 0
985 || epoch <= store.ownership_catalog_epoch.load(Ordering::Acquire)
986 || epoch <= self.ownership_last_epoch.load(Ordering::Acquire)
987 {
988 return Err(io::Error::other("invalid page ownership epoch"));
989 }
990 let pager = store
991 .pager
992 .as_ref()
993 .ok_or_else(|| io::Error::other("ownership epoch requires a pager"))?;
994 let format_version = if !store.secondary_index_specs.read().is_empty() {
995 reddb_file::file_format::PAGE_SECONDARY_INDEX_FILE_VERSION
996 } else if store.config.resident_memory_bytes_max.is_some() {
997 reddb_file::file_format::PAGE_DISK_RESIDENCY_FILE_VERSION
998 } else {
999 reddb_file::file_format::PAGE_CHECKPOINT_UNDO_FILE_VERSION
1000 };
1001 let format_version = format_version.max(
1004 pager
1005 .header()
1006 .map_err(|error| io::Error::other(error.to_string()))?
1007 .version,
1008 );
1009 pager
1010 .fence_file_format_version(format_version)
1011 .map_err(|error| io::Error::other(error.to_string()))?;
1012 let coordinator = Arc::clone(
1013 store
1014 .commit
1015 .as_ref()
1016 .ok_or_else(|| io::Error::other("ownership epoch requires a WAL coordinator"))?,
1017 );
1018 let recorder = Arc::new(StoreOwnershipRecorder::begin(coordinator, epoch)?);
1019 pager.install_ownership_recorder(recorder)?;
1020 store.ownership_active_epoch.store(epoch, Ordering::Release);
1021 Ok(())
1022 }
1023
1024 pub(super) fn finish_page_ownership_epoch(
1026 &self,
1027 store: &UnifiedStore,
1028 epoch: u64,
1029 ) -> io::Result<()> {
1030 if epoch == 0 || store.ownership_active_epoch.load(Ordering::Acquire) != epoch {
1031 return Err(io::Error::other("page ownership epoch is not active"));
1032 }
1033 if store.ownership_catalog_epoch.load(Ordering::Acquire) != epoch {
1034 return Err(io::Error::other("page ownership epoch is not published"));
1035 }
1036 let pager = store
1037 .pager
1038 .as_ref()
1039 .ok_or_else(|| io::Error::other("ownership epoch requires a pager"))?;
1040 pager
1041 .sync_recovered_allocator()
1042 .map_err(|error| io::Error::other(error.to_string()))?;
1043 self.append_durable_page_ownership(
1044 reddb_file::page_ownership_journal::PageOwnershipRecord::Finish { epoch },
1045 )?;
1046 pager.clear_ownership_recorder()?;
1047 store.ownership_active_epoch.store(0, Ordering::Release);
1048 self.release_completed_ownership_wal()
1049 }
1050
1051 pub(crate) fn replay_into(&self, store: &UnifiedStore) -> io::Result<()> {
1052 if !self.wal_path.exists() {
1053 return Ok(());
1054 }
1055
1056 let reader = match WalReader::open(&self.wal_path) {
1057 Ok(reader) => reader,
1058 Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(()),
1059 Err(err) => return Err(err),
1060 };
1061
1062 let mut tx_states = std::collections::HashMap::<u64, bool>::new();
1063 let mut pending = Vec::<(u64, Vec<u8>)>::new();
1064 let mut ownership_journal = Vec::new();
1065 let mut latest_ownership_epoch = 0;
1066 let mut incomplete_wal_tail = false;
1067 let mut applied = false;
1068
1069 for record in reader.iter() {
1070 let (_lsn, record) = match record {
1071 Ok(record) => record,
1072 Err(err) if err.kind() == io::ErrorKind::UnexpectedEof => {
1073 incomplete_wal_tail = true;
1074 break;
1075 }
1076 Err(err) => return Err(err),
1077 };
1078 match record {
1079 WalRecord::PageOwnership { record } => {
1080 latest_ownership_epoch = latest_ownership_epoch.max(record.epoch());
1081 self.ownership_last_epoch
1082 .fetch_max(record.epoch(), Ordering::AcqRel);
1083 ownership_journal
1084 .extend_from_slice(&reddb_file::page_ownership_journal::encode(record)?);
1085 }
1086 WalRecord::TxCommitBatch { actions, .. } => {
1087 for payload in actions {
1088 let action = StoreWalAction::decode(&payload)?;
1089 applied = true;
1090 store.apply_replayed_action(&action, false).map_err(|err| {
1091 io::Error::other(format!("failed to replay store wal action: {err}"))
1092 })?;
1093 store.remember_checkpoint_rows(vec![action], Default::default());
1097 }
1098 }
1099 WalRecord::Begin { tx_id } => {
1100 tx_states.insert(tx_id, false);
1101 }
1102 WalRecord::Commit { tx_id } => {
1103 tx_states.insert(tx_id, true);
1104 }
1105 WalRecord::Rollback { tx_id } => {
1106 tx_states.remove(&tx_id);
1107 }
1108 WalRecord::PageWrite {
1109 tx_id,
1110 page_id: _,
1111 data,
1112 } => pending.push((tx_id, data)),
1113 WalRecord::Checkpoint { .. } => {}
1114 WalRecord::VectorInsert {
1115 collection,
1116 entity_id,
1117 vector,
1118 } => {
1119 let mut map = store.replayed_turbo_inserts.lock();
1125 map.entry(collection).or_default().push((entity_id, vector));
1126 }
1127 WalRecord::ProbabilisticDelta {
1128 kind,
1129 operation,
1130 name,
1131 operands,
1132 } => {
1133 let mut deltas = store.replayed_probabilistic_deltas.lock();
1134 deltas.push((kind, operation, name, operands));
1135 }
1136 WalRecord::FullPageImage { .. } => {
1137 }
1139 }
1140 }
1141
1142 if incomplete_wal_tail && !ownership_journal.is_empty() {
1143 return Err(io::Error::new(
1144 io::ErrorKind::InvalidData,
1145 "incomplete WAL prevents ownership recovery",
1146 ));
1147 }
1148 self.recover_page_ownership(store, &ownership_journal)?;
1149
1150 for (tx_id, payload) in pending {
1151 if !tx_states.get(&tx_id).copied().unwrap_or(false) {
1152 continue;
1153 }
1154 let action = StoreWalAction::decode(&payload)?;
1155 applied = true;
1156 store.apply_replayed_action(&action, false).map_err(|err| {
1157 io::Error::other(format!("failed to replay store wal action: {err}"))
1158 })?;
1159 store.remember_checkpoint_rows(vec![action], Default::default());
1160 }
1161
1162 if applied && store.config.resident_memory_bytes_max.is_some() {
1163 return Ok(());
1167 }
1168 if applied {
1169 let epoch = latest_ownership_epoch
1172 .max(store.ownership_catalog_epoch.load(Ordering::Acquire))
1173 .checked_add(1)
1174 .ok_or_else(|| io::Error::other("page ownership epoch exhausted"))?;
1175 self.begin_page_ownership_epoch(store, epoch)?;
1176 store.mark_paged_registry_dirty();
1179 store
1180 .reconcile_replayed_btrees()
1181 .map_err(|err| io::Error::other(err.to_string()))?;
1182 self.finish_page_ownership_epoch(store, epoch)?;
1183 }
1184 Ok(())
1185 }
1186
1187 fn wait_until_durable(&self, target_lsn: u64, wal_bytes: u64) -> io::Result<()> {
1188 match self.mode {
1189 DurabilityMode::Strict => self.force_sync(),
1190 DurabilityMode::Async => {
1195 let (state_lock, cond) = &*self.state;
1196 let mut state = state_lock.lock();
1197 state.pending_target_lsn = state.pending_target_lsn.max(target_lsn);
1198 state.pending_statements = state.pending_statements.saturating_add(1);
1199 state.pending_wal_bytes = state.pending_wal_bytes.saturating_add(wal_bytes);
1200 state.first_pending_at.get_or_insert_with(Instant::now);
1201 cond.notify_all();
1202 Ok(())
1203 }
1204 DurabilityMode::WalDurableGrouped => {
1205 let (state_lock, cond) = &*self.state;
1206 let mut state = state_lock.lock();
1207 state.pending_target_lsn = state.pending_target_lsn.max(target_lsn);
1208 state.pending_statements = state.pending_statements.saturating_add(1);
1209 state.pending_wal_bytes = state.pending_wal_bytes.saturating_add(wal_bytes);
1210 state.first_pending_at.get_or_insert_with(Instant::now);
1211 cond.notify_all();
1212
1213 loop {
1214 if let Some(err) = state.last_error.clone() {
1215 return Err(io::Error::other(err));
1216 }
1217 if state.durable_lsn >= target_lsn {
1218 return Ok(());
1219 }
1220 cond.wait(&mut state);
1223 }
1224 }
1225 }
1226 }
1227
1228 fn run_group_commit_loop(
1229 wal: Arc<WalMutex>,
1230 queue: Arc<WalAppendQueue>,
1231 state: Arc<(CommitStateMutex, CommitStateCondvar)>,
1232 fsync_count: Arc<AtomicU64>,
1233 window: Duration,
1234 max_statements: usize,
1235 max_wal_bytes: u64,
1236 ) {
1237 let (state_lock, cond) = &*state;
1238 loop {
1239 let target_lsn = {
1240 let mut guard = state_lock.lock();
1241
1242 while !guard.shutdown && guard.pending_target_lsn <= guard.durable_lsn {
1243 cond.wait(&mut guard);
1244 }
1245
1246 if guard.shutdown {
1247 return;
1248 }
1249
1250 let immediate = window.is_zero()
1251 || guard.pending_statements >= max_statements
1252 || guard.pending_wal_bytes >= max_wal_bytes;
1253
1254 if !immediate {
1255 let deadline = guard.first_pending_at.unwrap_or_else(Instant::now) + window;
1256 let now = Instant::now();
1257 if now < deadline {
1258 let timeout = deadline.saturating_duration_since(now);
1259 let _ = cond.wait_for(&mut guard, timeout);
1260 if guard.shutdown {
1261 return;
1262 }
1263 if guard.pending_target_lsn <= guard.durable_lsn {
1264 continue;
1265 }
1266 let should_wait_again = guard.pending_statements < max_statements
1267 && guard.pending_wal_bytes < max_wal_bytes
1268 && guard
1269 .first_pending_at
1270 .map(|first| first.elapsed() < window)
1271 .unwrap_or(false);
1272 if should_wait_again {
1273 continue;
1274 }
1275 }
1276 }
1277
1278 guard.pending_target_lsn
1279 };
1280
1281 let _epoch = queue.epoch.read_recursive();
1286
1287 let batches = queue.drain_sorted();
1293
1294 let sync_result = {
1295 let mut wal = wal.lock();
1296 let mut write_err: Option<io::Error> = None;
1297 for (_lsn, bytes) in batches {
1298 if let Err(e) = wal.append_bytes(&bytes) {
1299 write_err = Some(e);
1300 break;
1301 }
1302 }
1303 match write_err {
1304 Some(e) => Err(e),
1305 None => wal.sync().map(|_| {
1306 fsync_count.fetch_add(1, Ordering::Relaxed);
1311 wal.durable_lsn()
1312 }),
1313 }
1314 };
1315
1316 let more_pending = queue.has_pending();
1321
1322 let mut guard = state_lock.lock();
1323 match sync_result {
1324 Ok(durable_lsn) => {
1325 guard.durable_lsn = durable_lsn;
1326 if !more_pending {
1327 guard.pending_statements = 0;
1328 guard.pending_wal_bytes = 0;
1329 guard.first_pending_at = None;
1330 }
1331 guard.last_error = None;
1332 let _ = target_lsn;
1333 }
1334 Err(err) => {
1335 guard.last_error = Some(err.to_string());
1336 }
1337 }
1338 cond.notify_all();
1339 }
1340 }
1341}
1342
1343impl Drop for StoreCommitCoordinator {
1344 fn drop(&mut self) {
1345 let (state_lock, cond) = &*self.state;
1346 let mut state = state_lock.lock();
1348 state.shutdown = true;
1349 cond.notify_all();
1350 }
1351}
1352
1353impl UnifiedStore {
1354 pub(crate) fn deferred_store_wal_capture_active() -> bool {
1355 deferred_store_wal_capture_active()
1356 }
1357
1358 pub(crate) fn begin_deferred_store_wal_capture() {
1359 begin_deferred_store_wal_capture();
1360 }
1361
1362 pub(crate) fn take_deferred_store_wal_capture() -> DeferredStoreWalActions {
1363 take_deferred_store_wal_capture()
1364 }
1365
1366 pub(crate) fn append_deferred_store_wal_actions(
1367 &self,
1368 actions: DeferredStoreWalActions,
1369 ) -> Result<(), StoreError> {
1370 if actions.actions.is_empty() {
1371 return Ok(());
1372 }
1373 #[cfg(test)]
1374 if self
1375 .test_require_deferred_publication_fence
1376 .swap(false, Ordering::AcqRel)
1377 && !self.vault_publication_lock.is_owned_by_current_thread()
1378 {
1379 return Err(StoreError::Io(io::Error::other(
1380 "deferred WAL missing checkpoint publication fence",
1381 )));
1382 }
1383 #[cfg(test)]
1384 if self
1385 .test_fail_deferred_wal_append
1386 .swap(false, Ordering::AcqRel)
1387 {
1388 return Err(StoreError::Io(io::Error::other(
1389 "injected deferred WAL append failure",
1390 )));
1391 }
1392 if self.config.embedded_wal_path.is_some() {
1393 return self.append_embedded_store_wal_actions(&actions.actions);
1394 }
1395 if let Some(commit) = &self.commit {
1398 let references = commit
1399 .append_checkpoint_actions(&actions.actions)
1400 .map_err(StoreError::Io)?;
1401 self.remember_checkpoint_rows(actions.actions, references);
1402 Ok(())
1403 } else {
1404 self.flush_paged_state()
1405 }
1406 }
1407
1408 pub(crate) fn append_vector_insert_record(
1415 &self,
1416 collection: &str,
1417 entity_id: u64,
1418 vector: &[f32],
1419 ) -> std::io::Result<()> {
1420 let Some(commit) = &self.commit else {
1421 return Ok(());
1422 };
1423 let record = WalRecord::VectorInsert {
1424 collection: collection.to_string(),
1425 entity_id,
1426 vector: vector.to_vec(),
1427 };
1428 commit.append_single_record(record)
1429 }
1430
1431 pub(crate) fn append_probabilistic_delta_record(
1432 &self,
1433 kind: u8,
1434 operation: u8,
1435 name: &str,
1436 operands: Vec<Vec<u8>>,
1437 ) -> std::io::Result<()> {
1438 let Some(commit) = &self.commit else {
1439 return Ok(());
1440 };
1441 let record = WalRecord::ProbabilisticDelta {
1442 kind,
1443 operation,
1444 name: name.to_string(),
1445 operands,
1446 };
1447 commit.append_single_record(record)
1448 }
1449
1450 pub(crate) fn finish_paged_write(
1451 &self,
1452 actions: impl IntoIterator<Item = StoreWalAction>,
1453 ) -> Result<(), StoreError> {
1454 self.finish_paged_write_for_projection(
1455 actions,
1456 self.config.resident_memory_bytes_max.is_some(),
1457 )
1458 }
1459
1460 pub(crate) fn finish_paged_write_for_projection(
1461 &self,
1462 actions: impl IntoIterator<Item = StoreWalAction>,
1463 track: bool,
1464 ) -> Result<(), StoreError> {
1465 let actions: Vec<StoreWalAction> = actions.into_iter().collect();
1466 if deferred_store_wal_capture_active() {
1467 let captured = capture_deferred_store_wal_actions(actions);
1468 debug_assert!(captured);
1469 return Ok(());
1470 }
1471 if self.config.embedded_wal_path.is_some() {
1472 return self.append_embedded_store_wal_actions(&actions);
1473 }
1474 let mut references = super::checkpoint_wal::CheckpointWalBatch::default();
1475 let result = match self.config.durability_mode {
1476 DurabilityMode::Strict => {
1477 if let Some(commit) = &self.commit {
1478 if track {
1479 references = commit
1480 .append_checkpoint_actions(&actions)
1481 .map_err(StoreError::Io)?;
1482 } else {
1483 commit.append_actions(&actions).map_err(StoreError::Io)?;
1484 }
1485 }
1486 self.flush_paged_state()
1487 }
1488 DurabilityMode::WalDurableGrouped | DurabilityMode::Async => {
1489 if let Some(commit) = &self.commit {
1490 if track {
1491 references = commit
1492 .append_checkpoint_actions(&actions)
1493 .map_err(StoreError::Io)?;
1494 } else {
1495 commit.append_actions(&actions).map_err(StoreError::Io)?;
1496 }
1497 Ok(())
1498 } else {
1499 self.flush_paged_state()
1500 }
1501 }
1502 };
1503 result?;
1504 if track {
1505 self.remember_checkpoint_rows(actions, references);
1506 }
1507 Ok(())
1508 }
1509
1510 pub(crate) fn apply_encoded_store_wal_action(&self, payload: &[u8]) -> Result<(), StoreError> {
1511 let action = StoreWalAction::decode(payload).map_err(StoreError::Io)?;
1512 self.apply_replayed_action(&action, true)
1513 }
1514
1515 fn append_embedded_store_wal_actions(
1516 &self,
1517 actions: &[StoreWalAction],
1518 ) -> Result<(), StoreError> {
1519 let Some(path) = self.config.embedded_wal_path.as_deref() else {
1520 return Ok(());
1521 };
1522 if actions.is_empty() {
1523 return Ok(());
1524 }
1525 let payloads: Vec<Vec<u8>> = actions.iter().map(StoreWalAction::encode).collect();
1526 let encoded_len = crate::storage::EmbeddedRdbArtifact::wal_payloads_encoded_len(&payloads)
1527 .map_err(|err| StoreError::Io(std::io::Error::other(err.to_string())))?;
1528 match self.append_embedded_payloads(path, &payloads) {
1529 Ok(_) => Ok(()),
1530 Err(crate::api::RedDBError::InvalidOperation(msg))
1531 if msg.contains("embedded wal region full") =>
1532 {
1533 self.recover_full_embedded_wal(path, &payloads, encoded_len)
1534 }
1535 Err(err) => Err(StoreError::Io(std::io::Error::other(err.to_string()))),
1536 }
1537 }
1538
1539 fn recover_full_embedded_wal(
1540 &self,
1541 path: &Path,
1542 payloads: &[Vec<u8>],
1543 encoded_len: u64,
1544 ) -> Result<(), StoreError> {
1545 let _vault_guard = self.vault_publication_lock.lock();
1546 match self.append_embedded_payloads(path, payloads) {
1550 Ok(_) => return Ok(()),
1551 Err(crate::api::RedDBError::InvalidOperation(message))
1552 if message.contains("embedded wal region full") => {}
1553 Err(error) => return Err(StoreError::Io(std::io::Error::other(error.to_string()))),
1554 }
1555 struct CheckpointFence<'a>(Option<&'a crate::storage::embedded::EmbeddedCommitSession>);
1559 impl Drop for CheckpointFence<'_> {
1560 fn drop(&mut self) {
1561 if let Some(session) = self.0 {
1562 session.fence_interrupted_write();
1563 }
1564 }
1565 }
1566 let mut fence = CheckpointFence(Some(
1567 self.embedded_commit_session
1568 .get()
1569 .expect("WAL-full recovery follows an append attempt"),
1570 ));
1571 let started = Instant::now();
1572 let snapshot = self.to_binary_dump_bytes();
1573 let capacity = encoded_len.max((snapshot.len() as u64).min(EMBEDDED_WAL_HEADROOM_LIMIT));
1574 let checkpoint = crate::storage::EmbeddedRdbArtifact::write_snapshot_with_wal_capacity(
1575 path, &snapshot, capacity,
1576 )
1577 .map_err(|err| StoreError::Io(std::io::Error::other(err.to_string())))?;
1578 fence.0 = None;
1579 tracing::debug!(
1580 target: "reddb::embedded_checkpoint",
1581 snapshot_bytes = snapshot.len(),
1582 wal_region_bytes = checkpoint.manifest.wal_region_bytes,
1583 elapsed_us = started.elapsed().as_micros() as u64,
1584 "embedded WAL overflow checkpoint"
1585 );
1586 self.append_embedded_payloads(path, payloads)
1587 .map(|_| ())
1588 .map_err(|err| StoreError::Io(std::io::Error::other(err.to_string())))
1589 }
1590
1591 fn append_embedded_payloads(
1592 &self,
1593 path: &Path,
1594 payloads: &[Vec<u8>],
1595 ) -> crate::RedDBResult<crate::storage::EmbeddedRdbOpen> {
1596 let session = self
1597 .embedded_commit_session
1598 .get_or_init(|| crate::storage::EmbeddedRdbArtifact::group_commit_session(path));
1599 let options = self.config.group_commit;
1600 let grouped = self.config.durability_mode == DurabilityMode::WalDurableGrouped;
1601 let window = if grouped {
1602 StoreCommitCoordinator::resolve_window(&options)
1603 } else {
1604 Duration::ZERO
1605 };
1606 session.append(
1607 payloads,
1608 reddb_file::embedded::EmbeddedGroupCommitOptions {
1609 window,
1610 max_statements: if grouped { options.max_statements } else { 1 },
1611 max_wal_bytes: options.max_wal_bytes,
1612 },
1613 )
1614 }
1615
1616 pub(super) fn enqueue_grouped_actions(
1617 &self,
1618 actions: &[StoreWalAction],
1619 ) -> Result<Option<crate::storage::embedded::PendingEmbeddedAppend>, StoreError> {
1620 if self.config.durability_mode != DurabilityMode::WalDurableGrouped
1621 || deferred_store_wal_capture_active()
1622 {
1623 return Ok(None);
1624 }
1625 let Some(path) = self.config.embedded_wal_path.as_deref() else {
1626 return Ok(None);
1627 };
1628 let session = self
1629 .embedded_commit_session
1630 .get_or_init(|| crate::storage::EmbeddedRdbArtifact::group_commit_session(path));
1631 let payloads: Vec<Vec<u8>> = actions.iter().map(StoreWalAction::encode).collect();
1632 let options = self.config.group_commit;
1633 session
1634 .enqueue(
1635 &payloads,
1636 reddb_file::embedded::EmbeddedGroupCommitOptions {
1637 window: StoreCommitCoordinator::resolve_window(&options),
1638 max_statements: options.max_statements,
1639 max_wal_bytes: options.max_wal_bytes,
1640 },
1641 )
1642 .map(Some)
1643 .map_err(|error| StoreError::Io(io::Error::other(error.to_string())))
1644 }
1645
1646 pub(super) fn complete_grouped_actions(
1647 &self,
1648 pending: &crate::storage::embedded::PendingEmbeddedAppend,
1649 actions: &[StoreWalAction],
1650 ) -> Result<(), StoreError> {
1651 match pending.wait() {
1652 Ok(_) => Ok(()),
1653 Err(crate::RedDBError::InvalidOperation(message))
1654 if message.contains("embedded wal region full") =>
1655 {
1656 self.append_embedded_store_wal_actions(actions)
1657 }
1658 Err(error) => Err(StoreError::Io(io::Error::other(error.to_string()))),
1659 }
1660 }
1661
1662 pub(crate) fn apply_replayed_action(
1663 &self,
1664 action: &StoreWalAction,
1665 update_btrees: bool,
1666 ) -> Result<(), StoreError> {
1667 reddb_engine::storage::store_wal::apply_replayed_action(
1668 &NativeReplayTarget {
1669 store: self,
1670 update_btrees,
1671 },
1672 action,
1673 )
1674 }
1675
1676 pub fn refresh_collection(
1693 &self,
1694 name: &str,
1695 entities: Vec<UnifiedEntity>,
1696 ) -> Result<Vec<Vec<u8>>, StoreError> {
1697 let fv = self.format_version();
1698
1699 let new_manager = Arc::new(SegmentManager::with_config(
1702 name,
1703 self.config.manager_config.clone(),
1704 ));
1705
1706 let mut prepared = entities;
1707 for entity in &mut prepared {
1708 if entity.id.raw() == 0 {
1709 entity.id = self.next_entity_id();
1710 } else {
1711 self.register_entity_id(entity.id);
1712 }
1713 if let EntityKind::TableRow { ref mut row_id, .. } = entity.kind {
1714 if *row_id == 0 {
1715 *row_id = new_manager.next_row_id();
1716 } else {
1717 new_manager.register_row_id(*row_id);
1718 }
1719 }
1720 entity.ensure_table_logical_id();
1721 }
1722
1723 let serialized: Vec<Vec<u8>> = prepared
1724 .iter()
1725 .map(|e| Self::serialize_entity_record(e, None, fv))
1726 .collect();
1727
1728 new_manager.bulk_insert(prepared.clone()).map_err(|e| {
1729 StoreError::Io(std::io::Error::other(format!(
1730 "refresh_collection: bulk_insert into new manager failed: {e}"
1731 )))
1732 })?;
1733
1734 self.swap_collection_state(name, new_manager, &prepared, &serialized, true);
1735
1736 self.finish_paged_write([StoreWalAction::RefreshCollection {
1737 collection: name.to_string(),
1738 records: serialized.clone(),
1739 }])?;
1740
1741 Ok(serialized)
1742 }
1743
1744 fn swap_collection_state(
1750 &self,
1751 name: &str,
1752 new_manager: Arc<SegmentManager>,
1753 prepared: &[UnifiedEntity],
1754 serialized: &[Vec<u8>],
1755 update_btrees: bool,
1756 ) {
1757 self.resident
1758 .swap_collection_state(name, new_manager, prepared);
1759
1760 if let Some(pager) = self
1761 .pager
1762 .as_ref()
1763 .filter(|_| update_btrees && self.config.resident_memory_bytes_max.is_none())
1764 {
1765 let new_btree = Arc::new(BTree::new(Arc::clone(pager)));
1766 let mut sorted: Vec<(Vec<u8>, Vec<u8>)> = prepared
1767 .iter()
1768 .zip(serialized.iter())
1769 .map(|(e, r)| (e.id.raw().to_be_bytes().to_vec(), r.clone()))
1770 .collect();
1771 sorted.sort_by(|a, b| a.0.cmp(&b.0));
1772 if !sorted.is_empty() {
1773 let _ = new_btree.bulk_insert_sorted(&sorted);
1774 }
1775 self.btree_indices
1776 .write()
1777 .insert(name.to_string(), new_btree);
1778 self.mark_paged_registry_dirty();
1779 }
1780 }
1781
1782 fn apply_replayed_refresh_collection(
1783 &self,
1784 collection: &str,
1785 records: &[Vec<u8>],
1786 update_btrees: bool,
1787 ) -> Result<(), StoreError> {
1788 let (new_manager, prepared) = self.resident.prepare_replayed_refresh_collection(
1789 collection,
1790 records,
1791 self.format_version(),
1792 &self.config.manager_config,
1793 )?;
1794 self.swap_collection_state(collection, new_manager, &prepared, records, update_btrees);
1795 Ok(())
1796 }
1797
1798 pub fn refresh_collection_from_records(
1812 &self,
1813 name: &str,
1814 records: Vec<Vec<u8>>,
1815 ) -> Result<(), StoreError> {
1816 self.apply_replayed_refresh_collection(name, &records, true)?;
1817 self.finish_paged_write([StoreWalAction::RefreshCollection {
1818 collection: name.to_string(),
1819 records,
1820 }])?;
1821 Ok(())
1822 }
1823
1824 pub(crate) fn create_collection_in_memory(&self, name: &str) -> Result<(), StoreError> {
1825 self.resident
1826 .create_collection_in_memory(name, &self.config.manager_config)
1827 }
1828
1829 fn drop_collection_in_memory(&self, name: &str) -> Result<(), StoreError> {
1830 self.resident.drop_collection_in_memory(name, || {
1831 if let Some(tree) = self.btree_indices.write().remove(name) {
1832 self.retire_btree(tree);
1833 }
1834 })
1835 }
1836
1837 fn apply_replayed_upsert(
1838 &self,
1839 collection: &str,
1840 record: &[u8],
1841 update_btrees: bool,
1842 ) -> Result<(), StoreError> {
1843 let id = self.resident.apply_replayed_upsert(
1844 collection,
1845 record,
1846 self.format_version(),
1847 &self.config.manager_config,
1848 )?;
1849
1850 if let Some(pager) = self
1851 .pager
1852 .as_ref()
1853 .filter(|_| update_btrees && self.config.resident_memory_bytes_max.is_none())
1854 {
1855 let mut btree_indices = self.btree_indices.write();
1856 let btree = btree_indices
1857 .entry(collection.to_string())
1858 .or_insert_with(|| Arc::new(BTree::new(Arc::clone(pager))));
1859 let root_before = btree.root_page_id();
1860 let key = id.raw().to_be_bytes();
1861 match btree.insert(&key, record) {
1862 Ok(_) => {}
1863 Err(BTreeError::DuplicateKey) => {
1864 let _ = btree.delete(&key);
1865 let _ = btree.insert(&key, record);
1866 }
1867 Err(err) => {
1868 return Err(StoreError::Io(io::Error::other(format!(
1869 "replay upsert btree error: {err}"
1870 ))));
1871 }
1872 }
1873 if root_before != btree.root_page_id() {
1874 self.mark_paged_registry_dirty();
1875 }
1876 }
1877
1878 Ok(())
1879 }
1880
1881 fn apply_replayed_delete(
1882 &self,
1883 collection: &str,
1884 id: EntityId,
1885 update_btrees: bool,
1886 ) -> Result<(), StoreError> {
1887 self.resident.apply_replayed_delete(collection, id, || {
1888 if self.pager.is_some()
1889 && update_btrees
1890 && self.config.resident_memory_bytes_max.is_none()
1891 {
1892 let btree_indices = self.btree_indices.read();
1893 if let Some(btree) = btree_indices.get(collection) {
1894 let root_before = btree.root_page_id();
1895 let key = id.raw().to_be_bytes();
1896 let _ = btree.delete(&key);
1897 if root_before != btree.root_page_id() {
1898 self.mark_paged_registry_dirty();
1899 }
1900 }
1901 }
1902 })
1903 }
1904}
1905
1906#[cfg(all(test, target_os = "linux"))]
1907#[path = "storage_apocalypse.rs"]
1908mod storage_apocalypse;
1909
1910#[cfg(test)]
1911mod tests {
1912 use super::*;
1913 use crate::api::{DurabilityMode, GroupCommitOptions};
1914 use std::sync::{Barrier, Mutex as StdMutex, OnceLock};
1915 use std::time::SystemTime;
1916
1917 #[test]
1918 fn ownership_epoch_rejects_nested_begin_and_wrong_finish() {
1919 let directory = tempfile::tempdir().expect("temporary store");
1920 let store = UnifiedStore::open(directory.path().join("data.rdb")).expect("store");
1921 store.create_collection("rows").expect("collection");
1922 store.persist().expect("baseline");
1923 let coordinator = store.commit.as_ref().expect("WAL coordinator");
1924 assert!(coordinator.begin_page_ownership_epoch(&store, 0).is_err());
1925 assert!(coordinator.finish_page_ownership_epoch(&store, 1).is_err());
1926 coordinator
1927 .begin_page_ownership_epoch(&store, 2)
1928 .expect("begin epoch");
1929 assert!(coordinator.begin_page_ownership_epoch(&store, 3).is_err());
1930 assert!(coordinator.finish_page_ownership_epoch(&store, 3).is_err());
1931 assert!(coordinator.finish_page_ownership_epoch(&store, 2).is_err());
1932 store.flush_paged_registry().expect("publish epoch");
1933 coordinator
1934 .finish_page_ownership_epoch(&store, 2)
1935 .expect("finish epoch");
1936 assert_eq!(store.ownership_active_epoch.load(Ordering::Acquire), 0);
1937 }
1938
1939 #[test]
1940 fn native_pager_records_page_ownership_in_durable_wal() {
1941 use reddb_file::page_ownership_journal::PageOwnershipRecord;
1942
1943 let directory = tempfile::tempdir().expect("temporary store");
1944 let path = directory.path().join("data.rdb");
1945 let wal_path = reddb_file::layout::unified_wal_path(&path);
1946 let pager = crate::storage::engine::Pager::open(&path, Default::default())
1947 .expect("open native pager");
1948 let coordinator = Arc::new(
1949 StoreCommitCoordinator::open(
1950 &wal_path,
1951 DurabilityMode::Async,
1952 GroupCommitOptions::default(),
1953 )
1954 .expect("open WAL"),
1955 );
1956 let recorder = Arc::new(
1957 StoreOwnershipRecorder::begin(Arc::clone(&coordinator), 7).expect("begin epoch"),
1958 );
1959 pager
1960 .install_ownership_recorder(recorder)
1961 .expect("install recorder");
1962 let page = pager
1963 .allocate_page(crate::storage::engine::PageType::BTreeLeaf)
1964 .expect("allocate")
1965 .page_id();
1966 pager
1967 .record_retirement_intent(&[page])
1968 .expect("record retirement");
1969 pager.free_retired_pages(&[page]).expect("retire");
1970 let records: Vec<_> = WalReader::open(&wal_path)
1971 .expect("read WAL")
1972 .iter()
1973 .map(|item| item.expect("WAL record").1)
1974 .collect();
1975 assert_eq!(
1976 records,
1977 vec![
1978 WalRecord::PageOwnership {
1979 record: PageOwnershipRecord::Begin { epoch: 7 }
1980 },
1981 WalRecord::PageOwnership {
1982 record: PageOwnershipRecord::Reserve {
1983 epoch: 7,
1984 page_id: page
1985 }
1986 },
1987 WalRecord::PageOwnership {
1988 record: PageOwnershipRecord::Retire {
1989 epoch: 7,
1990 page_id: page
1991 }
1992 },
1993 WalRecord::PageOwnership {
1994 record: PageOwnershipRecord::Retire {
1995 epoch: 7,
1996 page_id: page
1997 }
1998 },
1999 ]
2000 );
2001 assert!(
2002 coordinator.fsync_count() >= 3,
2003 "async ownership writes must sync"
2004 );
2005 assert!(
2006 coordinator.truncate().is_err(),
2007 "ownership WAL remains protected"
2008 );
2009 }
2010
2011 #[test]
2012 fn replay_reclaims_unpublished_ownership_reservations() {
2013 use reddb_file::page_ownership_journal::PageOwnershipRecord;
2014
2015 let directory = tempfile::tempdir().expect("temporary store");
2016 let path = directory.path().join("data.rdb");
2017 {
2018 let store = UnifiedStore::open(&path).expect("create store");
2019 store.create_collection("rows").expect("collection");
2020 store.persist().expect("persist baseline");
2021 }
2022 let wal_path = reddb_file::layout::unified_wal_path(&path);
2023 let ids;
2024 {
2025 let pager =
2026 crate::storage::engine::Pager::open(&path, Default::default()).expect("pager");
2027 let first = pager.page_count().expect("page count");
2028 let coordinator = StoreCommitCoordinator::open(
2029 &wal_path,
2030 DurabilityMode::Strict,
2031 GroupCommitOptions::default(),
2032 )
2033 .expect("WAL");
2034 coordinator
2035 .append_durable_page_ownership_batch(&[
2036 PageOwnershipRecord::Begin { epoch: 2 },
2037 PageOwnershipRecord::Reserve {
2038 epoch: 2,
2039 page_id: first,
2040 },
2041 PageOwnershipRecord::Reserve {
2042 epoch: 2,
2043 page_id: first + 1,
2044 },
2045 ])
2046 .expect("reserve pages durably");
2047 ids = [
2048 pager
2049 .allocate_page(crate::storage::engine::PageType::BTreeLeaf)
2050 .expect("allocate")
2051 .page_id(),
2052 pager
2053 .allocate_page(crate::storage::engine::PageType::BTreeLeaf)
2054 .expect("allocate")
2055 .page_id(),
2056 ];
2057 assert_eq!(ids, [first, first + 1]);
2058 pager.sync().expect("persist abandoned pages");
2059 }
2060 let reopened = UnifiedStore::open(&path).expect("recover ownership");
2061 let pager = reopened.pager.as_ref().expect("pager");
2062 let a = pager
2063 .allocate_page(crate::storage::engine::PageType::BTreeLeaf)
2064 .expect("reuse")
2065 .page_id();
2066 let b = pager
2067 .allocate_page(crate::storage::engine::PageType::BTreeLeaf)
2068 .expect("reuse")
2069 .page_id();
2070 assert_ne!(a, b);
2071 assert!(ids.contains(&a) && ids.contains(&b));
2072 let records: Vec<_> = WalReader::open(&wal_path)
2073 .expect("read WAL")
2074 .iter()
2075 .collect::<io::Result<Vec<_>>>()
2076 .expect("records");
2077 assert!(matches!(
2078 records.last(),
2079 Some((
2080 _,
2081 WalRecord::PageOwnership {
2082 record: PageOwnershipRecord::Finish { epoch: 2 }
2083 }
2084 ))
2085 ));
2086 reopened
2087 .commit
2088 .as_ref()
2089 .expect("coordinator")
2090 .truncate()
2091 .expect("completed ownership can be truncated");
2092 }
2093
2094 #[cfg(target_os = "linux")]
2095 #[test]
2096 fn ownership_recovery_survives_interruption_and_retry() {
2097 use reddb_file::page_ownership_journal::PageOwnershipRecord;
2098
2099 const CHILD: &str = "REDDB_EXPERIMENTAL_OWNERSHIP_RECOVERY_PATH";
2100 if let Some(path) = std::env::var_os(CHILD) {
2101 let _store = UnifiedStore::open(path).expect("recover store");
2102 panic!("recovery interruption hook was not reached");
2103 }
2104 for (published, hook, exit_code) in [
2105 (
2106 false,
2107 "REDDB_EXPERIMENTAL_EXIT_DURING_OWNERSHIP_RECOVERY",
2108 187,
2109 ),
2110 (false, "REDDB_EXPERIMENTAL_EXIT_AFTER_OWNERSHIP_SYNC", 188),
2111 (false, "REDDB_EXPERIMENTAL_EXIT_AFTER_OWNERSHIP_FINISH", 189),
2112 (
2113 true,
2114 "REDDB_EXPERIMENTAL_EXIT_DURING_OWNERSHIP_RECOVERY",
2115 187,
2116 ),
2117 (true, "REDDB_EXPERIMENTAL_EXIT_AFTER_OWNERSHIP_SYNC", 188),
2118 (true, "REDDB_EXPERIMENTAL_EXIT_AFTER_OWNERSHIP_FINISH", 189),
2119 ] {
2120 let directory = tempfile::tempdir().expect("temporary store");
2121 let path = directory.path().join("data.rdb");
2122 {
2123 let store = UnifiedStore::open(&path).expect("create store");
2124 store.create_collection("rows").expect("collection");
2125 store.persist().expect("baseline");
2126 if published {
2127 store.ownership_active_epoch.store(2, Ordering::Release);
2128 store.flush_paged_registry().expect("publish epoch");
2129 store.ownership_active_epoch.store(0, Ordering::Release);
2130 }
2131 }
2132 let wal_path = reddb_file::layout::unified_wal_path(&path);
2133 let ids;
2134 {
2135 let pager =
2136 crate::storage::engine::Pager::open(&path, Default::default()).expect("pager");
2137 let first = pager.page_count().expect("page count");
2138 let coordinator = StoreCommitCoordinator::open(
2139 &wal_path,
2140 DurabilityMode::Strict,
2141 GroupCommitOptions::default(),
2142 )
2143 .expect("WAL");
2144 let intent = |page_id| {
2145 if published {
2146 PageOwnershipRecord::Retire { epoch: 2, page_id }
2147 } else {
2148 PageOwnershipRecord::Reserve { epoch: 2, page_id }
2149 }
2150 };
2151 coordinator
2152 .append_durable_page_ownership_batch(&[
2153 PageOwnershipRecord::Begin { epoch: 2 },
2154 intent(first),
2155 intent(first + 1),
2156 ])
2157 .expect("reserve");
2158 ids = [
2159 pager
2160 .allocate_page(crate::storage::engine::PageType::BTreeLeaf)
2161 .expect("allocate")
2162 .page_id(),
2163 pager
2164 .allocate_page(crate::storage::engine::PageType::BTreeLeaf)
2165 .expect("allocate")
2166 .page_id(),
2167 ];
2168 pager.sync().expect("persist abandoned pages");
2169 }
2170 for _ in 0..if exit_code == 189 { 1 } else { 2 } {
2171 let output = std::process::Command::new(std::env::current_exe().expect("test executable"))
2172 .args(["--exact", "storage::unified::store::commit::tests::ownership_recovery_survives_interruption_and_retry", "--nocapture"])
2173 .env(CHILD, &path)
2174 .env(hook, "1")
2175 .output().expect("run interrupted recovery");
2176 assert_eq!(
2177 output.status.code(),
2178 Some(exit_code),
2179 "published={published} {hook}: {}",
2180 String::from_utf8_lossy(&output.stderr)
2181 );
2182 }
2183 let reopened = UnifiedStore::open(&path).expect("recover after interruptions");
2184 let pager = reopened.pager.as_ref().expect("pager");
2185 let a = pager
2186 .allocate_page(crate::storage::engine::PageType::BTreeLeaf)
2187 .expect("reuse")
2188 .page_id();
2189 let b = pager
2190 .allocate_page(crate::storage::engine::PageType::BTreeLeaf)
2191 .expect("reuse")
2192 .page_id();
2193 assert_ne!(a, b, "published={published} {hook}: duplicate page");
2194 assert!(
2195 ids.contains(&a) && ids.contains(&b),
2196 "published={published} {hook}: lost recovered page"
2197 );
2198 }
2199 }
2200
2201 #[test]
2202 fn ownership_intents_are_durable_in_every_mode() {
2203 use reddb_file::page_ownership_journal::PageOwnershipRecord;
2204
2205 for mode in [
2206 DurabilityMode::Strict,
2207 DurabilityMode::WalDurableGrouped,
2208 DurabilityMode::Async,
2209 ] {
2210 let directory = tempfile::tempdir().expect("temporary WAL");
2211 let path = directory.path().join("ownership");
2212 let coordinator =
2213 StoreCommitCoordinator::open(&path, mode, GroupCommitOptions::default())
2214 .expect("open coordinator");
2215 let intent = PageOwnershipRecord::Reserve {
2216 epoch: 1,
2217 page_id: 67,
2218 };
2219 let detach = PageOwnershipRecord::DetachFree {
2220 epoch: 1,
2221 page_id: 68,
2222 };
2223 coordinator
2224 .append_durable_page_ownership_batch(&[intent, detach])
2225 .expect("durable intent");
2226 assert!(
2227 coordinator.fsync_count() > 0,
2228 "ownership intent must be synced in {mode:?}"
2229 );
2230 let records: Vec<_> = WalReader::open(&path)
2231 .expect("open WAL")
2232 .iter()
2233 .collect::<io::Result<Vec<_>>>()
2234 .expect("read records");
2235 assert!(
2236 matches!(records.as_slice(), [(_, WalRecord::PageOwnership { record: first }), (_, WalRecord::PageOwnership { record: second })] if *first == intent && *second == detach)
2237 );
2238 assert!(coordinator
2239 .append_single_record(WalRecord::PageOwnership { record: intent })
2240 .is_err());
2241 assert!(coordinator
2242 .append_durable_page_ownership(PageOwnershipRecord::Begin { epoch: 0 })
2243 .is_err());
2244 assert!(
2245 coordinator.truncate().is_err(),
2246 "active ownership must prevent truncation"
2247 );
2248 let before = std::fs::read(&path).expect("ownership WAL");
2249 drop(coordinator);
2250 let reopened = StoreCommitCoordinator::open(&path, mode, GroupCommitOptions::default())
2251 .expect("reopen coordinator");
2252 assert!(
2253 reopened.truncate().is_err(),
2254 "reopen must rediscover ownership records"
2255 );
2256 assert_eq!(std::fs::read(&path).expect("preserved WAL"), before);
2257 }
2258 }
2259
2260 #[test]
2261 fn unresolved_ownership_intent_blocks_store_replay() {
2262 for truncated in [false, true] {
2263 let directory = tempfile::tempdir().expect("temporary store");
2264 let path = directory.path().join("data.rdb");
2265 {
2266 let store = UnifiedStore::open(&path).expect("create store");
2267 store.create_collection("rows").expect("create collection");
2268 store.persist().expect("persist baseline");
2269 }
2270 let wal_path = reddb_file::layout::unified_wal_path(&path);
2271 {
2272 let mut writer = WalWriter::open(&wal_path).expect("open WAL");
2273 writer
2274 .append(&WalRecord::PageOwnership {
2275 record: reddb_file::page_ownership_journal::PageOwnershipRecord::Reserve {
2276 epoch: 1,
2277 page_id: 67,
2278 },
2279 })
2280 .expect("append ownership intent");
2281 writer.sync().expect("sync ownership intent");
2282 }
2283 if truncated {
2284 let wal = std::fs::OpenOptions::new()
2285 .write(true)
2286 .open(&wal_path)
2287 .expect("WAL");
2288 let length = wal.metadata().expect("length").len();
2289 wal.set_len(length - 1).expect("truncate last byte");
2290 wal.sync_all().expect("sync truncation");
2291 }
2292 let before = std::fs::read(&path).expect("baseline data");
2293 let error = match UnifiedStore::open(&path) {
2294 Ok(_) => panic!("unresolved ownership must prevent opening"),
2295 Err(error) => error,
2296 };
2297 let expected = if truncated {
2298 "incomplete page ownership WAL record"
2299 } else {
2300 "page ownership journal requires recovery"
2301 };
2302 assert!(error.to_string().contains(expected), "{error}");
2303 assert_eq!(std::fs::read(&path).expect("preserved data"), before);
2304 }
2305 }
2306
2307 #[test]
2308 fn logical_wal_replay_restores_a_deleted_physical_identity() {
2309 for flat in [false, true] {
2310 let store = UnifiedStore::new();
2311 store.create_collection("replay").expect("collection");
2312 let id = EntityId::new(42);
2313 let entity = UnifiedEntity::table_row(id, "replay", 42, vec![Value::Integer(1)]);
2314 if flat {
2315 store
2316 .bulk_insert("replay", vec![entity])
2317 .expect("flat seed");
2318 } else {
2319 store.insert_auto("replay", entity).expect("hash seed");
2320 }
2321 let metadata = Metadata::with_fields(HashMap::from([(
2322 "source".into(),
2323 MetadataValue::String("wal".into()),
2324 )]));
2325 for sequence in 100..104 {
2326 let delete = StoreWalAction::DeleteEntityRecord {
2327 collection: "replay".into(),
2328 entity_id: id.raw(),
2329 };
2330 store
2331 .apply_encoded_store_wal_action(&delete.encode())
2332 .expect("replay delete");
2333 let mut restored =
2334 UnifiedEntity::table_row(id, "replay", 42, vec![Value::Integer(2)]);
2335 restored.ensure_table_logical_id();
2336 restored.sequence_id = sequence;
2337 let upsert = StoreWalAction::upsert_entity(
2338 "replay",
2339 &restored,
2340 Some(&metadata),
2341 store.format_version(),
2342 );
2343 store
2344 .apply_encoded_store_wal_action(&upsert.encode())
2345 .expect("replay upsert");
2346 let manager = store.get_collection("replay").expect("restored collection");
2347 let stored = manager.get(id).expect("restored entity is readable");
2348 assert_eq!(store.entity_cache_stats().entries, 0);
2349 assert_eq!(manager.count(), 1);
2350 assert_eq!(manager.query_all(|_| true).len(), 1);
2351 assert_eq!(stored.sequence_id, sequence);
2352 assert_eq!(
2353 UnifiedStore::serialize_entity_record(
2354 &stored,
2355 manager.get_metadata(id).as_ref(),
2356 store.format_version()
2357 ),
2358 UnifiedStore::serialize_entity_record(
2359 &restored,
2360 Some(&metadata),
2361 store.format_version()
2362 )
2363 );
2364 assert!(store.get_any(id).is_some());
2365 }
2366 }
2367 }
2368
2369 fn embedded_fixture(
2370 mode: DurabilityMode,
2371 background_bytes: usize,
2372 ) -> (tempfile::TempDir, PathBuf, UnifiedStore) {
2373 let directory = tempfile::tempdir().expect("directory");
2374 let path = directory.path().join("data.rdb");
2375 let mut store =
2376 UnifiedStore::with_config(UnifiedStoreConfig::default().with_durability_mode(mode));
2377 store.create_collection("keys").expect("collection");
2378 store.set_aux_metadata(vec![42; background_bytes]);
2379 crate::storage::EmbeddedRdbArtifact::create_with_snapshot(
2380 &path,
2381 &store.to_binary_dump_bytes(),
2382 )
2383 .expect("snapshot");
2384 store.config.embedded_wal_path = Some(path.clone());
2385 (directory, path, store)
2386 }
2387
2388 #[test]
2389 fn embedded_stale_full_result_does_not_checkpoint_reclaimed_space() {
2390 for mode in [DurabilityMode::Strict, DurabilityMode::WalDurableGrouped] {
2391 let (_directory, path, store) = embedded_fixture(mode, 0);
2392 store
2393 .append_embedded_store_wal_actions(&[StoreWalAction::CreateCollection {
2394 name: "keys".into(),
2395 }])
2396 .expect("another writer appended after reclaiming space");
2397 let before = crate::storage::EmbeddedRdbArtifact::open(&path).expect("before");
2398 let payloads = vec![StoreWalAction::CreateCollection {
2399 name: "after".into(),
2400 }
2401 .encode()];
2402 let length = crate::storage::EmbeddedRdbArtifact::wal_payloads_encoded_len(&payloads)
2403 .expect("encoded length");
2404 store
2406 .recover_full_embedded_wal(&path, &payloads, length)
2407 .expect("revalidate after acquiring publication");
2408 let after = crate::storage::EmbeddedRdbArtifact::open(&path).expect("after");
2409 assert_eq!(
2410 after.selected_superblock.generation,
2411 before.selected_superblock.generation + 1,
2412 "only the retry is published, without an unnecessary checkpoint"
2413 );
2414 assert_eq!(
2415 crate::storage::EmbeddedRdbArtifact::read_wal_payloads(&after)
2416 .expect("both appends retained")
2417 .len(),
2418 2
2419 );
2420 }
2421 }
2422
2423 #[test]
2424 fn embedded_full_wal_reserves_bounded_headroom_for_populated_store() {
2425 for mode in [DurabilityMode::Strict, DurabilityMode::WalDurableGrouped] {
2426 let (_directory, path, store) = embedded_fixture(mode, 2 * 1024 * 1024);
2427 let initial = crate::storage::EmbeddedRdbArtifact::open(&path).expect("initial");
2428 let action = StoreWalAction::CreateCollection {
2429 name: "keys".into(),
2430 };
2431 let payloads = vec![action.encode()];
2432 let length = crate::storage::EmbeddedRdbArtifact::wal_payloads_encoded_len(&payloads)
2433 .expect("encoded length");
2434 crate::storage::EmbeddedRdbArtifact::append_wal_payloads(
2435 &path,
2436 &vec![payloads[0].clone(); (initial.manifest.wal_region_bytes / length) as usize],
2437 )
2438 .expect("fill the initial region with valid records");
2439 store
2440 .append_embedded_store_wal_actions(&[action])
2441 .expect("overflow");
2442 let open = crate::storage::EmbeddedRdbArtifact::open(&path).expect("checkpoint");
2443 assert_eq!(open.manifest.wal_region_bytes, 1024 * 1024);
2444 let snapshot = crate::storage::EmbeddedRdbArtifact::read_snapshot(&open)
2445 .expect("snapshot")
2446 .expect("populated image");
2447 let recovered =
2448 UnifiedStore::load_from_bytes_with_config(&snapshot, UnifiedStoreConfig::default())
2449 .expect("recover snapshot");
2450 for payload in
2451 crate::storage::EmbeddedRdbArtifact::read_wal_payloads(&open).expect("WAL")
2452 {
2453 recovered
2454 .apply_encoded_store_wal_action(&payload)
2455 .expect("replay");
2456 }
2457 assert_eq!(
2458 recovered.resident.aux_metadata.read().len(),
2459 2 * 1024 * 1024
2460 );
2461 assert!(recovered.get_collection("keys").is_some());
2462 }
2463 }
2464
2465 #[test]
2466 fn embedded_grouped_overwrites_recover_across_repeated_full_wal_regions() {
2467 fn row(writer: usize, round: usize) -> UnifiedEntity {
2468 let mut state = (writer as u64 + 1) * 31 + round as u64 * 13;
2469 let payload: String = (0..4096)
2470 .map(|_| {
2471 state = state.wrapping_mul(1664525).wrapping_add(1013904223);
2472 char::from(b'!' + ((state >> 24) % 90) as u8)
2473 })
2474 .collect();
2475 let mut entity = UnifiedEntity::table_row(EntityId::new(0), "keys", 0, vec![]);
2476 entity.data = EntityData::Row(RowData::with_names(
2477 vec![
2478 Value::text(format!("k{writer}")),
2479 Value::text(format!("{round}:{payload}")),
2480 ],
2481 vec!["key".into(), "value".into()],
2482 ));
2483 entity
2484 }
2485 let (_directory, path, store) =
2486 embedded_fixture(DurabilityMode::WalDurableGrouped, 2 * 1024 * 1024);
2487 assert!(
2488 StoreWalAction::upsert_entity("keys", &row(0, 0), None, store.format_version())
2489 .encode()
2490 .len()
2491 >= 2048,
2492 "fixture must exert physical WAL pressure after value compression"
2493 );
2494 let mut ids: Vec<_> = (0..8)
2495 .map(|writer| {
2496 store
2497 .write_keyed_row("keys", row(writer, 0), None, None)
2498 .expect("seed")
2499 })
2500 .collect();
2501 let initial = crate::storage::EmbeddedRdbArtifact::open(&path).expect("initial roots");
2502 let mut previous_snapshot = crate::storage::EmbeddedRdbArtifact::read_snapshot(&initial)
2503 .expect("initial snapshot")
2504 .expect("image");
2505 for wave in 0..3 {
2509 let barrier = Barrier::new(ids.len());
2510 ids = std::thread::scope(|scope| {
2511 let workers: Vec<_> = ids
2512 .iter()
2513 .enumerate()
2514 .map(|(writer, previous)| {
2515 let store = &store;
2516 let barrier = &barrier;
2517 scope.spawn(move || {
2518 barrier.wait();
2519 let mut id = *previous;
2520 for round in 0..64 {
2521 id = store
2522 .write_keyed_row(
2523 "keys",
2524 row(writer, wave * 64 + round),
2525 None,
2526 Some(id),
2527 )
2528 .expect("durable overwrite");
2529 }
2530 id
2531 })
2532 })
2533 .collect();
2534 workers
2535 .into_iter()
2536 .map(|worker| worker.join().expect("writer"))
2537 .collect()
2538 });
2539 let open = crate::storage::EmbeddedRdbArtifact::open(&path).expect("wave roots");
2540 assert_eq!(open.manifest.wal_region_bytes, EMBEDDED_WAL_HEADROOM_LIMIT);
2541 let snapshot = crate::storage::EmbeddedRdbArtifact::read_snapshot(&open)
2542 .expect("wave snapshot")
2543 .expect("image");
2544 assert!(snapshot != previous_snapshot, "wave {wave} must checkpoint");
2545 previous_snapshot = snapshot;
2546 }
2547 let open = crate::storage::EmbeddedRdbArtifact::open(&path).expect("recover roots");
2548 let snapshot = crate::storage::EmbeddedRdbArtifact::read_snapshot(&open)
2549 .expect("snapshot")
2550 .expect("image");
2551 let recovered =
2552 UnifiedStore::load_from_bytes_with_config(&snapshot, UnifiedStoreConfig::default())
2553 .expect("snapshot replay");
2554 for payload in crate::storage::EmbeddedRdbArtifact::read_wal_payloads(&open).expect("WAL") {
2555 recovered
2556 .apply_encoded_store_wal_action(&payload)
2557 .expect("WAL replay");
2558 }
2559 assert_eq!(recovered.get_collection("keys").expect("keys").count(), 8);
2560 for (writer, id) in ids.iter().enumerate() {
2561 let entity = recovered
2562 .get("keys", *id)
2563 .expect("last acknowledged version");
2564 assert_eq!(
2565 entity.data.as_row().expect("row").get_field("value"),
2566 row(writer, 191)
2567 .data
2568 .as_row()
2569 .expect("expected row")
2570 .get_field("value")
2571 );
2572 }
2573 }
2574
2575 fn env_lock() -> &'static StdMutex<()> {
2580 static LOCK: OnceLock<StdMutex<()>> = OnceLock::new();
2581 LOCK.get_or_init(|| StdMutex::new(()))
2582 }
2583
2584 fn temp_wal(name: &str) -> PathBuf {
2585 let nanos = SystemTime::now()
2586 .duration_since(std::time::UNIX_EPOCH)
2587 .unwrap()
2588 .as_nanos();
2589 let path = reddb_file::layout::store_commit_coord_temp_wal_path(
2590 &std::env::temp_dir(),
2591 name,
2592 std::process::id(),
2593 nanos,
2594 );
2595 let _ = std::fs::remove_file(&path);
2596 path
2597 }
2598
2599 fn replay_crash_payload(id: u64, round: u64) -> String {
2600 let mut state = id * 31 + round * 13 + 1;
2601 (0..600)
2602 .map(|_| {
2603 state = state.wrapping_mul(1664525).wrapping_add(1013904223);
2604 char::from(b'!' + ((state >> 24) % 90) as u8)
2605 })
2606 .collect()
2607 }
2608
2609 #[cfg(target_os = "linux")]
2610 #[test]
2611 fn interrupted_replay_reclaims_unpublished_pages() {
2612 interrupted_replay_reclaims_pages(false, false);
2613 }
2614
2615 #[cfg(target_os = "linux")]
2616 #[test]
2617 fn interrupted_replay_reclaims_durable_unpublished_pages() {
2618 interrupted_replay_reclaims_pages(false, true);
2619 }
2620
2621 #[cfg(target_os = "linux")]
2622 #[test]
2623 fn interrupted_replay_preserves_published_pages() {
2624 interrupted_replay_reclaims_pages(true, false);
2625 }
2626
2627 #[cfg(target_os = "linux")]
2628 #[test]
2629 fn interrupted_checkpoint_reclaims_unpublished_pages() {
2630 const CHILD_PATH: &str = "REDDB_EXPERIMENTAL_CHECKPOINT_REBUILD_PATH";
2631 let config = || {
2632 UnifiedStoreConfig::default()
2633 .with_durability_mode(DurabilityMode::Strict)
2634 .with_page_cache_slots(4)
2635 };
2636 if let Some(path) = std::env::var_os(CHILD_PATH) {
2637 let store = UnifiedStore::open_with_config(path, config()).unwrap();
2638 for id in 1..=200 {
2641 let entity = UnifiedEntity::table_row(
2642 EntityId::new(id),
2643 "rows",
2644 id,
2645 vec![Value::text(replay_crash_payload(id, 8))],
2646 );
2647 let record =
2648 UnifiedStore::serialize_entity_record(&entity, None, STORE_VERSION_V11);
2649 store.apply_replayed_upsert("rows", &record, false).unwrap();
2650 }
2651 store.persist().expect("must exit during checkpoint");
2652 panic!("checkpoint rebuild hook was not reached");
2653 }
2654 let directory = tempfile::tempdir().unwrap();
2655 let path = directory.path().join("data.rdb");
2656 {
2657 let store = UnifiedStore::open_with_config(&path, config()).unwrap();
2658 store.create_collection("rows").unwrap();
2659 for id in 1..=200 {
2660 store
2661 .insert(
2662 "rows",
2663 UnifiedEntity::table_row(
2664 EntityId::new(id),
2665 "rows",
2666 id,
2667 vec![Value::text(format!("initial-{id}"))],
2668 ),
2669 )
2670 .unwrap();
2671 }
2672 store.persist().unwrap();
2673 }
2674 let mut first = 0;
2675 let mut last = 0;
2676 for cycle in 1..=12 {
2677 let output = std::process::Command::new(std::env::current_exe().unwrap())
2678 .args([
2679 "--exact",
2680 "storage::unified::store::commit::tests::interrupted_checkpoint_reclaims_unpublished_pages",
2681 "--nocapture",
2682 ])
2683 .env(CHILD_PATH, &path)
2684 .env("REDDB_EXPERIMENTAL_EXIT_DURING_CHECKPOINT_REBUILD", "1")
2685 .output()
2686 .unwrap();
2687 assert_eq!(
2688 output.status.code(),
2689 Some(183),
2690 "{}",
2691 String::from_utf8_lossy(&output.stderr)
2692 );
2693 assert!(String::from_utf8_lossy(&output.stderr)
2694 .contains("experimental checkpoint exit before manifest"));
2695 last = std::fs::metadata(&path).unwrap().len();
2696 if cycle == 1 {
2697 first = last;
2698 }
2699 eprintln!("checkpoint_exit_cycle={cycle} data_logical={last}");
2700 }
2701 let recovered = UnifiedStore::open_with_config(&path, config()).unwrap();
2702 assert_eq!(recovered.get_collection("rows").unwrap().count(), 200);
2703 for id in 1..=200 {
2704 assert_eq!(
2705 recovered
2706 .get("rows", EntityId::new(id))
2707 .unwrap()
2708 .data
2709 .as_row()
2710 .unwrap()
2711 .columns,
2712 vec![Value::text(format!("initial-{id}"))]
2713 );
2714 }
2715 assert!(last <= first + 2 * crate::storage::engine::PAGE_SIZE as u64,
2716 "repeated interrupted checkpoint must bound page growth: first={first}, after_12={last}");
2717 }
2718
2719 #[cfg(target_os = "linux")]
2720 #[test]
2721 fn interrupted_checkpoint_with_wal_reclaims_unpublished_pages() {
2722 use std::os::unix::fs::MetadataExt;
2723 const CHILD_PATH: &str = "REDDB_EXPERIMENTAL_CHECKPOINT_WAL_PATH";
2724 const ROUND: &str = "REDDB_EXPERIMENTAL_CHECKPOINT_WAL_ROUND";
2725 let config = || {
2726 UnifiedStoreConfig::default()
2727 .with_durability_mode(DurabilityMode::Strict)
2728 .with_page_cache_slots(4)
2729 };
2730 if let Some(path) = std::env::var_os(CHILD_PATH) {
2731 let round: u64 = std::env::var(ROUND).unwrap().parse().unwrap();
2732 let store = UnifiedStore::open_with_config(path, config()).unwrap();
2733 if round > 1 {
2734 for id in 1..=200 {
2735 assert_eq!(
2736 store
2737 .get("rows", EntityId::new(id))
2738 .unwrap()
2739 .data
2740 .as_row()
2741 .unwrap()
2742 .columns,
2743 vec![Value::text(replay_crash_payload(id, round - 1))]
2744 );
2745 }
2746 }
2747 let actions: Vec<_> = (1..=200)
2748 .map(|id| {
2749 let entity = UnifiedEntity::table_row(
2750 EntityId::new(id),
2751 "rows",
2752 id,
2753 vec![Value::text(replay_crash_payload(id, round))],
2754 );
2755 StoreWalAction::upsert_entity("rows", &entity, None, STORE_VERSION_V11)
2756 })
2757 .collect();
2758 for action in &actions {
2759 store.apply_replayed_action(action, false).unwrap();
2760 }
2761 store
2764 .commit
2765 .as_ref()
2766 .unwrap()
2767 .append_actions(&actions)
2768 .unwrap();
2769 store.persist().expect("must exit during checkpoint");
2770 panic!("checkpoint rebuild hook was not reached");
2771 }
2772 let directory = tempfile::tempdir().unwrap();
2773 let path = directory.path().join("data.rdb");
2774 {
2775 let store = UnifiedStore::open_with_config(&path, config()).unwrap();
2776 store.create_collection("rows").unwrap();
2777 for id in 1..=200 {
2778 store
2779 .insert(
2780 "rows",
2781 UnifiedEntity::table_row(
2782 EntityId::new(id),
2783 "rows",
2784 id,
2785 vec![Value::text(format!("initial-{id}"))],
2786 ),
2787 )
2788 .unwrap();
2789 }
2790 store.persist().unwrap();
2791 }
2792 let mut fourth = 0;
2793 let mut last = 0;
2794 for round in 1..=12 {
2795 let output = std::process::Command::new(std::env::current_exe().unwrap())
2796 .args([
2797 "--exact",
2798 "storage::unified::store::commit::tests::interrupted_checkpoint_with_wal_reclaims_unpublished_pages",
2799 "--nocapture",
2800 ])
2801 .env(CHILD_PATH, &path)
2802 .env(ROUND, round.to_string())
2803 .env("REDDB_EXPERIMENTAL_EXIT_DURING_CHECKPOINT_REBUILD", "1")
2804 .output().unwrap();
2805 assert_eq!(
2806 output.status.code(),
2807 Some(183),
2808 "round {round}: {}",
2809 String::from_utf8_lossy(&output.stderr)
2810 );
2811 assert!(String::from_utf8_lossy(&output.stderr)
2812 .contains("experimental checkpoint exit before manifest"));
2813 let meta = std::fs::metadata(&path).unwrap();
2814 last = meta.len();
2815 if round == 4 {
2816 fourth = last;
2817 }
2818 eprintln!(
2819 "checkpoint_wal_exit_round={round} data_logical={last} data_allocated={}",
2820 meta.blocks() * 512
2821 );
2822 }
2823 let recovered = UnifiedStore::open_with_config(&path, config()).unwrap();
2824 assert_eq!(recovered.get_collection("rows").unwrap().count(), 200);
2825 for id in 1..=200 {
2826 assert_eq!(
2827 recovered
2828 .get("rows", EntityId::new(id))
2829 .unwrap()
2830 .data
2831 .as_row()
2832 .unwrap()
2833 .columns,
2834 vec![Value::text(replay_crash_payload(id, 12))]
2835 );
2836 }
2837 recovered.persist().unwrap();
2838 drop(recovered);
2839 let reopened = UnifiedStore::open_with_config(&path, config()).unwrap();
2840 for id in 1..=200 {
2841 assert_eq!(
2842 reopened
2843 .get("rows", EntityId::new(id))
2844 .unwrap()
2845 .data
2846 .as_row()
2847 .unwrap()
2848 .columns,
2849 vec![Value::text(replay_crash_payload(id, 12))]
2850 );
2851 }
2852 assert!(last <= fourth + 2 * crate::storage::engine::PAGE_SIZE as u64,
2853 "repeated interrupted WAL-backed checkpoints must bound page growth: round4={fourth}, round12={last}");
2854 }
2855
2856 #[cfg(target_os = "linux")]
2857 #[test]
2858 fn interrupted_retired_reclamation_preserves_reuse() {
2859 const CHILD_PATH: &str = "REDDB_EXPERIMENTAL_RETIRED_PATH";
2860 fn mutate(store: &UnifiedStore, round: u64) {
2861 for id in 1..=200 {
2862 let entity = UnifiedEntity::table_row(
2863 EntityId::new(id),
2864 "rows",
2865 id,
2866 vec![Value::text(replay_crash_payload(id, round))],
2867 );
2868 let record =
2869 UnifiedStore::serialize_entity_record(&entity, None, STORE_VERSION_V11);
2870 store.apply_replayed_upsert("rows", &record, false).unwrap();
2871 }
2872 }
2873 let config = || {
2874 UnifiedStoreConfig::default()
2875 .with_durability_mode(DurabilityMode::Strict)
2876 .with_page_cache_slots(4)
2877 };
2878 if let Some(path) = std::env::var_os(CHILD_PATH) {
2879 let store = UnifiedStore::open_with_config(path, config()).unwrap();
2880 mutate(&store, 2);
2881 store.persist().expect("must exit at retired free boundary");
2882 panic!("retired free hook was not reached");
2883 }
2884 let mut reuse = Vec::new();
2885 for (label, env_name, exit_code, message) in [
2886 (
2887 "before",
2888 "REDDB_EXPERIMENTAL_EXIT_BEFORE_RETIRED_FREE",
2889 184,
2890 "experimental exit before retired free",
2891 ),
2892 (
2893 "enqueued",
2894 "REDDB_EXPERIMENTAL_EXIT_AFTER_RETIRED_ENQUEUE",
2895 186,
2896 "experimental exit after retired enqueue",
2897 ),
2898 (
2899 "after",
2900 "REDDB_EXPERIMENTAL_EXIT_AFTER_RETIRED_FREE",
2901 185,
2902 "experimental exit after retired free",
2903 ),
2904 ] {
2905 let directory = tempfile::tempdir().unwrap();
2906 let path = directory.path().join("data.rdb");
2907 {
2908 let store = UnifiedStore::open_with_config(&path, config()).unwrap();
2909 store.create_collection("rows").unwrap();
2910 for id in 1..=200 {
2911 store
2912 .insert(
2913 "rows",
2914 UnifiedEntity::table_row(
2915 EntityId::new(id),
2916 "rows",
2917 id,
2918 vec![Value::text(format!("initial-{id}"))],
2919 ),
2920 )
2921 .unwrap();
2922 }
2923 store.persist().unwrap();
2924 mutate(&store, 1);
2925 store.persist().unwrap();
2926 }
2927 let output = std::process::Command::new(std::env::current_exe().unwrap())
2928 .args([
2929 "--exact",
2930 "storage::unified::store::commit::tests::interrupted_retired_reclamation_preserves_reuse",
2931 "--nocapture",
2932 ])
2933 .env(CHILD_PATH, &path)
2934 .env(env_name, "1")
2935 .output().unwrap();
2936 assert_eq!(
2937 output.status.code(),
2938 Some(exit_code),
2939 "{label}: {}",
2940 String::from_utf8_lossy(&output.stderr)
2941 );
2942 assert!(String::from_utf8_lossy(&output.stderr).contains(message));
2943 eprintln!(
2944 "retired_reclaim_{label} child={}",
2945 String::from_utf8_lossy(&output.stderr).trim()
2946 );
2947 let store = UnifiedStore::open_with_config(&path, config()).unwrap();
2948 for id in 1..=200 {
2949 assert_eq!(
2950 store
2951 .get("rows", EntityId::new(id))
2952 .unwrap()
2953 .data
2954 .as_row()
2955 .unwrap()
2956 .columns,
2957 vec![Value::text(replay_crash_payload(id, 2))]
2958 );
2959 }
2960 let pager = store.pager.as_ref().unwrap();
2961 let before_pages = pager.page_count().unwrap();
2962 let mut ids = std::collections::HashSet::new();
2963 let mut reused = 0;
2964 for _ in 0..256 {
2965 let page = pager
2966 .allocate_page(crate::storage::engine::page::PageType::BTreeLeaf)
2967 .unwrap();
2968 let id = page.page_id();
2969 assert!(ids.insert(id), "duplicate allocation");
2970 reused += u32::from(id < before_pages);
2971 pager.write_page(id, page).unwrap();
2972 }
2973 pager.sync().unwrap();
2974 eprintln!(
2975 "retired_reclaim_{label} before_pages={before_pages} reused={reused} appended={}",
2976 256 - reused
2977 );
2978 drop(store);
2979 let reopened = UnifiedStore::open_with_config(&path, config()).unwrap();
2980 for id in 1..=200 {
2981 assert_eq!(
2982 reopened
2983 .get("rows", EntityId::new(id))
2984 .unwrap()
2985 .data
2986 .as_row()
2987 .unwrap()
2988 .columns,
2989 vec![Value::text(replay_crash_payload(id, 2))]
2990 );
2991 }
2992 reuse.push(reused);
2993 }
2994 assert!(
2995 reuse[0] >= reuse[2] && reuse[1] >= reuse[2],
2996 "recovery must not lose retired pages before allocator publication: before={} enqueued={} after={}",
2997 reuse[0],
2998 reuse[1],
2999 reuse[2]
3000 );
3001 }
3002
3003 #[cfg(target_os = "linux")]
3004 #[test]
3005 fn experimental_retaining_released_root_is_not_recoverable() {
3006 let directory = tempfile::tempdir().unwrap();
3007 let path = directory.path().join("data.rdb");
3008 let config = || {
3009 UnifiedStoreConfig::default()
3010 .with_durability_mode(DurabilityMode::Strict)
3011 .with_page_cache_slots(4)
3012 };
3013 {
3014 let store = UnifiedStore::open_with_config(&path, config()).unwrap();
3015 store.create_collection("rows").unwrap();
3016 for id in 1..=200 {
3017 store
3018 .insert(
3019 "rows",
3020 UnifiedEntity::table_row(
3021 EntityId::new(id),
3022 "rows",
3023 id,
3024 vec![Value::text(format!("initial-{id}"))],
3025 ),
3026 )
3027 .unwrap();
3028 }
3029 store.persist().unwrap();
3030 for id in 1..=200 {
3031 let entity = UnifiedEntity::table_row(
3032 EntityId::new(id),
3033 "rows",
3034 id,
3035 vec![Value::text(replay_crash_payload(id, 1))],
3036 );
3037 let record =
3038 UnifiedStore::serialize_entity_record(&entity, None, STORE_VERSION_V11);
3039 store.apply_replayed_upsert("rows", &record, false).unwrap();
3040 }
3041 store.persist().unwrap();
3042 }
3043 let output = std::process::Command::new(std::env::current_exe().unwrap())
3044 .args(["--exact", "storage::unified::store::commit::tests::interrupted_retired_reclamation_preserves_reuse", "--nocapture"])
3045 .env("REDDB_EXPERIMENTAL_RETIRED_PATH", &path)
3046 .env("REDDB_EXPERIMENTAL_RETAIN_RELEASED_ROOT", "1")
3047 .env("REDDB_EXPERIMENTAL_EXIT_AFTER_RETIRED_FREE", "1")
3048 .output().unwrap();
3049 assert_eq!(
3050 output.status.code(),
3051 Some(185),
3052 "{}",
3053 String::from_utf8_lossy(&output.stderr)
3054 );
3055 assert!(String::from_utf8_lossy(&output.stderr)
3056 .contains("experimental exit after retired free"));
3057
3058 let reopened = UnifiedStore::open_with_config(&path, config()).unwrap();
3059 for id in 1..=200 {
3060 assert_eq!(
3061 reopened
3062 .get("rows", EntityId::new(id))
3063 .unwrap()
3064 .data
3065 .as_row()
3066 .unwrap()
3067 .columns,
3068 vec![Value::text(replay_crash_payload(id, 2))]
3069 );
3070 }
3071 let retired_roots: Vec<_> = reopened
3072 .retired_btrees
3073 .lock()
3074 .iter()
3075 .map(|entry| entry.tree.root_page_id())
3076 .collect();
3077 let pager = reopened.pager.as_ref().unwrap();
3078 let root_types: Vec<_> = retired_roots
3079 .iter()
3080 .map(|id| pager.read_page(*id).unwrap().page_type().unwrap())
3081 .collect();
3082 assert!(
3083 root_types.contains(&crate::storage::engine::page::PageType::FreelistTrunk),
3084 "expected a retained root to have become a freelist trunk: {root_types:?}"
3085 );
3086 let error = reopened
3087 .persist()
3088 .expect_err("retained root was reused as allocator metadata");
3089 assert!(
3090 error
3091 .to_string()
3092 .contains("unexpected retired tree page type"),
3093 "{error}"
3094 );
3095 eprintln!("retained_root_types={root_types:?} persist_error={error}");
3096 }
3097
3098 #[cfg(target_os = "linux")]
3099 fn interrupted_replay_reclaims_pages(after_publication: bool, force_sync: bool) {
3100 use std::os::unix::fs::MetadataExt;
3101 const CHILD_PATH: &str = "REDDB_EXPERIMENTAL_REPLAY_REBUILD_PATH";
3102 let config = || {
3103 UnifiedStoreConfig::default()
3104 .with_durability_mode(DurabilityMode::Strict)
3105 .with_page_cache_slots(4)
3106 };
3107 if let Some(path) = std::env::var_os(CHILD_PATH) {
3108 let _store = UnifiedStore::open_with_config(path, config())
3109 .expect("must exit while replay rebuilds");
3110 panic!("replay rebuild hook was not reached");
3111 }
3112 let directory = tempfile::tempdir().unwrap();
3113 let path = directory.path().join("data.rdb");
3114 {
3115 let store = UnifiedStore::open_with_config(&path, config()).unwrap();
3116 store.create_collection("rows").unwrap();
3117 for id in 1..=200 {
3118 store
3119 .insert(
3120 "rows",
3121 UnifiedEntity::table_row(
3122 EntityId::new(id),
3123 "rows",
3124 id,
3125 vec![Value::text(format!("initial-{id}"))],
3126 ),
3127 )
3128 .unwrap();
3129 }
3130 store.persist().unwrap();
3131 }
3132 {
3133 let coordinator = StoreCommitCoordinator::open(
3134 reddb_file::layout::unified_wal_path(&path),
3135 DurabilityMode::Strict,
3136 GroupCommitOptions::default(),
3137 )
3138 .unwrap();
3139 let actions = (1..=200)
3140 .map(|id| {
3141 let entity = UnifiedEntity::table_row(
3142 EntityId::new(id),
3143 "rows",
3144 id,
3145 vec![Value::text(replay_crash_payload(id, 8))],
3146 );
3147 StoreWalAction::upsert_entity("rows", &entity, None, STORE_VERSION_V11)
3148 })
3149 .collect::<Vec<_>>();
3150 coordinator.append_actions(&actions).unwrap();
3151 }
3152 let snapshot = || {
3153 let data = std::fs::metadata(&path).unwrap();
3154 let wal = std::fs::metadata(reddb_file::layout::unified_wal_path(&path)).unwrap();
3155 (
3156 data.len(),
3157 data.blocks() * 512,
3158 wal.len(),
3159 wal.blocks() * 512,
3160 )
3161 };
3162 let baseline = snapshot();
3163 let mut previous = baseline;
3164 let mut first_failure = baseline;
3165 for cycle in 1..=12 {
3166 let test_name = if after_publication {
3167 "storage::unified::store::commit::tests::interrupted_replay_preserves_published_pages"
3168 } else if force_sync {
3169 "storage::unified::store::commit::tests::interrupted_replay_reclaims_durable_unpublished_pages"
3170 } else {
3171 "storage::unified::store::commit::tests::interrupted_replay_reclaims_unpublished_pages"
3172 };
3173 let mut command = std::process::Command::new(std::env::current_exe().unwrap());
3174 command
3175 .args(["--exact", test_name, "--nocapture"])
3176 .env(CHILD_PATH, &path);
3177 if !force_sync {
3178 command.env("REDDB_EXPERIMENTAL_REPLAY_NO_SYNC", "1");
3179 } else {
3180 command.env_remove("REDDB_EXPERIMENTAL_REPLAY_NO_SYNC");
3181 }
3182 let message = if after_publication {
3183 command.env("REDDB_EXPERIMENTAL_EXIT_AFTER_REPLAY_PUBLICATION", "1");
3184 "experimental replay exit after manifest"
3185 } else {
3186 command.env("REDDB_EXPERIMENTAL_EXIT_DURING_REPLAY_REBUILD", "1");
3187 "experimental replay rebuild exit before manifest"
3188 };
3189 let output = command.output().unwrap();
3190 assert_eq!(
3191 output.status.code(),
3192 Some(182),
3193 "cycle {cycle}: {}",
3194 String::from_utf8_lossy(&output.stderr)
3195 );
3196 assert!(String::from_utf8_lossy(&output.stderr).contains(message));
3197 let now = snapshot();
3198 eprintln!("replay_exit_cycle={cycle} data_logical={} data_allocated={} wal_logical={} wal_allocated={}",now.0,now.1,now.2,now.3);
3199 if cycle == 1 {
3200 first_failure = now;
3201 }
3202 previous = now;
3203 }
3204 let recovered = UnifiedStore::open_with_config(&path, config()).unwrap();
3205 assert_eq!(recovered.get_collection("rows").unwrap().count(), 200);
3206 for id in 1..=200 {
3207 assert_eq!(
3208 recovered
3209 .get("rows", EntityId::new(id))
3210 .unwrap()
3211 .data
3212 .as_row()
3213 .unwrap()
3214 .columns,
3215 vec![Value::text(replay_crash_payload(id, 8))]
3216 );
3217 }
3218 let pages = recovered.pager.as_ref().unwrap().page_count().unwrap();
3219 let free = recovered.pager.as_ref().unwrap().free_page_count().unwrap();
3220 recovered.persist().unwrap();
3221 drop(recovered);
3222 let final_sizes = snapshot();
3223 let reopened = UnifiedStore::open_with_config(&path, config()).unwrap();
3224 assert_eq!(reopened.get_collection("rows").unwrap().count(), 200);
3225 for id in 1..=200 {
3226 assert_eq!(
3227 reopened
3228 .get("rows", EntityId::new(id))
3229 .unwrap()
3230 .data
3231 .as_row()
3232 .unwrap()
3233 .columns,
3234 vec![Value::text(replay_crash_payload(id, 8))]
3235 );
3236 }
3237 eprintln!("replay_rebuild baseline={baseline:?} after_12={previous:?} after_recovery_checkpoint={final_sizes:?} pages={pages} free={free}");
3238 let pager = reopened.pager.as_ref().unwrap();
3242 let before_probe = pager.page_count().unwrap();
3243 let mut ids = std::collections::HashSet::new();
3244 let mut reused = 0;
3245 for _ in 0..256 {
3246 let page = pager
3247 .allocate_page(crate::storage::engine::page::PageType::BTreeLeaf)
3248 .unwrap();
3249 let id = page.page_id();
3250 assert!(ids.insert(id), "allocator returned the same page twice");
3251 reused += u32::from(id < before_probe);
3252 pager.write_page(id, page).unwrap();
3253 }
3254 pager.sync().unwrap();
3255 let after_probe = pager.page_count().unwrap();
3256 eprintln!("replay_reuse_probe count=256 before_pages={before_probe} after_pages={after_probe} reused={reused} appended={}", 256 - reused);
3257 drop(reopened);
3258 let verified = UnifiedStore::open_with_config(&path, config()).unwrap();
3259 for id in 1..=200 {
3260 assert_eq!(
3261 verified
3262 .get("rows", EntityId::new(id))
3263 .unwrap()
3264 .data
3265 .as_row()
3266 .unwrap()
3267 .columns,
3268 vec![Value::text(replay_crash_payload(id, 8))]
3269 );
3270 }
3271 assert!(
3272 previous.0 <= first_failure.0 + 2 * crate::storage::engine::PAGE_SIZE as u64,
3273 "repeated interrupted replay must bound page growth: first={}, after_12={}",
3274 first_failure.0,
3275 previous.0
3276 );
3277 }
3278
3279 #[test]
3280 fn paged_wal_replay_survives_immediate_process_exit() {
3281 const CHILD_PATH: &str = "REDDB_TEST_REPLAY_CRASH_PATH";
3282 let config = || UnifiedStoreConfig::default().with_page_cache_slots(4);
3283 if let Some(path) = std::env::var_os(CHILD_PATH) {
3284 let store = UnifiedStore::open_with_config(path, config()).expect("replay child");
3285 assert_eq!(store.get_collection("rows").unwrap().count(), 400);
3286 std::process::exit(0);
3288 }
3289 let directory = tempfile::tempdir().unwrap();
3290 let path = directory.path().join("data.rdb");
3291 {
3292 let store = UnifiedStore::open_with_config(&path, config()).unwrap();
3293 store.create_collection("rows").unwrap();
3294 for id in 1..=400 {
3295 store
3296 .insert(
3297 "rows",
3298 UnifiedEntity::table_row(
3299 EntityId::new(id),
3300 "rows",
3301 id,
3302 vec![Value::text(format!("initial-{id}"))],
3303 ),
3304 )
3305 .unwrap();
3306 }
3307 store.persist().unwrap();
3308 }
3309 {
3310 let coordinator = StoreCommitCoordinator::open(
3311 reddb_file::layout::unified_wal_path(&path),
3312 DurabilityMode::Strict,
3313 GroupCommitOptions::default(),
3314 )
3315 .unwrap();
3316 for round in 0..3 {
3317 let actions = (1..=400)
3318 .map(|id| {
3319 let payload = replay_crash_payload(id, round);
3320 let entity = UnifiedEntity::table_row(
3321 EntityId::new(id),
3322 "rows",
3323 id,
3324 vec![Value::text(payload)],
3325 );
3326 StoreWalAction::upsert_entity("rows", &entity, None, STORE_VERSION_V11)
3327 })
3328 .collect::<Vec<_>>();
3329 coordinator.append_actions(&actions).unwrap();
3330 }
3331 }
3332 let status = std::process::Command::new(std::env::current_exe().unwrap())
3333 .args(["--exact", "storage::unified::store::commit::tests::paged_wal_replay_survives_immediate_process_exit", "--nocapture"])
3334 .env(CHILD_PATH, &path).status().unwrap();
3335 assert!(status.success(), "replay process failed");
3336 let reopened = UnifiedStore::open_with_config(&path, config())
3337 .expect("cold reopen after replay crash");
3338 assert_eq!(reopened.get_collection("rows").unwrap().count(), 400);
3339 for id in 1..=400 {
3340 let entity = reopened
3341 .get("rows", EntityId::new(id))
3342 .expect("retained entity");
3343 let EntityData::Row(row) = entity.data else {
3344 panic!("expected row")
3345 };
3346 assert_eq!(row.columns, vec![Value::text(replay_crash_payload(id, 2))]);
3347 }
3348 drop(reopened);
3349 let stable_bytes = std::fs::metadata(&path).unwrap().len();
3350 let repeated = UnifiedStore::open_with_config(&path, config()).unwrap();
3351 assert_eq!(repeated.get_collection("rows").unwrap().count(), 400);
3352 assert_eq!(
3353 std::fs::metadata(&path).unwrap().len(),
3354 stable_bytes,
3355 "idempotent replay must not allocate another tree generation"
3356 );
3357 }
3358
3359 #[test]
3360 fn checkpoint_reset_waits_for_an_enqueued_commit_acknowledgement() {
3361 let _env = env_lock().lock().unwrap_or_else(|p| p.into_inner());
3362 let path = temp_wal("checkpoint-ack");
3363 let coordinator = Arc::new(
3364 StoreCommitCoordinator::open(
3365 path.clone(),
3366 DurabilityMode::WalDurableGrouped,
3367 GroupCommitOptions::default(),
3368 )
3369 .unwrap(),
3370 );
3371 let state = coordinator.state.0.lock();
3374 let (written_tx, written_rx) = std::sync::mpsc::channel();
3375 let writer_coordinator = Arc::clone(&coordinator);
3376 let writer = std::thread::spawn(move || {
3377 let result = writer_coordinator.append_actions(&[StoreWalAction::CreateCollection {
3378 name: "acknowledged".into(),
3379 }]);
3380 written_tx.send(result).unwrap();
3381 });
3382 let deadline = Instant::now() + Duration::from_secs(2);
3383 while !coordinator.queue.has_pending() {
3384 assert!(Instant::now() < deadline, "writer never enqueued");
3385 std::thread::yield_now();
3386 }
3387 let (started_tx, started_rx) = std::sync::mpsc::channel();
3388 let (reset_tx, reset_rx) = std::sync::mpsc::channel();
3389 let reset_coordinator = Arc::clone(&coordinator);
3390 let reset = std::thread::spawn(move || {
3391 started_tx.send(()).unwrap();
3392 reset_tx.send(reset_coordinator.truncate()).unwrap();
3393 });
3394 started_rx.recv_timeout(Duration::from_secs(2)).unwrap();
3395 let deadline = Instant::now() + Duration::from_millis(100);
3396 while coordinator.queue.has_pending() && Instant::now() < deadline {
3397 std::thread::sleep(Duration::from_millis(1));
3398 }
3399 let discarded_before_ack = !coordinator.queue.has_pending();
3400 drop(state);
3401 let acknowledged = written_rx.recv_timeout(Duration::from_secs(2));
3402 if acknowledged.is_err() {
3403 let mut state = coordinator.state.0.lock();
3406 state.shutdown = true;
3407 state.last_error = Some("checkpoint acknowledgement regression cleanup".into());
3408 coordinator.state.1.notify_all();
3409 }
3410 writer.join().unwrap();
3411 reset_rx
3412 .recv_timeout(Duration::from_secs(2))
3413 .unwrap()
3414 .unwrap();
3415 reset.join().unwrap();
3416 assert!(
3417 !discarded_before_ack,
3418 "checkpoint discarded an unacknowledged WAL record"
3419 );
3420 assert!(
3421 acknowledged.unwrap().is_ok(),
3422 "durable writer did not finish"
3423 );
3424 drop(coordinator);
3425 let _ = std::fs::remove_file(path);
3426 }
3427
3428 #[test]
3439 fn group_commit_coalesces_concurrent_autocommits() {
3440 let _env = env_lock().lock().unwrap_or_else(|p| p.into_inner());
3441 std::env::remove_var("REDDB_GROUP_COMMIT_WINDOW_US");
3444
3445 let path = temp_wal("coalesce");
3446 let coord = Arc::new(
3447 StoreCommitCoordinator::open(
3448 path.clone(),
3449 DurabilityMode::WalDurableGrouped,
3450 GroupCommitOptions::default(),
3451 )
3452 .expect("open commit coordinator"),
3453 );
3454
3455 const WRITERS: usize = 32;
3456 let barrier = Arc::new(Barrier::new(WRITERS));
3457 let mut handles = Vec::with_capacity(WRITERS);
3458 for tx in 0..WRITERS {
3459 let coord_c = Arc::clone(&coord);
3460 let barrier_c = Arc::clone(&barrier);
3461 handles.push(std::thread::spawn(move || {
3462 barrier_c.wait();
3466 let action = StoreWalAction::CreateCollection {
3467 name: format!("c{tx}"),
3468 };
3469 coord_c
3470 .append_actions(std::slice::from_ref(&action))
3471 .expect("append_actions");
3472 }));
3473 }
3474 for h in handles {
3475 h.join().expect("writer thread");
3476 }
3477
3478 let fsyncs = coord.fsync_count();
3479 assert!(fsyncs > 0, "expected at least one fsync, got {fsyncs}");
3480 assert!(
3481 fsyncs < WRITERS as u64,
3482 "expected fsyncs ({fsyncs}) to be strictly less than \
3483 concurrent writers ({WRITERS}); coalescing failed"
3484 );
3485
3486 drop(coord);
3487 let _ = std::fs::remove_file(&path);
3488 }
3489
3490 #[test]
3496 fn zero_window_disables_coalescing_floor() {
3497 let _env = env_lock().lock().unwrap_or_else(|p| p.into_inner());
3498 std::env::set_var("REDDB_GROUP_COMMIT_WINDOW_US", "0");
3499
3500 let path = temp_wal("zero_window");
3501 let coord = Arc::new(
3502 StoreCommitCoordinator::open(
3503 path.clone(),
3504 DurabilityMode::WalDurableGrouped,
3505 GroupCommitOptions::default(),
3506 )
3507 .expect("open commit coordinator"),
3508 );
3509
3510 const WRITERS: usize = 8;
3511 let barrier = Arc::new(Barrier::new(WRITERS));
3512 let mut handles = Vec::with_capacity(WRITERS);
3513 for tx in 0..WRITERS {
3514 let coord_c = Arc::clone(&coord);
3515 let barrier_c = Arc::clone(&barrier);
3516 handles.push(std::thread::spawn(move || {
3517 barrier_c.wait();
3518 let action = StoreWalAction::CreateCollection {
3519 name: format!("z{tx}"),
3520 };
3521 coord_c
3522 .append_actions(std::slice::from_ref(&action))
3523 .expect("append_actions");
3524 }));
3525 }
3526 for h in handles {
3527 h.join().expect("writer thread");
3528 }
3529
3530 let fsyncs = coord.fsync_count();
3536 assert!(fsyncs >= 1, "expected at least one fsync, got {fsyncs}");
3537
3538 std::env::remove_var("REDDB_GROUP_COMMIT_WINDOW_US");
3539 drop(coord);
3540 let _ = std::fs::remove_file(&path);
3541 }
3542
3543 #[test]
3545 fn resolve_window_precedence() {
3546 let _env = env_lock().lock().unwrap_or_else(|p| p.into_inner());
3547 std::env::remove_var("REDDB_GROUP_COMMIT_WINDOW_US");
3549 let cfg = GroupCommitOptions::default();
3550 assert_eq!(
3551 StoreCommitCoordinator::resolve_window(&cfg),
3552 Duration::from_micros(DEFAULT_ADAPTIVE_WINDOW_US)
3553 );
3554
3555 let cfg_ms = GroupCommitOptions {
3557 window_ms: 5,
3558 ..GroupCommitOptions::default()
3559 };
3560 assert_eq!(
3561 StoreCommitCoordinator::resolve_window(&cfg_ms),
3562 Duration::from_millis(5)
3563 );
3564
3565 std::env::set_var("REDDB_GROUP_COMMIT_WINDOW_US", "750");
3567 assert_eq!(
3568 StoreCommitCoordinator::resolve_window(&cfg),
3569 Duration::from_micros(750)
3570 );
3571 assert_eq!(
3572 StoreCommitCoordinator::resolve_window(&cfg_ms),
3573 Duration::from_micros(750)
3574 );
3575
3576 std::env::set_var("REDDB_GROUP_COMMIT_WINDOW_US", "0");
3578 assert_eq!(
3579 StoreCommitCoordinator::resolve_window(&cfg),
3580 Duration::from_micros(0)
3581 );
3582
3583 std::env::remove_var("REDDB_GROUP_COMMIT_WINDOW_US");
3584 }
3585}