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