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(super) struct StoreWalCaptureGuard<'a> {
94 active: bool,
95 session: Option<&'a crate::storage::embedded::EmbeddedCommitSession>,
96}
97
98impl<'a> StoreWalCaptureGuard<'a> {
99 pub(super) 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(super) 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(&self, store: &UnifiedStore) -> io::Result<()> {
1201 if !self.wal_path.exists() {
1202 return Ok(());
1203 }
1204
1205 let reader = match WalReader::open(&self.wal_path) {
1206 Ok(reader) => reader,
1207 Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(()),
1208 Err(err) => return Err(err),
1209 };
1210
1211 let mut tx_states = std::collections::HashMap::<u64, bool>::new();
1212 let mut has_page_writes = false;
1213 let mut ownership_journal = Vec::new();
1214 let mut latest_ownership_epoch = 0;
1215 let mut incomplete_wal_tail = false;
1216 let mut applied = false;
1217
1218 for record in reader.iter() {
1219 let (_lsn, record) = match record {
1220 Ok(record) => record,
1221 Err(err) if err.kind() == io::ErrorKind::UnexpectedEof => {
1222 incomplete_wal_tail = true;
1223 break;
1224 }
1225 Err(err) => return Err(err),
1226 };
1227 match record {
1228 WalRecord::PageOwnership { record } => {
1229 latest_ownership_epoch = latest_ownership_epoch.max(record.epoch());
1230 self.ownership_last_epoch
1231 .fetch_max(record.epoch(), Ordering::AcqRel);
1232 ownership_journal
1233 .extend_from_slice(&reddb_file::page_ownership_journal::encode(record)?);
1234 }
1235 WalRecord::TxCommitBatch { actions, .. } => {
1236 for payload in actions {
1237 let action = StoreWalAction::decode(&payload)?;
1238 applied = true;
1239 store.apply_replayed_action(&action, false).map_err(|err| {
1240 io::Error::other(format!("failed to replay store wal action: {err}"))
1241 })?;
1242 store.remember_checkpoint_rows(vec![action], Default::default());
1246 }
1247 }
1248 WalRecord::Begin { tx_id } => {
1249 tx_states.insert(tx_id, false);
1250 }
1251 WalRecord::Commit { tx_id } => {
1252 tx_states.insert(tx_id, true);
1253 }
1254 WalRecord::Rollback { tx_id } => {
1255 tx_states.remove(&tx_id);
1256 }
1257 WalRecord::PageWrite { .. } => has_page_writes = true,
1258 WalRecord::Checkpoint { .. } => {}
1259 WalRecord::VectorInsert {
1260 collection,
1261 entity_id,
1262 vector,
1263 } => {
1264 self.runtime_wal_state.store(2, Ordering::Release);
1265 let mut map = store.replayed_turbo_inserts.lock();
1271 map.entry(
1272 reddb_engine::storage::system_collection_names::canonical_owned(collection),
1273 )
1274 .or_default()
1275 .push((entity_id, vector));
1276 }
1277 WalRecord::ProbabilisticDelta {
1278 kind,
1279 operation,
1280 name,
1281 operands,
1282 } => {
1283 self.runtime_wal_state.store(2, Ordering::Release);
1284 let mut deltas = store.replayed_probabilistic_deltas.lock();
1285 deltas.push((kind, operation, name, operands));
1286 }
1287 WalRecord::FullPageImage { .. } => {
1288 }
1290 }
1291 }
1292
1293 if incomplete_wal_tail && !ownership_journal.is_empty() {
1294 return Err(io::Error::new(
1295 io::ErrorKind::InvalidData,
1296 "incomplete WAL prevents ownership recovery",
1297 ));
1298 }
1299 self.recover_page_ownership(store, &ownership_journal)?;
1300
1301 if has_page_writes {
1302 for item in WalReader::open(&self.wal_path)?.iter() {
1306 let record = match item {
1307 Ok((_, record)) => record,
1308 Err(err) if err.kind() == io::ErrorKind::UnexpectedEof => break,
1309 Err(err) => return Err(err),
1310 };
1311 let WalRecord::PageWrite { tx_id, data, .. } = record else {
1312 continue;
1313 };
1314 if !tx_states.get(&tx_id).copied().unwrap_or(false) {
1315 continue;
1316 }
1317 let action = StoreWalAction::decode(&data)?;
1318 applied = true;
1319 store.apply_replayed_action(&action, false).map_err(|err| {
1320 io::Error::other(format!("failed to replay store wal action: {err}"))
1321 })?;
1322 store.remember_checkpoint_rows(vec![action], Default::default());
1323 }
1324 }
1325
1326 if applied && store.config.resident_memory_bytes_max.is_some() {
1327 return Ok(());
1331 }
1332 if applied {
1333 let epoch = latest_ownership_epoch
1336 .max(store.ownership_catalog_epoch.load(Ordering::Acquire))
1337 .checked_add(1)
1338 .ok_or_else(|| io::Error::other("page ownership epoch exhausted"))?;
1339 self.begin_page_ownership_epoch(store, epoch)?;
1340 store.mark_paged_registry_dirty();
1343 store
1344 .reconcile_replayed_btrees()
1345 .map_err(|err| io::Error::other(err.to_string()))?;
1346 self.finish_page_ownership_epoch(store, epoch)?;
1347 }
1348 Ok(())
1349 }
1350
1351 fn wait_until_durable(&self, target_lsn: u64, wal_bytes: u64) -> io::Result<()> {
1352 match self.mode {
1353 DurabilityMode::Strict => self.force_sync(),
1354 DurabilityMode::Async => {
1359 let (state_lock, cond) = &*self.state;
1360 let mut state = state_lock.lock();
1361 state.pending_target_lsn = state.pending_target_lsn.max(target_lsn);
1362 state.pending_statements = state.pending_statements.saturating_add(1);
1363 state.pending_wal_bytes = state.pending_wal_bytes.saturating_add(wal_bytes);
1364 state.first_pending_at.get_or_insert_with(Instant::now);
1365 cond.notify_all();
1366 Ok(())
1367 }
1368 DurabilityMode::WalDurableGrouped => {
1369 let (state_lock, cond) = &*self.state;
1370 let mut state = state_lock.lock();
1371 state.pending_target_lsn = state.pending_target_lsn.max(target_lsn);
1372 state.pending_statements = state.pending_statements.saturating_add(1);
1373 state.pending_wal_bytes = state.pending_wal_bytes.saturating_add(wal_bytes);
1374 state.first_pending_at.get_or_insert_with(Instant::now);
1375 cond.notify_all();
1376
1377 loop {
1378 if let Some(err) = state.last_error.clone() {
1379 return Err(io::Error::other(err));
1380 }
1381 if state.durable_lsn >= target_lsn {
1382 return Ok(());
1383 }
1384 cond.wait(&mut state);
1387 }
1388 }
1389 }
1390 }
1391
1392 fn run_group_commit_loop(
1393 wal: Arc<WalMutex>,
1394 queue: Arc<WalAppendQueue>,
1395 state: Arc<(CommitStateMutex, CommitStateCondvar)>,
1396 fsync_count: Arc<AtomicU64>,
1397 window: Duration,
1398 max_statements: usize,
1399 max_wal_bytes: u64,
1400 ) {
1401 let (state_lock, cond) = &*state;
1402 loop {
1403 let target_lsn = {
1404 let mut guard = state_lock.lock();
1405
1406 while !guard.shutdown && guard.pending_target_lsn <= guard.durable_lsn {
1407 cond.wait(&mut guard);
1408 }
1409
1410 if guard.shutdown {
1411 return;
1412 }
1413
1414 let immediate = window.is_zero()
1415 || guard.pending_statements >= max_statements
1416 || guard.pending_wal_bytes >= max_wal_bytes;
1417
1418 if !immediate {
1419 let deadline = guard.first_pending_at.unwrap_or_else(Instant::now) + window;
1420 let now = Instant::now();
1421 if now < deadline {
1422 let timeout = deadline.saturating_duration_since(now);
1423 let _ = cond.wait_for(&mut guard, timeout);
1424 if guard.shutdown {
1425 return;
1426 }
1427 if guard.pending_target_lsn <= guard.durable_lsn {
1428 continue;
1429 }
1430 let should_wait_again = guard.pending_statements < max_statements
1431 && guard.pending_wal_bytes < max_wal_bytes
1432 && guard
1433 .first_pending_at
1434 .map(|first| first.elapsed() < window)
1435 .unwrap_or(false);
1436 if should_wait_again {
1437 continue;
1438 }
1439 }
1440 }
1441
1442 guard.pending_target_lsn
1443 };
1444
1445 let _epoch = queue.epoch.read_recursive();
1450
1451 let batches = queue.drain_sorted();
1457
1458 let sync_result = {
1459 let mut wal = wal.lock();
1460 let mut write_err: Option<io::Error> = None;
1461 for (_lsn, bytes) in batches {
1462 if let Err(e) = wal.append_bytes(&bytes) {
1463 write_err = Some(e);
1464 break;
1465 }
1466 }
1467 match write_err {
1468 Some(e) => Err(e),
1469 None => wal.sync().map(|_| {
1470 fsync_count.fetch_add(1, Ordering::Relaxed);
1475 wal.durable_lsn()
1476 }),
1477 }
1478 };
1479
1480 let more_pending = queue.has_pending();
1485
1486 let mut guard = state_lock.lock();
1487 match sync_result {
1488 Ok(durable_lsn) => {
1489 guard.durable_lsn = durable_lsn;
1490 if !more_pending {
1491 guard.pending_statements = 0;
1492 guard.pending_wal_bytes = 0;
1493 guard.first_pending_at = None;
1494 }
1495 guard.last_error = None;
1496 let _ = target_lsn;
1497 }
1498 Err(err) => {
1499 guard.last_error = Some(err.to_string());
1500 }
1501 }
1502 cond.notify_all();
1503 }
1504 }
1505}
1506
1507impl Drop for StoreCommitCoordinator {
1508 fn drop(&mut self) {
1509 let (state_lock, cond) = &*self.state;
1510 let mut state = state_lock.lock();
1512 state.shutdown = true;
1513 cond.notify_all();
1514 }
1515}
1516
1517impl UnifiedStore {
1518 pub(crate) fn deferred_store_wal_capture_active() -> bool {
1519 deferred_store_wal_capture_active()
1520 }
1521
1522 pub(crate) fn begin_deferred_store_wal_capture() {
1523 begin_deferred_store_wal_capture();
1524 }
1525
1526 pub(crate) fn take_deferred_store_wal_capture() -> DeferredStoreWalActions {
1527 take_deferred_store_wal_capture()
1528 }
1529
1530 pub(crate) fn append_deferred_store_wal_actions(
1531 &self,
1532 actions: DeferredStoreWalActions,
1533 ) -> Result<(), StoreError> {
1534 if actions.actions.is_empty() {
1535 return Ok(());
1536 }
1537 #[cfg(test)]
1538 if self
1539 .test_require_deferred_publication_fence
1540 .swap(false, Ordering::AcqRel)
1541 && !self.vault_publication_lock.is_owned_by_current_thread()
1542 {
1543 return Err(StoreError::Io(io::Error::other(
1544 "deferred WAL missing checkpoint publication fence",
1545 )));
1546 }
1547 #[cfg(test)]
1548 if self
1549 .test_fail_deferred_wal_append
1550 .swap(false, Ordering::AcqRel)
1551 {
1552 return Err(StoreError::Io(io::Error::other(
1553 "injected deferred WAL append failure",
1554 )));
1555 }
1556 if self.config.embedded_wal_path.is_some() {
1557 return self.append_embedded_store_wal_actions(&actions.actions);
1558 }
1559 if let Some(commit) = &self.commit {
1562 let references = commit
1563 .append_checkpoint_actions(&actions.actions)
1564 .map_err(StoreError::Io)?;
1565 self.remember_checkpoint_rows(actions.actions, references);
1566 Ok(())
1567 } else {
1568 self.flush_paged_state()
1569 }
1570 }
1571
1572 pub(crate) fn append_vector_insert_record(
1579 &self,
1580 collection: &str,
1581 entity_id: u64,
1582 vector: &[f32],
1583 ) -> std::io::Result<()> {
1584 let Some(commit) = &self.commit else {
1585 return Ok(());
1586 };
1587 let record = WalRecord::VectorInsert {
1588 collection: collection.to_string(),
1589 entity_id,
1590 vector: vector.to_vec(),
1591 };
1592 commit.append_single_record(record)
1593 }
1594
1595 pub(crate) fn append_probabilistic_delta_record(
1596 &self,
1597 kind: u8,
1598 operation: u8,
1599 name: &str,
1600 operands: Vec<Vec<u8>>,
1601 ) -> std::io::Result<()> {
1602 let Some(commit) = &self.commit else {
1603 return Ok(());
1604 };
1605 let record = WalRecord::ProbabilisticDelta {
1606 kind,
1607 operation,
1608 name: name.to_string(),
1609 operands,
1610 };
1611 commit.append_single_record(record)
1612 }
1613
1614 pub(crate) fn finish_paged_write(
1615 &self,
1616 actions: impl IntoIterator<Item = StoreWalAction>,
1617 ) -> Result<(), StoreError> {
1618 self.finish_paged_write_for_projection(
1619 actions,
1620 self.config.resident_memory_bytes_max.is_some(),
1621 )
1622 }
1623
1624 pub(crate) fn finish_paged_write_for_projection(
1625 &self,
1626 actions: impl IntoIterator<Item = StoreWalAction>,
1627 track: bool,
1628 ) -> Result<(), StoreError> {
1629 let actions: Vec<StoreWalAction> = actions.into_iter().collect();
1630 if deferred_store_wal_capture_active() {
1631 let captured = capture_deferred_store_wal_actions(actions);
1632 debug_assert!(captured);
1633 return Ok(());
1634 }
1635 if self.config.embedded_wal_path.is_some() {
1636 return self.append_embedded_store_wal_actions(&actions);
1637 }
1638 let mut references = super::checkpoint_wal::CheckpointWalBatch::default();
1639 let result = match self.config.durability_mode {
1640 DurabilityMode::Strict => {
1641 if let Some(commit) = &self.commit {
1642 if track {
1643 references = commit
1644 .append_checkpoint_actions(&actions)
1645 .map_err(StoreError::Io)?;
1646 } else {
1647 commit.append_actions(&actions).map_err(StoreError::Io)?;
1648 }
1649 }
1650 self.flush_paged_state()
1651 }
1652 DurabilityMode::WalDurableGrouped | DurabilityMode::Async => {
1653 if let Some(commit) = &self.commit {
1654 if track {
1655 references = commit
1656 .append_checkpoint_actions(&actions)
1657 .map_err(StoreError::Io)?;
1658 } else {
1659 commit.append_actions(&actions).map_err(StoreError::Io)?;
1660 }
1661 Ok(())
1662 } else {
1663 self.flush_paged_state()
1664 }
1665 }
1666 };
1667 result?;
1668 if track {
1669 self.remember_checkpoint_rows(actions, references);
1670 }
1671 Ok(())
1672 }
1673
1674 pub(crate) fn apply_encoded_store_wal_action(&self, payload: &[u8]) -> Result<(), StoreError> {
1675 let action = StoreWalAction::decode(payload).map_err(StoreError::Io)?;
1676 self.apply_replayed_action(&action, true)
1677 }
1678
1679 fn append_embedded_store_wal_actions(
1680 &self,
1681 actions: &[StoreWalAction],
1682 ) -> Result<(), StoreError> {
1683 let Some(path) = self.config.embedded_wal_path.as_deref() else {
1684 return Ok(());
1685 };
1686 if actions.is_empty() {
1687 return Ok(());
1688 }
1689 let payloads: Vec<Vec<u8>> = actions.iter().map(StoreWalAction::encode).collect();
1690 let encoded_len = crate::storage::EmbeddedRdbArtifact::wal_payloads_encoded_len(&payloads)
1691 .map_err(|err| StoreError::Io(std::io::Error::other(err.to_string())))?;
1692 match self.append_embedded_payloads(path, &payloads) {
1693 Ok(_) => Ok(()),
1694 Err(crate::api::RedDBError::InvalidOperation(msg))
1695 if msg.contains("embedded wal region full") =>
1696 {
1697 self.recover_full_embedded_wal(path, &payloads, encoded_len)
1698 }
1699 Err(err) => Err(StoreError::Io(std::io::Error::other(err.to_string()))),
1700 }
1701 }
1702
1703 fn recover_full_embedded_wal(
1704 &self,
1705 path: &Path,
1706 payloads: &[Vec<u8>],
1707 encoded_len: u64,
1708 ) -> Result<(), StoreError> {
1709 let _vault_guard = self.vault_publication_lock.lock();
1710 match self.append_embedded_payloads(path, payloads) {
1714 Ok(_) => return Ok(()),
1715 Err(crate::api::RedDBError::InvalidOperation(message))
1716 if message.contains("embedded wal region full") => {}
1717 Err(error) => return Err(StoreError::Io(std::io::Error::other(error.to_string()))),
1718 }
1719 struct CheckpointFence<'a>(Option<&'a crate::storage::embedded::EmbeddedCommitSession>);
1723 impl Drop for CheckpointFence<'_> {
1724 fn drop(&mut self) {
1725 if let Some(session) = self.0 {
1726 session.fence_interrupted_write();
1727 }
1728 }
1729 }
1730 let mut fence = CheckpointFence(Some(
1731 self.embedded_commit_session
1732 .get()
1733 .expect("WAL-full recovery follows an append attempt"),
1734 ));
1735 let started = Instant::now();
1736 let snapshot = self.to_binary_dump_bytes();
1737 let capacity = encoded_len.max((snapshot.len() as u64).min(EMBEDDED_WAL_HEADROOM_LIMIT));
1738 let checkpoint = crate::storage::EmbeddedRdbArtifact::write_snapshot_with_wal_capacity(
1739 path, &snapshot, capacity,
1740 )
1741 .map_err(|err| StoreError::Io(std::io::Error::other(err.to_string())))?;
1742 fence.0 = None;
1743 tracing::debug!(
1744 target: "reddb::embedded_checkpoint",
1745 snapshot_bytes = snapshot.len(),
1746 wal_region_bytes = checkpoint.manifest.wal_region_bytes,
1747 elapsed_us = started.elapsed().as_micros() as u64,
1748 "embedded WAL overflow checkpoint"
1749 );
1750 self.append_embedded_payloads(path, payloads)
1751 .map(|_| ())
1752 .map_err(|err| StoreError::Io(std::io::Error::other(err.to_string())))
1753 }
1754
1755 fn append_embedded_payloads(
1756 &self,
1757 path: &Path,
1758 payloads: &[Vec<u8>],
1759 ) -> crate::RedDBResult<crate::storage::EmbeddedRdbOpen> {
1760 let session = self
1761 .embedded_commit_session
1762 .get_or_init(|| crate::storage::EmbeddedRdbArtifact::group_commit_session(path));
1763 let options = self.config.group_commit;
1764 let grouped = self.config.durability_mode == DurabilityMode::WalDurableGrouped;
1765 let window = if grouped {
1766 StoreCommitCoordinator::resolve_window(&options)
1767 } else {
1768 Duration::ZERO
1769 };
1770 session.append(
1771 payloads,
1772 reddb_file::embedded::EmbeddedGroupCommitOptions {
1773 window,
1774 max_statements: if grouped { options.max_statements } else { 1 },
1775 max_wal_bytes: options.max_wal_bytes,
1776 },
1777 )
1778 }
1779
1780 pub(super) fn enqueue_grouped_actions(
1781 &self,
1782 actions: &[StoreWalAction],
1783 ) -> Result<Option<crate::storage::embedded::PendingEmbeddedAppend>, StoreError> {
1784 if self.config.durability_mode != DurabilityMode::WalDurableGrouped
1785 || deferred_store_wal_capture_active()
1786 {
1787 return Ok(None);
1788 }
1789 let Some(path) = self.config.embedded_wal_path.as_deref() else {
1790 return Ok(None);
1791 };
1792 let session = self
1793 .embedded_commit_session
1794 .get_or_init(|| crate::storage::EmbeddedRdbArtifact::group_commit_session(path));
1795 let payloads: Vec<Vec<u8>> = actions.iter().map(StoreWalAction::encode).collect();
1796 let options = self.config.group_commit;
1797 session
1798 .enqueue(
1799 &payloads,
1800 reddb_file::embedded::EmbeddedGroupCommitOptions {
1801 window: StoreCommitCoordinator::resolve_window(&options),
1802 max_statements: options.max_statements,
1803 max_wal_bytes: options.max_wal_bytes,
1804 },
1805 )
1806 .map(Some)
1807 .map_err(|error| StoreError::Io(io::Error::other(error.to_string())))
1808 }
1809
1810 pub(super) fn complete_grouped_actions(
1811 &self,
1812 pending: &crate::storage::embedded::PendingEmbeddedAppend,
1813 actions: &[StoreWalAction],
1814 ) -> Result<(), StoreError> {
1815 match pending.wait() {
1816 Ok(_) => Ok(()),
1817 Err(crate::RedDBError::InvalidOperation(message))
1818 if message.contains("embedded wal region full") =>
1819 {
1820 self.append_embedded_store_wal_actions(actions)
1821 }
1822 Err(error) => Err(StoreError::Io(io::Error::other(error.to_string()))),
1823 }
1824 }
1825
1826 pub(crate) fn apply_replayed_action(
1827 &self,
1828 action: &StoreWalAction,
1829 update_btrees: bool,
1830 ) -> Result<(), StoreError> {
1831 reddb_engine::storage::store_wal::apply_replayed_action(
1832 &NativeReplayTarget {
1833 store: self,
1834 update_btrees,
1835 },
1836 action,
1837 )
1838 }
1839
1840 pub fn refresh_collection(
1857 &self,
1858 name: &str,
1859 entities: Vec<UnifiedEntity>,
1860 ) -> Result<Vec<Vec<u8>>, StoreError> {
1861 let name = Self::canonical_collection(name);
1862 let fv = self.format_version();
1863
1864 let new_manager = Arc::new(SegmentManager::with_config(
1867 name,
1868 self.config.manager_config.clone(),
1869 ));
1870
1871 let mut prepared = entities;
1872 for entity in &mut prepared {
1873 entity.canonicalize_system_collection_names();
1874 if entity.id.raw() == 0 {
1875 entity.id = self.next_entity_id();
1876 } else {
1877 self.register_entity_id(entity.id);
1878 }
1879 if let EntityKind::TableRow { ref mut row_id, .. } = entity.kind {
1880 if *row_id == 0 {
1881 *row_id = new_manager.next_row_id();
1882 } else {
1883 new_manager.register_row_id(*row_id);
1884 }
1885 }
1886 entity.ensure_table_logical_id();
1887 }
1888
1889 let serialized: Vec<Vec<u8>> = prepared
1890 .iter()
1891 .map(|e| Self::serialize_entity_record(e, None, fv))
1892 .collect();
1893
1894 new_manager.bulk_insert(prepared.clone()).map_err(|e| {
1895 StoreError::Io(std::io::Error::other(format!(
1896 "refresh_collection: bulk_insert into new manager failed: {e}"
1897 )))
1898 })?;
1899
1900 self.swap_collection_state(name, new_manager, &prepared, &serialized, true);
1901
1902 self.finish_paged_write([StoreWalAction::RefreshCollection {
1903 collection: name.to_string(),
1904 records: serialized.clone(),
1905 }])?;
1906
1907 Ok(serialized)
1908 }
1909
1910 fn swap_collection_state(
1916 &self,
1917 name: &str,
1918 new_manager: Arc<SegmentManager>,
1919 prepared: &[UnifiedEntity],
1920 serialized: &[Vec<u8>],
1921 update_btrees: bool,
1922 ) {
1923 self.resident
1924 .swap_collection_state(name, new_manager, prepared);
1925
1926 if let Some(pager) = self
1927 .pager
1928 .as_ref()
1929 .filter(|_| update_btrees && self.config.resident_memory_bytes_max.is_none())
1930 {
1931 let new_btree = Arc::new(BTree::new(Arc::clone(pager)));
1932 let mut sorted: Vec<(Vec<u8>, Vec<u8>)> = prepared
1933 .iter()
1934 .zip(serialized.iter())
1935 .map(|(e, r)| (e.id.raw().to_be_bytes().to_vec(), r.clone()))
1936 .collect();
1937 sorted.sort_by(|a, b| a.0.cmp(&b.0));
1938 if !sorted.is_empty() {
1939 let _ = new_btree.bulk_insert_sorted(&sorted);
1940 }
1941 self.btree_indices
1942 .write()
1943 .insert(name.to_string(), new_btree);
1944 self.mark_paged_registry_dirty();
1945 }
1946 }
1947
1948 fn apply_replayed_refresh_collection(
1949 &self,
1950 collection: &str,
1951 records: &[Vec<u8>],
1952 update_btrees: bool,
1953 ) -> Result<(), StoreError> {
1954 let collection = Self::canonical_collection(collection);
1955 let (new_manager, prepared) = self.resident.prepare_replayed_refresh_collection(
1956 collection,
1957 records,
1958 self.format_version(),
1959 &self.config.manager_config,
1960 )?;
1961 self.swap_collection_state(collection, new_manager, &prepared, records, update_btrees);
1962 Ok(())
1963 }
1964
1965 pub fn refresh_collection_from_records(
1979 &self,
1980 name: &str,
1981 records: Vec<Vec<u8>>,
1982 ) -> Result<(), StoreError> {
1983 let name = Self::canonical_collection(name);
1984 self.apply_replayed_refresh_collection(name, &records, true)?;
1985 self.finish_paged_write([StoreWalAction::RefreshCollection {
1986 collection: name.to_string(),
1987 records,
1988 }])?;
1989 Ok(())
1990 }
1991
1992 pub(crate) fn create_collection_in_memory(&self, name: &str) -> Result<(), StoreError> {
1993 let name = Self::canonical_collection(name);
1994 self.resident
1995 .create_collection_in_memory(name, &self.config.manager_config)
1996 }
1997
1998 fn drop_collection_in_memory(&self, name: &str) -> Result<(), StoreError> {
1999 let name = Self::canonical_collection(name);
2000 self.resident.drop_collection_in_memory(name, || {
2001 if let Some(tree) = self.btree_indices.write().remove(name) {
2002 self.retire_btree(tree);
2003 }
2004 })
2005 }
2006
2007 fn apply_replayed_upsert(
2008 &self,
2009 collection: &str,
2010 record: &[u8],
2011 update_btrees: bool,
2012 ) -> Result<(), StoreError> {
2013 let collection = Self::canonical_collection(collection);
2014 let id = self.resident.apply_replayed_upsert(
2015 collection,
2016 record,
2017 self.format_version(),
2018 &self.config.manager_config,
2019 )?;
2020
2021 if let Some(pager) = self
2022 .pager
2023 .as_ref()
2024 .filter(|_| update_btrees && self.config.resident_memory_bytes_max.is_none())
2025 {
2026 let mut btree_indices = self.btree_indices.write();
2027 let btree = btree_indices
2028 .entry(collection.to_string())
2029 .or_insert_with(|| Arc::new(BTree::new(Arc::clone(pager))));
2030 let root_before = btree.root_page_id();
2031 let key = id.raw().to_be_bytes();
2032 match btree.insert(&key, record) {
2033 Ok(_) => {}
2034 Err(BTreeError::DuplicateKey) => {
2035 let _ = btree.delete(&key);
2036 let _ = btree.insert(&key, record);
2037 }
2038 Err(err) => {
2039 return Err(StoreError::Io(io::Error::other(format!(
2040 "replay upsert btree error: {err}"
2041 ))));
2042 }
2043 }
2044 if root_before != btree.root_page_id() {
2045 self.mark_paged_registry_dirty();
2046 }
2047 }
2048
2049 Ok(())
2050 }
2051
2052 fn apply_replayed_delete(
2053 &self,
2054 collection: &str,
2055 id: EntityId,
2056 update_btrees: bool,
2057 ) -> Result<(), StoreError> {
2058 let collection = Self::canonical_collection(collection);
2059 self.resident.apply_replayed_delete(collection, id, || {
2060 if self.pager.is_some()
2061 && update_btrees
2062 && self.config.resident_memory_bytes_max.is_none()
2063 {
2064 let btree_indices = self.btree_indices.read();
2065 if let Some(btree) = btree_indices.get(collection) {
2066 let root_before = btree.root_page_id();
2067 let key = id.raw().to_be_bytes();
2068 let _ = btree.delete(&key);
2069 if root_before != btree.root_page_id() {
2070 self.mark_paged_registry_dirty();
2071 }
2072 }
2073 }
2074 })
2075 }
2076}
2077
2078#[cfg(all(test, target_os = "linux"))]
2079#[path = "storage_apocalypse.rs"]
2080mod storage_apocalypse;
2081
2082#[cfg(test)]
2083mod tests {
2084 use super::*;
2085 use crate::api::{DurabilityMode, GroupCommitOptions};
2086 use std::sync::{Barrier, Mutex as StdMutex, OnceLock};
2087 use std::time::SystemTime;
2088
2089 #[test]
2090 fn runtime_wal_retention_serializes_concurrent_appends() {
2091 let directory = tempfile::tempdir().expect("temporary WAL directory");
2092 let path = directory.path().join("data.wal");
2093 let coordinator = Arc::new(
2094 StoreCommitCoordinator::open(
2095 &path,
2096 DurabilityMode::WalDurableGrouped,
2097 GroupCommitOptions::default(),
2098 )
2099 .expect("coordinator"),
2100 );
2101 let barrier = Arc::new(Barrier::new(5));
2102 let mut workers = Vec::new();
2103 for worker in 0..4u8 {
2104 let coordinator = Arc::clone(&coordinator);
2105 let barrier = Arc::clone(&barrier);
2106 workers.push(std::thread::spawn(move || {
2107 barrier.wait();
2108 for index in 0..20u8 {
2109 coordinator
2110 .append_single_record(WalRecord::ProbabilisticDelta {
2111 kind: 1,
2112 operation: 1,
2113 name: "visitors".into(),
2114 operands: vec![vec![worker, index]],
2115 })
2116 .expect("append delta");
2117 }
2118 }));
2119 }
2120 barrier.wait();
2121 for _ in 0..5 {
2122 coordinator.truncate().expect("retain while appending");
2123 }
2124 for worker in workers {
2125 worker.join().expect("worker");
2126 }
2127 coordinator.truncate().expect("final retention");
2128 let entries: std::collections::HashSet<_> = WalReader::open(&path)
2129 .expect("reader")
2130 .iter()
2131 .map(|entry| match entry.expect("record").1 {
2132 WalRecord::ProbabilisticDelta { operands, .. } => operands[0].clone(),
2133 _ => panic!("unexpected retained record"),
2134 })
2135 .collect();
2136 assert_eq!(entries.len(), 80);
2137 }
2138
2139 #[test]
2140 fn runtime_wal_retention_preserves_exact_frames_and_append_order() {
2141 use crate::storage::wal::record::WalRecordAuthority;
2142 for mode in [
2143 DurabilityMode::Strict,
2144 DurabilityMode::WalDurableGrouped,
2145 DurabilityMode::Async,
2146 ] {
2147 let directory = tempfile::tempdir().expect("temporary WAL directory");
2148 let path = directory.path().join("data.wal");
2149 let record = WalRecord::ProbabilisticDelta {
2150 kind: 1,
2151 operation: 1,
2152 name: "visitors".into(),
2153 operands: vec![b"alice".to_vec()],
2154 };
2155 let mut frame = Vec::new();
2156 record.encode_with_authority_into(
2157 &mut frame,
2158 WalRecordAuthority {
2159 term: 42,
2160 ownership_epoch: Some(9),
2161 },
2162 );
2163 {
2164 let mut writer = WalWriter::open(&path).expect("WAL writer");
2165 writer
2166 .append(&WalRecord::Begin { tx_id: 1 })
2167 .expect("logical begin");
2168 writer.append_bytes(&frame).expect("runtime record");
2169 writer
2170 .append(&WalRecord::Commit { tx_id: 1 })
2171 .expect("logical commit");
2172 writer.sync().expect("sync");
2173 }
2174 let coordinator =
2175 StoreCommitCoordinator::open(&path, mode, GroupCommitOptions::default())
2176 .expect("coordinator");
2177 coordinator.truncate().expect("retain runtime records");
2178 let mut expected = reddb_file::encode_wal_file_header().to_vec();
2179 expected.extend_from_slice(&frame);
2180 assert_eq!(std::fs::read(&path).expect("retained WAL"), expected);
2181 let later = WalRecord::ProbabilisticDelta {
2182 kind: 1,
2183 operation: 1,
2184 name: "visitors".into(),
2185 operands: vec![b"bob".to_vec()],
2186 };
2187 coordinator
2188 .append_single_record(later.clone())
2189 .expect("append after rewrite");
2190 coordinator.truncate().expect("retain queued record");
2191 let records: Vec<_> = WalReader::open(&path)
2192 .expect("reader")
2193 .iter()
2194 .map(|entry| entry.expect("record").1)
2195 .collect();
2196 assert_eq!(records, vec![record, later]);
2197 coordinator
2198 .truncate_after_runtime_checkpoint()
2199 .expect("runtime snapshot permits truncation");
2200 assert_eq!(
2201 std::fs::read(&path).expect("checkpointed WAL"),
2202 reddb_file::encode_wal_file_header()
2203 );
2204 }
2205 }
2206
2207 #[test]
2208 fn runtime_wal_rewrite_survives_interruption() {
2209 const TEST: &str =
2210 "storage::unified::store::commit::tests::runtime_wal_rewrite_survives_interruption";
2211 const CHILD: &str = "REDDB_TEST_RUNTIME_WAL_REWRITE_PATH";
2212 if let Some(path) = std::env::var_os(CHILD) {
2213 let coordinator = StoreCommitCoordinator::open(
2214 path,
2215 DurabilityMode::Strict,
2216 GroupCommitOptions::default(),
2217 )
2218 .expect("child coordinator");
2219 coordinator.truncate().expect("fault injection");
2220 panic!("interruption point was not reached");
2221 }
2222 for (hook, code, replaced) in [
2223 ("REDDB_TEST_EXIT_BEFORE_RUNTIME_WAL_RENAME", 211, false),
2224 ("REDDB_TEST_EXIT_AFTER_RUNTIME_WAL_RENAME", 212, true),
2225 ] {
2226 let directory = tempfile::tempdir().expect("temporary WAL directory");
2227 let path = directory.path().join("data.wal");
2228 let record = WalRecord::ProbabilisticDelta {
2229 kind: 1,
2230 operation: 1,
2231 name: "visitors".into(),
2232 operands: vec![b"alice".to_vec()],
2233 };
2234 {
2235 let mut writer = WalWriter::open(&path).expect("WAL writer");
2236 writer
2237 .append(&WalRecord::Begin { tx_id: 1 })
2238 .expect("begin");
2239 writer.append(&record).expect("runtime record");
2240 writer
2241 .append(&WalRecord::Commit { tx_id: 1 })
2242 .expect("commit");
2243 writer.sync().expect("sync");
2244 }
2245 let original = std::fs::read(&path).expect("original WAL");
2246 let child =
2247 std::process::Command::new(std::env::current_exe().expect("test executable"))
2248 .args(["--exact", TEST, "--nocapture"])
2249 .env(CHILD, &path)
2250 .env(hook, "1")
2251 .output()
2252 .expect("child");
2253 assert_eq!(
2254 child.status.code(),
2255 Some(code),
2256 "{}",
2257 String::from_utf8_lossy(&child.stderr)
2258 );
2259 let after = std::fs::read(&path).expect("WAL after interruption");
2260 if replaced {
2261 let mut expected = reddb_file::encode_wal_file_header().to_vec();
2262 expected.extend_from_slice(&record.encode());
2263 assert_eq!(after, expected);
2264 } else {
2265 assert_eq!(after, original);
2266 }
2267 let coordinator = StoreCommitCoordinator::open(
2268 &path,
2269 DurabilityMode::Strict,
2270 GroupCommitOptions::default(),
2271 )
2272 .expect("reopen");
2273 coordinator.truncate().expect("retry rewrite");
2274 let records: Vec<_> = WalReader::open(&path)
2275 .expect("reader")
2276 .iter()
2277 .map(|entry| entry.expect("record").1)
2278 .collect();
2279 assert_eq!(records, vec![record]);
2280 }
2281 }
2282
2283 #[test]
2284 fn ownership_epoch_rejects_nested_begin_and_wrong_finish() {
2285 let directory = tempfile::tempdir().expect("temporary store");
2286 let store = UnifiedStore::open(directory.path().join("data.rdb")).expect("store");
2287 store.create_collection("rows").expect("collection");
2288 store.persist().expect("baseline");
2289 let coordinator = store.commit.as_ref().expect("WAL coordinator");
2290 assert!(coordinator.begin_page_ownership_epoch(&store, 0).is_err());
2291 assert!(coordinator.finish_page_ownership_epoch(&store, 1).is_err());
2292 coordinator
2293 .begin_page_ownership_epoch(&store, 2)
2294 .expect("begin epoch");
2295 assert!(coordinator.begin_page_ownership_epoch(&store, 3).is_err());
2296 assert!(coordinator.finish_page_ownership_epoch(&store, 3).is_err());
2297 assert!(coordinator.finish_page_ownership_epoch(&store, 2).is_err());
2298 store.flush_paged_registry().expect("publish epoch");
2299 coordinator
2300 .finish_page_ownership_epoch(&store, 2)
2301 .expect("finish epoch");
2302 assert_eq!(store.ownership_active_epoch.load(Ordering::Acquire), 0);
2303 }
2304
2305 #[test]
2306 fn native_pager_records_page_ownership_in_durable_wal() {
2307 use reddb_file::page_ownership_journal::PageOwnershipRecord;
2308
2309 let directory = tempfile::tempdir().expect("temporary store");
2310 let path = directory.path().join("data.rdb");
2311 let wal_path = reddb_file::layout::unified_wal_path(&path);
2312 let pager = crate::storage::engine::Pager::open(&path, Default::default())
2313 .expect("open native pager");
2314 let coordinator = Arc::new(
2315 StoreCommitCoordinator::open(
2316 &wal_path,
2317 DurabilityMode::Async,
2318 GroupCommitOptions::default(),
2319 )
2320 .expect("open WAL"),
2321 );
2322 let recorder = Arc::new(
2323 StoreOwnershipRecorder::begin(Arc::clone(&coordinator), 7).expect("begin epoch"),
2324 );
2325 pager
2326 .install_ownership_recorder(recorder)
2327 .expect("install recorder");
2328 let page = pager
2329 .allocate_page(crate::storage::engine::PageType::BTreeLeaf)
2330 .expect("allocate")
2331 .page_id();
2332 pager
2333 .record_retirement_intent(&[page])
2334 .expect("record retirement");
2335 pager.free_retired_pages(&[page]).expect("retire");
2336 let records: Vec<_> = WalReader::open(&wal_path)
2337 .expect("read WAL")
2338 .iter()
2339 .map(|item| item.expect("WAL record").1)
2340 .collect();
2341 assert_eq!(
2342 records,
2343 vec![
2344 WalRecord::PageOwnership {
2345 record: PageOwnershipRecord::Begin { epoch: 7 }
2346 },
2347 WalRecord::PageOwnership {
2348 record: PageOwnershipRecord::Reserve {
2349 epoch: 7,
2350 page_id: page
2351 }
2352 },
2353 WalRecord::PageOwnership {
2354 record: PageOwnershipRecord::Retire {
2355 epoch: 7,
2356 page_id: page
2357 }
2358 },
2359 WalRecord::PageOwnership {
2360 record: PageOwnershipRecord::Retire {
2361 epoch: 7,
2362 page_id: page
2363 }
2364 },
2365 ]
2366 );
2367 assert!(
2368 coordinator.fsync_count() >= 3,
2369 "async ownership writes must sync"
2370 );
2371 assert!(
2372 coordinator.truncate().is_err(),
2373 "ownership WAL remains protected"
2374 );
2375 }
2376
2377 #[test]
2378 fn replay_reclaims_unpublished_ownership_reservations() {
2379 use reddb_file::page_ownership_journal::PageOwnershipRecord;
2380
2381 let directory = tempfile::tempdir().expect("temporary store");
2382 let path = directory.path().join("data.rdb");
2383 {
2384 let store = UnifiedStore::open(&path).expect("create store");
2385 store.create_collection("rows").expect("collection");
2386 store.persist().expect("persist baseline");
2387 }
2388 let wal_path = reddb_file::layout::unified_wal_path(&path);
2389 let ids;
2390 {
2391 let pager =
2392 crate::storage::engine::Pager::open(&path, Default::default()).expect("pager");
2393 let first = pager.page_count().expect("page count");
2394 let coordinator = StoreCommitCoordinator::open(
2395 &wal_path,
2396 DurabilityMode::Strict,
2397 GroupCommitOptions::default(),
2398 )
2399 .expect("WAL");
2400 coordinator
2401 .append_durable_page_ownership_batch(&[
2402 PageOwnershipRecord::Begin { epoch: 2 },
2403 PageOwnershipRecord::Reserve {
2404 epoch: 2,
2405 page_id: first,
2406 },
2407 PageOwnershipRecord::Reserve {
2408 epoch: 2,
2409 page_id: first + 1,
2410 },
2411 ])
2412 .expect("reserve pages durably");
2413 ids = [
2414 pager
2415 .allocate_page(crate::storage::engine::PageType::BTreeLeaf)
2416 .expect("allocate")
2417 .page_id(),
2418 pager
2419 .allocate_page(crate::storage::engine::PageType::BTreeLeaf)
2420 .expect("allocate")
2421 .page_id(),
2422 ];
2423 assert_eq!(ids, [first, first + 1]);
2424 pager.sync().expect("persist abandoned pages");
2425 }
2426 let reopened = UnifiedStore::open(&path).expect("recover ownership");
2427 let pager = reopened.pager.as_ref().expect("pager");
2428 let a = pager
2429 .allocate_page(crate::storage::engine::PageType::BTreeLeaf)
2430 .expect("reuse")
2431 .page_id();
2432 let b = pager
2433 .allocate_page(crate::storage::engine::PageType::BTreeLeaf)
2434 .expect("reuse")
2435 .page_id();
2436 assert_ne!(a, b);
2437 assert!(ids.contains(&a) && ids.contains(&b));
2438 let records: Vec<_> = WalReader::open(&wal_path)
2439 .expect("read WAL")
2440 .iter()
2441 .collect::<io::Result<Vec<_>>>()
2442 .expect("records");
2443 assert!(matches!(
2444 records.last(),
2445 Some((
2446 _,
2447 WalRecord::PageOwnership {
2448 record: PageOwnershipRecord::Finish { epoch: 2 }
2449 }
2450 ))
2451 ));
2452 reopened
2453 .commit
2454 .as_ref()
2455 .expect("coordinator")
2456 .truncate()
2457 .expect("completed ownership can be truncated");
2458 }
2459
2460 #[cfg(target_os = "linux")]
2461 #[test]
2462 fn ownership_recovery_survives_interruption_and_retry() {
2463 use reddb_file::page_ownership_journal::PageOwnershipRecord;
2464
2465 const CHILD: &str = "REDDB_EXPERIMENTAL_OWNERSHIP_RECOVERY_PATH";
2466 if let Some(path) = std::env::var_os(CHILD) {
2467 let _store = UnifiedStore::open(path).expect("recover store");
2468 panic!("recovery interruption hook was not reached");
2469 }
2470 for (published, hook, exit_code) in [
2471 (
2472 false,
2473 "REDDB_EXPERIMENTAL_EXIT_DURING_OWNERSHIP_RECOVERY",
2474 187,
2475 ),
2476 (false, "REDDB_EXPERIMENTAL_EXIT_AFTER_OWNERSHIP_SYNC", 188),
2477 (false, "REDDB_EXPERIMENTAL_EXIT_AFTER_OWNERSHIP_FINISH", 189),
2478 (
2479 true,
2480 "REDDB_EXPERIMENTAL_EXIT_DURING_OWNERSHIP_RECOVERY",
2481 187,
2482 ),
2483 (true, "REDDB_EXPERIMENTAL_EXIT_AFTER_OWNERSHIP_SYNC", 188),
2484 (true, "REDDB_EXPERIMENTAL_EXIT_AFTER_OWNERSHIP_FINISH", 189),
2485 ] {
2486 let directory = tempfile::tempdir().expect("temporary store");
2487 let path = directory.path().join("data.rdb");
2488 {
2489 let store = UnifiedStore::open(&path).expect("create store");
2490 store.create_collection("rows").expect("collection");
2491 store.persist().expect("baseline");
2492 if published {
2493 store.ownership_active_epoch.store(2, Ordering::Release);
2494 store.flush_paged_registry().expect("publish epoch");
2495 store.ownership_active_epoch.store(0, Ordering::Release);
2496 }
2497 }
2498 let wal_path = reddb_file::layout::unified_wal_path(&path);
2499 let ids;
2500 {
2501 let pager =
2502 crate::storage::engine::Pager::open(&path, Default::default()).expect("pager");
2503 let first = pager.page_count().expect("page count");
2504 let coordinator = StoreCommitCoordinator::open(
2505 &wal_path,
2506 DurabilityMode::Strict,
2507 GroupCommitOptions::default(),
2508 )
2509 .expect("WAL");
2510 let intent = |page_id| {
2511 if published {
2512 PageOwnershipRecord::Retire { epoch: 2, page_id }
2513 } else {
2514 PageOwnershipRecord::Reserve { epoch: 2, page_id }
2515 }
2516 };
2517 coordinator
2518 .append_durable_page_ownership_batch(&[
2519 PageOwnershipRecord::Begin { epoch: 2 },
2520 intent(first),
2521 intent(first + 1),
2522 ])
2523 .expect("reserve");
2524 ids = [
2525 pager
2526 .allocate_page(crate::storage::engine::PageType::BTreeLeaf)
2527 .expect("allocate")
2528 .page_id(),
2529 pager
2530 .allocate_page(crate::storage::engine::PageType::BTreeLeaf)
2531 .expect("allocate")
2532 .page_id(),
2533 ];
2534 pager.sync().expect("persist abandoned pages");
2535 }
2536 for _ in 0..if exit_code == 189 { 1 } else { 2 } {
2537 let output = std::process::Command::new(std::env::current_exe().expect("test executable"))
2538 .args(["--exact", "storage::unified::store::commit::tests::ownership_recovery_survives_interruption_and_retry", "--nocapture"])
2539 .env(CHILD, &path)
2540 .env(hook, "1")
2541 .output().expect("run interrupted recovery");
2542 assert_eq!(
2543 output.status.code(),
2544 Some(exit_code),
2545 "published={published} {hook}: {}",
2546 String::from_utf8_lossy(&output.stderr)
2547 );
2548 }
2549 let reopened = UnifiedStore::open(&path).expect("recover after interruptions");
2550 let pager = reopened.pager.as_ref().expect("pager");
2551 let a = pager
2552 .allocate_page(crate::storage::engine::PageType::BTreeLeaf)
2553 .expect("reuse")
2554 .page_id();
2555 let b = pager
2556 .allocate_page(crate::storage::engine::PageType::BTreeLeaf)
2557 .expect("reuse")
2558 .page_id();
2559 assert_ne!(a, b, "published={published} {hook}: duplicate page");
2560 assert!(
2561 ids.contains(&a) && ids.contains(&b),
2562 "published={published} {hook}: lost recovered page"
2563 );
2564 }
2565 }
2566
2567 #[test]
2568 fn ownership_intents_are_durable_in_every_mode() {
2569 use reddb_file::page_ownership_journal::PageOwnershipRecord;
2570
2571 for mode in [
2572 DurabilityMode::Strict,
2573 DurabilityMode::WalDurableGrouped,
2574 DurabilityMode::Async,
2575 ] {
2576 let directory = tempfile::tempdir().expect("temporary WAL");
2577 let path = directory.path().join("ownership");
2578 let coordinator =
2579 StoreCommitCoordinator::open(&path, mode, GroupCommitOptions::default())
2580 .expect("open coordinator");
2581 let intent = PageOwnershipRecord::Reserve {
2582 epoch: 1,
2583 page_id: 67,
2584 };
2585 let detach = PageOwnershipRecord::DetachFree {
2586 epoch: 1,
2587 page_id: 68,
2588 };
2589 coordinator
2590 .append_durable_page_ownership_batch(&[intent, detach])
2591 .expect("durable intent");
2592 assert!(
2593 coordinator.fsync_count() > 0,
2594 "ownership intent must be synced in {mode:?}"
2595 );
2596 let records: Vec<_> = WalReader::open(&path)
2597 .expect("open WAL")
2598 .iter()
2599 .collect::<io::Result<Vec<_>>>()
2600 .expect("read records");
2601 assert!(
2602 matches!(records.as_slice(), [(_, WalRecord::PageOwnership { record: first }), (_, WalRecord::PageOwnership { record: second })] if *first == intent && *second == detach)
2603 );
2604 assert!(coordinator
2605 .append_single_record(WalRecord::PageOwnership { record: intent })
2606 .is_err());
2607 assert!(coordinator
2608 .append_durable_page_ownership(PageOwnershipRecord::Begin { epoch: 0 })
2609 .is_err());
2610 assert!(
2611 coordinator.truncate().is_err(),
2612 "active ownership must prevent truncation"
2613 );
2614 let before = std::fs::read(&path).expect("ownership WAL");
2615 drop(coordinator);
2616 let reopened = StoreCommitCoordinator::open(&path, mode, GroupCommitOptions::default())
2617 .expect("reopen coordinator");
2618 assert!(
2619 reopened.truncate().is_err(),
2620 "reopen must rediscover ownership records"
2621 );
2622 assert_eq!(std::fs::read(&path).expect("preserved WAL"), before);
2623 }
2624 }
2625
2626 #[test]
2627 fn unresolved_ownership_intent_blocks_store_replay() {
2628 for truncated in [false, true] {
2629 let directory = tempfile::tempdir().expect("temporary store");
2630 let path = directory.path().join("data.rdb");
2631 {
2632 let store = UnifiedStore::open(&path).expect("create store");
2633 store.create_collection("rows").expect("create collection");
2634 store.persist().expect("persist baseline");
2635 }
2636 let wal_path = reddb_file::layout::unified_wal_path(&path);
2637 {
2638 let mut writer = WalWriter::open(&wal_path).expect("open WAL");
2639 writer
2640 .append(&WalRecord::PageOwnership {
2641 record: reddb_file::page_ownership_journal::PageOwnershipRecord::Reserve {
2642 epoch: 1,
2643 page_id: 67,
2644 },
2645 })
2646 .expect("append ownership intent");
2647 writer.sync().expect("sync ownership intent");
2648 }
2649 if truncated {
2650 let wal = std::fs::OpenOptions::new()
2651 .write(true)
2652 .open(&wal_path)
2653 .expect("WAL");
2654 let length = wal.metadata().expect("length").len();
2655 wal.set_len(length - 1).expect("truncate last byte");
2656 wal.sync_all().expect("sync truncation");
2657 }
2658 let before = std::fs::read(&path).expect("baseline data");
2659 let error = match UnifiedStore::open(&path) {
2660 Ok(_) => panic!("unresolved ownership must prevent opening"),
2661 Err(error) => error,
2662 };
2663 let expected = if truncated {
2664 "incomplete page ownership WAL record"
2665 } else {
2666 "page ownership journal requires recovery"
2667 };
2668 assert!(error.to_string().contains(expected), "{error}");
2669 assert_eq!(std::fs::read(&path).expect("preserved data"), before);
2670 }
2671 }
2672
2673 #[test]
2674 fn logical_wal_replay_restores_a_deleted_physical_identity() {
2675 for flat in [false, true] {
2676 let store = UnifiedStore::new();
2677 store.create_collection("replay").expect("collection");
2678 let id = EntityId::new(42);
2679 let entity = UnifiedEntity::table_row(id, "replay", 42, vec![Value::Integer(1)]);
2680 if flat {
2681 store
2682 .bulk_insert("replay", vec![entity])
2683 .expect("flat seed");
2684 } else {
2685 store.insert_auto("replay", entity).expect("hash seed");
2686 }
2687 let metadata = Metadata::with_fields(HashMap::from([(
2688 "source".into(),
2689 MetadataValue::String("wal".into()),
2690 )]));
2691 for sequence in 100..104 {
2692 let delete = StoreWalAction::DeleteEntityRecord {
2693 collection: "replay".into(),
2694 entity_id: id.raw(),
2695 };
2696 store
2697 .apply_encoded_store_wal_action(&delete.encode())
2698 .expect("replay delete");
2699 let mut restored =
2700 UnifiedEntity::table_row(id, "replay", 42, vec![Value::Integer(2)]);
2701 restored.ensure_table_logical_id();
2702 restored.sequence_id = sequence;
2703 let upsert = StoreWalAction::upsert_entity(
2704 "replay",
2705 &restored,
2706 Some(&metadata),
2707 store.format_version(),
2708 );
2709 store
2710 .apply_encoded_store_wal_action(&upsert.encode())
2711 .expect("replay upsert");
2712 let manager = store.get_collection("replay").expect("restored collection");
2713 let stored = manager.get(id).expect("restored entity is readable");
2714 assert_eq!(store.entity_cache_stats().entries, 0);
2715 assert_eq!(manager.count(), 1);
2716 assert_eq!(manager.query_all(|_| true).len(), 1);
2717 assert_eq!(stored.sequence_id, sequence);
2718 assert_eq!(
2719 UnifiedStore::serialize_entity_record(
2720 &stored,
2721 manager.get_metadata(id).as_ref(),
2722 store.format_version()
2723 ),
2724 UnifiedStore::serialize_entity_record(
2725 &restored,
2726 Some(&metadata),
2727 store.format_version()
2728 )
2729 );
2730 assert!(store.get_any(id).is_some());
2731 }
2732 }
2733 }
2734
2735 fn embedded_fixture(
2736 mode: DurabilityMode,
2737 background_bytes: usize,
2738 ) -> (tempfile::TempDir, PathBuf, UnifiedStore) {
2739 let directory = tempfile::tempdir().expect("directory");
2740 let path = directory.path().join("data.rdb");
2741 let mut store =
2742 UnifiedStore::with_config(UnifiedStoreConfig::default().with_durability_mode(mode));
2743 store.create_collection("keys").expect("collection");
2744 store.set_aux_metadata(vec![42; background_bytes]);
2745 crate::storage::EmbeddedRdbArtifact::create_with_snapshot(
2746 &path,
2747 &store.to_binary_dump_bytes(),
2748 )
2749 .expect("snapshot");
2750 store.config.embedded_wal_path = Some(path.clone());
2751 (directory, path, store)
2752 }
2753
2754 #[test]
2755 fn embedded_stale_full_result_does_not_checkpoint_reclaimed_space() {
2756 for mode in [DurabilityMode::Strict, DurabilityMode::WalDurableGrouped] {
2757 let (_directory, path, store) = embedded_fixture(mode, 0);
2758 store
2759 .append_embedded_store_wal_actions(&[StoreWalAction::CreateCollection {
2760 name: "keys".into(),
2761 }])
2762 .expect("another writer appended after reclaiming space");
2763 let before = crate::storage::EmbeddedRdbArtifact::open(&path).expect("before");
2764 let payloads = vec![StoreWalAction::CreateCollection {
2765 name: "after".into(),
2766 }
2767 .encode()];
2768 let length = crate::storage::EmbeddedRdbArtifact::wal_payloads_encoded_len(&payloads)
2769 .expect("encoded length");
2770 store
2772 .recover_full_embedded_wal(&path, &payloads, length)
2773 .expect("revalidate after acquiring publication");
2774 let after = crate::storage::EmbeddedRdbArtifact::open(&path).expect("after");
2775 assert_eq!(
2776 after.selected_superblock.generation,
2777 before.selected_superblock.generation + 1,
2778 "only the retry is published, without an unnecessary checkpoint"
2779 );
2780 assert_eq!(
2781 crate::storage::EmbeddedRdbArtifact::read_wal_payloads(&after)
2782 .expect("both appends retained")
2783 .len(),
2784 2
2785 );
2786 }
2787 }
2788
2789 #[test]
2790 fn embedded_full_wal_reserves_bounded_headroom_for_populated_store() {
2791 for mode in [DurabilityMode::Strict, DurabilityMode::WalDurableGrouped] {
2792 let (_directory, path, store) = embedded_fixture(mode, 2 * 1024 * 1024);
2793 let initial = crate::storage::EmbeddedRdbArtifact::open(&path).expect("initial");
2794 let action = StoreWalAction::CreateCollection {
2795 name: "keys".into(),
2796 };
2797 let payloads = vec![action.encode()];
2798 let length = crate::storage::EmbeddedRdbArtifact::wal_payloads_encoded_len(&payloads)
2799 .expect("encoded length");
2800 crate::storage::EmbeddedRdbArtifact::append_wal_payloads(
2801 &path,
2802 &vec![payloads[0].clone(); (initial.manifest.wal_region_bytes / length) as usize],
2803 )
2804 .expect("fill the initial region with valid records");
2805 store
2806 .append_embedded_store_wal_actions(&[action])
2807 .expect("overflow");
2808 let open = crate::storage::EmbeddedRdbArtifact::open(&path).expect("checkpoint");
2809 assert_eq!(open.manifest.wal_region_bytes, 1024 * 1024);
2810 let snapshot = crate::storage::EmbeddedRdbArtifact::read_snapshot(&open)
2811 .expect("snapshot")
2812 .expect("populated image");
2813 let recovered =
2814 UnifiedStore::load_from_bytes_with_config(&snapshot, UnifiedStoreConfig::default())
2815 .expect("recover snapshot");
2816 for payload in
2817 crate::storage::EmbeddedRdbArtifact::read_wal_payloads(&open).expect("WAL")
2818 {
2819 recovered
2820 .apply_encoded_store_wal_action(&payload)
2821 .expect("replay");
2822 }
2823 assert_eq!(
2824 recovered.resident.aux_metadata.read().len(),
2825 2 * 1024 * 1024
2826 );
2827 assert!(recovered.get_collection("keys").is_some());
2828 }
2829 }
2830
2831 #[test]
2832 fn embedded_grouped_overwrites_recover_across_repeated_full_wal_regions() {
2833 fn row(writer: usize, round: usize) -> UnifiedEntity {
2834 let mut state = (writer as u64 + 1) * 31 + round as u64 * 13;
2835 let payload: String = (0..4096)
2836 .map(|_| {
2837 state = state.wrapping_mul(1664525).wrapping_add(1013904223);
2838 char::from(b'!' + ((state >> 24) % 90) as u8)
2839 })
2840 .collect();
2841 let mut entity = UnifiedEntity::table_row(EntityId::new(0), "keys", 0, vec![]);
2842 entity.data = EntityData::Row(RowData::with_names(
2843 vec![
2844 Value::text(format!("k{writer}")),
2845 Value::text(format!("{round}:{payload}")),
2846 ],
2847 vec!["key".into(), "value".into()],
2848 ));
2849 entity
2850 }
2851 let (_directory, path, store) =
2852 embedded_fixture(DurabilityMode::WalDurableGrouped, 2 * 1024 * 1024);
2853 assert!(
2854 StoreWalAction::upsert_entity("keys", &row(0, 0), None, store.format_version())
2855 .encode()
2856 .len()
2857 >= 2048,
2858 "fixture must exert physical WAL pressure after value compression"
2859 );
2860 let mut ids: Vec<_> = (0..8)
2861 .map(|writer| {
2862 store
2863 .write_keyed_row("keys", row(writer, 0), None, None)
2864 .expect("seed")
2865 })
2866 .collect();
2867 let initial = crate::storage::EmbeddedRdbArtifact::open(&path).expect("initial roots");
2868 let mut previous_snapshot = crate::storage::EmbeddedRdbArtifact::read_snapshot(&initial)
2869 .expect("initial snapshot")
2870 .expect("image");
2871 for wave in 0..3 {
2875 let barrier = Barrier::new(ids.len());
2876 ids = std::thread::scope(|scope| {
2877 let workers: Vec<_> = ids
2878 .iter()
2879 .enumerate()
2880 .map(|(writer, previous)| {
2881 let store = &store;
2882 let barrier = &barrier;
2883 scope.spawn(move || {
2884 barrier.wait();
2885 let mut id = *previous;
2886 for round in 0..64 {
2887 id = store
2888 .write_keyed_row(
2889 "keys",
2890 row(writer, wave * 64 + round),
2891 None,
2892 Some(id),
2893 )
2894 .expect("durable overwrite");
2895 }
2896 id
2897 })
2898 })
2899 .collect();
2900 workers
2901 .into_iter()
2902 .map(|worker| worker.join().expect("writer"))
2903 .collect()
2904 });
2905 let open = crate::storage::EmbeddedRdbArtifact::open(&path).expect("wave roots");
2906 assert_eq!(open.manifest.wal_region_bytes, EMBEDDED_WAL_HEADROOM_LIMIT);
2907 let snapshot = crate::storage::EmbeddedRdbArtifact::read_snapshot(&open)
2908 .expect("wave snapshot")
2909 .expect("image");
2910 assert!(snapshot != previous_snapshot, "wave {wave} must checkpoint");
2911 previous_snapshot = snapshot;
2912 }
2913 let open = crate::storage::EmbeddedRdbArtifact::open(&path).expect("recover roots");
2914 let snapshot = crate::storage::EmbeddedRdbArtifact::read_snapshot(&open)
2915 .expect("snapshot")
2916 .expect("image");
2917 let recovered =
2918 UnifiedStore::load_from_bytes_with_config(&snapshot, UnifiedStoreConfig::default())
2919 .expect("snapshot replay");
2920 for payload in crate::storage::EmbeddedRdbArtifact::read_wal_payloads(&open).expect("WAL") {
2921 recovered
2922 .apply_encoded_store_wal_action(&payload)
2923 .expect("WAL replay");
2924 }
2925 assert_eq!(recovered.get_collection("keys").expect("keys").count(), 8);
2926 for (writer, id) in ids.iter().enumerate() {
2927 let entity = recovered
2928 .get("keys", *id)
2929 .expect("last acknowledged version");
2930 assert_eq!(
2931 entity.data.as_row().expect("row").get_field("value"),
2932 row(writer, 191)
2933 .data
2934 .as_row()
2935 .expect("expected row")
2936 .get_field("value")
2937 );
2938 }
2939 }
2940
2941 fn env_lock() -> &'static StdMutex<()> {
2946 static LOCK: OnceLock<StdMutex<()>> = OnceLock::new();
2947 LOCK.get_or_init(|| StdMutex::new(()))
2948 }
2949
2950 fn temp_wal(name: &str) -> PathBuf {
2951 let nanos = SystemTime::now()
2952 .duration_since(std::time::UNIX_EPOCH)
2953 .unwrap()
2954 .as_nanos();
2955 let path = reddb_file::layout::store_commit_coord_temp_wal_path(
2956 &std::env::temp_dir(),
2957 name,
2958 std::process::id(),
2959 nanos,
2960 );
2961 let _ = std::fs::remove_file(&path);
2962 path
2963 }
2964
2965 fn replay_crash_payload(id: u64, round: u64) -> String {
2966 let mut state = id * 31 + round * 13 + 1;
2967 (0..600)
2968 .map(|_| {
2969 state = state.wrapping_mul(1664525).wrapping_add(1013904223);
2970 char::from(b'!' + ((state >> 24) % 90) as u8)
2971 })
2972 .collect()
2973 }
2974
2975 #[cfg(target_os = "linux")]
2976 #[test]
2977 fn interrupted_replay_reclaims_unpublished_pages() {
2978 interrupted_replay_reclaims_pages(false, false);
2979 }
2980
2981 #[cfg(target_os = "linux")]
2982 #[test]
2983 fn interrupted_replay_reclaims_durable_unpublished_pages() {
2984 interrupted_replay_reclaims_pages(false, true);
2985 }
2986
2987 #[cfg(target_os = "linux")]
2988 #[test]
2989 fn interrupted_replay_preserves_published_pages() {
2990 interrupted_replay_reclaims_pages(true, false);
2991 }
2992
2993 #[cfg(target_os = "linux")]
2994 #[test]
2995 fn interrupted_checkpoint_reclaims_unpublished_pages() {
2996 const CHILD_PATH: &str = "REDDB_EXPERIMENTAL_CHECKPOINT_REBUILD_PATH";
2997 let config = || {
2998 UnifiedStoreConfig::default()
2999 .with_durability_mode(DurabilityMode::Strict)
3000 .with_page_cache_slots(4)
3001 };
3002 if let Some(path) = std::env::var_os(CHILD_PATH) {
3003 let store = UnifiedStore::open_with_config(path, config()).unwrap();
3004 for id in 1..=200 {
3007 let entity = UnifiedEntity::table_row(
3008 EntityId::new(id),
3009 "rows",
3010 id,
3011 vec![Value::text(replay_crash_payload(id, 8))],
3012 );
3013 let record =
3014 UnifiedStore::serialize_entity_record(&entity, None, STORE_VERSION_V11);
3015 store.apply_replayed_upsert("rows", &record, false).unwrap();
3016 }
3017 store.persist().expect("must exit during checkpoint");
3018 panic!("checkpoint rebuild hook was not reached");
3019 }
3020 let directory = tempfile::tempdir().unwrap();
3021 let path = directory.path().join("data.rdb");
3022 {
3023 let store = UnifiedStore::open_with_config(&path, config()).unwrap();
3024 store.create_collection("rows").unwrap();
3025 for id in 1..=200 {
3026 store
3027 .insert(
3028 "rows",
3029 UnifiedEntity::table_row(
3030 EntityId::new(id),
3031 "rows",
3032 id,
3033 vec![Value::text(format!("initial-{id}"))],
3034 ),
3035 )
3036 .unwrap();
3037 }
3038 store.persist().unwrap();
3039 }
3040 let mut first = 0;
3041 let mut last = 0;
3042 for cycle in 1..=12 {
3043 let output = std::process::Command::new(std::env::current_exe().unwrap())
3044 .args([
3045 "--exact",
3046 "storage::unified::store::commit::tests::interrupted_checkpoint_reclaims_unpublished_pages",
3047 "--nocapture",
3048 ])
3049 .env(CHILD_PATH, &path)
3050 .env("REDDB_EXPERIMENTAL_EXIT_DURING_CHECKPOINT_REBUILD", "1")
3051 .output()
3052 .unwrap();
3053 assert_eq!(
3054 output.status.code(),
3055 Some(183),
3056 "{}",
3057 String::from_utf8_lossy(&output.stderr)
3058 );
3059 assert!(String::from_utf8_lossy(&output.stderr)
3060 .contains("experimental checkpoint exit before manifest"));
3061 last = std::fs::metadata(&path).unwrap().len();
3062 if cycle == 1 {
3063 first = last;
3064 }
3065 eprintln!("checkpoint_exit_cycle={cycle} data_logical={last}");
3066 }
3067 let recovered = UnifiedStore::open_with_config(&path, config()).unwrap();
3068 assert_eq!(recovered.get_collection("rows").unwrap().count(), 200);
3069 for id in 1..=200 {
3070 assert_eq!(
3071 recovered
3072 .get("rows", EntityId::new(id))
3073 .unwrap()
3074 .data
3075 .as_row()
3076 .unwrap()
3077 .columns,
3078 vec![Value::text(format!("initial-{id}"))]
3079 );
3080 }
3081 assert!(last <= first + 2 * crate::storage::engine::PAGE_SIZE as u64,
3082 "repeated interrupted checkpoint must bound page growth: first={first}, after_12={last}");
3083 }
3084
3085 #[cfg(target_os = "linux")]
3086 #[test]
3087 fn interrupted_checkpoint_with_wal_reclaims_unpublished_pages() {
3088 use std::os::unix::fs::MetadataExt;
3089 const CHILD_PATH: &str = "REDDB_EXPERIMENTAL_CHECKPOINT_WAL_PATH";
3090 const ROUND: &str = "REDDB_EXPERIMENTAL_CHECKPOINT_WAL_ROUND";
3091 let config = || {
3092 UnifiedStoreConfig::default()
3093 .with_durability_mode(DurabilityMode::Strict)
3094 .with_page_cache_slots(4)
3095 };
3096 if let Some(path) = std::env::var_os(CHILD_PATH) {
3097 let round: u64 = std::env::var(ROUND).unwrap().parse().unwrap();
3098 let store = UnifiedStore::open_with_config(path, config()).unwrap();
3099 if round > 1 {
3100 for id in 1..=200 {
3101 assert_eq!(
3102 store
3103 .get("rows", EntityId::new(id))
3104 .unwrap()
3105 .data
3106 .as_row()
3107 .unwrap()
3108 .columns,
3109 vec![Value::text(replay_crash_payload(id, round - 1))]
3110 );
3111 }
3112 }
3113 let actions: Vec<_> = (1..=200)
3114 .map(|id| {
3115 let entity = UnifiedEntity::table_row(
3116 EntityId::new(id),
3117 "rows",
3118 id,
3119 vec![Value::text(replay_crash_payload(id, round))],
3120 );
3121 StoreWalAction::upsert_entity("rows", &entity, None, STORE_VERSION_V11)
3122 })
3123 .collect();
3124 for action in &actions {
3125 store.apply_replayed_action(action, false).unwrap();
3126 }
3127 store
3130 .commit
3131 .as_ref()
3132 .unwrap()
3133 .append_actions(&actions)
3134 .unwrap();
3135 store.persist().expect("must exit during checkpoint");
3136 panic!("checkpoint rebuild hook was not reached");
3137 }
3138 let directory = tempfile::tempdir().unwrap();
3139 let path = directory.path().join("data.rdb");
3140 {
3141 let store = UnifiedStore::open_with_config(&path, config()).unwrap();
3142 store.create_collection("rows").unwrap();
3143 for id in 1..=200 {
3144 store
3145 .insert(
3146 "rows",
3147 UnifiedEntity::table_row(
3148 EntityId::new(id),
3149 "rows",
3150 id,
3151 vec![Value::text(format!("initial-{id}"))],
3152 ),
3153 )
3154 .unwrap();
3155 }
3156 store.persist().unwrap();
3157 }
3158 let mut fourth = 0;
3159 let mut last = 0;
3160 for round in 1..=12 {
3161 let output = std::process::Command::new(std::env::current_exe().unwrap())
3162 .args([
3163 "--exact",
3164 "storage::unified::store::commit::tests::interrupted_checkpoint_with_wal_reclaims_unpublished_pages",
3165 "--nocapture",
3166 ])
3167 .env(CHILD_PATH, &path)
3168 .env(ROUND, round.to_string())
3169 .env("REDDB_EXPERIMENTAL_EXIT_DURING_CHECKPOINT_REBUILD", "1")
3170 .output().unwrap();
3171 assert_eq!(
3172 output.status.code(),
3173 Some(183),
3174 "round {round}: {}",
3175 String::from_utf8_lossy(&output.stderr)
3176 );
3177 assert!(String::from_utf8_lossy(&output.stderr)
3178 .contains("experimental checkpoint exit before manifest"));
3179 let meta = std::fs::metadata(&path).unwrap();
3180 last = meta.len();
3181 if round == 4 {
3182 fourth = last;
3183 }
3184 eprintln!(
3185 "checkpoint_wal_exit_round={round} data_logical={last} data_allocated={}",
3186 meta.blocks() * 512
3187 );
3188 }
3189 let recovered = UnifiedStore::open_with_config(&path, config()).unwrap();
3190 assert_eq!(recovered.get_collection("rows").unwrap().count(), 200);
3191 for id in 1..=200 {
3192 assert_eq!(
3193 recovered
3194 .get("rows", EntityId::new(id))
3195 .unwrap()
3196 .data
3197 .as_row()
3198 .unwrap()
3199 .columns,
3200 vec![Value::text(replay_crash_payload(id, 12))]
3201 );
3202 }
3203 recovered.persist().unwrap();
3204 drop(recovered);
3205 let reopened = UnifiedStore::open_with_config(&path, config()).unwrap();
3206 for id in 1..=200 {
3207 assert_eq!(
3208 reopened
3209 .get("rows", EntityId::new(id))
3210 .unwrap()
3211 .data
3212 .as_row()
3213 .unwrap()
3214 .columns,
3215 vec![Value::text(replay_crash_payload(id, 12))]
3216 );
3217 }
3218 assert!(last <= fourth + 2 * crate::storage::engine::PAGE_SIZE as u64,
3219 "repeated interrupted WAL-backed checkpoints must bound page growth: round4={fourth}, round12={last}");
3220 }
3221
3222 #[cfg(target_os = "linux")]
3223 #[test]
3224 fn interrupted_retired_reclamation_preserves_reuse() {
3225 const CHILD_PATH: &str = "REDDB_EXPERIMENTAL_RETIRED_PATH";
3226 fn mutate(store: &UnifiedStore, round: u64) {
3227 for id in 1..=200 {
3228 let entity = UnifiedEntity::table_row(
3229 EntityId::new(id),
3230 "rows",
3231 id,
3232 vec![Value::text(replay_crash_payload(id, round))],
3233 );
3234 let record =
3235 UnifiedStore::serialize_entity_record(&entity, None, STORE_VERSION_V11);
3236 store.apply_replayed_upsert("rows", &record, false).unwrap();
3237 }
3238 }
3239 let config = || {
3240 UnifiedStoreConfig::default()
3241 .with_durability_mode(DurabilityMode::Strict)
3242 .with_page_cache_slots(4)
3243 };
3244 if let Some(path) = std::env::var_os(CHILD_PATH) {
3245 let store = UnifiedStore::open_with_config(path, config()).unwrap();
3246 mutate(&store, 2);
3247 store.persist().expect("must exit at retired free boundary");
3248 panic!("retired free hook was not reached");
3249 }
3250 let mut reuse = Vec::new();
3251 for (label, env_name, exit_code, message) in [
3252 (
3253 "before",
3254 "REDDB_EXPERIMENTAL_EXIT_BEFORE_RETIRED_FREE",
3255 184,
3256 "experimental exit before retired free",
3257 ),
3258 (
3259 "enqueued",
3260 "REDDB_EXPERIMENTAL_EXIT_AFTER_RETIRED_ENQUEUE",
3261 186,
3262 "experimental exit after retired enqueue",
3263 ),
3264 (
3265 "after",
3266 "REDDB_EXPERIMENTAL_EXIT_AFTER_RETIRED_FREE",
3267 185,
3268 "experimental exit after retired free",
3269 ),
3270 ] {
3271 let directory = tempfile::tempdir().unwrap();
3272 let path = directory.path().join("data.rdb");
3273 {
3274 let store = UnifiedStore::open_with_config(&path, config()).unwrap();
3275 store.create_collection("rows").unwrap();
3276 for id in 1..=200 {
3277 store
3278 .insert(
3279 "rows",
3280 UnifiedEntity::table_row(
3281 EntityId::new(id),
3282 "rows",
3283 id,
3284 vec![Value::text(format!("initial-{id}"))],
3285 ),
3286 )
3287 .unwrap();
3288 }
3289 store.persist().unwrap();
3290 mutate(&store, 1);
3291 store.persist().unwrap();
3292 }
3293 let output = std::process::Command::new(std::env::current_exe().unwrap())
3294 .args([
3295 "--exact",
3296 "storage::unified::store::commit::tests::interrupted_retired_reclamation_preserves_reuse",
3297 "--nocapture",
3298 ])
3299 .env(CHILD_PATH, &path)
3300 .env(env_name, "1")
3301 .output().unwrap();
3302 assert_eq!(
3303 output.status.code(),
3304 Some(exit_code),
3305 "{label}: {}",
3306 String::from_utf8_lossy(&output.stderr)
3307 );
3308 assert!(String::from_utf8_lossy(&output.stderr).contains(message));
3309 eprintln!(
3310 "retired_reclaim_{label} child={}",
3311 String::from_utf8_lossy(&output.stderr).trim()
3312 );
3313 let store = UnifiedStore::open_with_config(&path, config()).unwrap();
3314 for id in 1..=200 {
3315 assert_eq!(
3316 store
3317 .get("rows", EntityId::new(id))
3318 .unwrap()
3319 .data
3320 .as_row()
3321 .unwrap()
3322 .columns,
3323 vec![Value::text(replay_crash_payload(id, 2))]
3324 );
3325 }
3326 let pager = store.pager.as_ref().unwrap();
3327 let before_pages = pager.page_count().unwrap();
3328 let mut ids = std::collections::HashSet::new();
3329 let mut reused = 0;
3330 for _ in 0..256 {
3331 let page = pager
3332 .allocate_page(crate::storage::engine::page::PageType::BTreeLeaf)
3333 .unwrap();
3334 let id = page.page_id();
3335 assert!(ids.insert(id), "duplicate allocation");
3336 reused += u32::from(id < before_pages);
3337 pager.write_page(id, page).unwrap();
3338 }
3339 pager.sync().unwrap();
3340 eprintln!(
3341 "retired_reclaim_{label} before_pages={before_pages} reused={reused} appended={}",
3342 256 - reused
3343 );
3344 drop(store);
3345 let reopened = UnifiedStore::open_with_config(&path, config()).unwrap();
3346 for id in 1..=200 {
3347 assert_eq!(
3348 reopened
3349 .get("rows", EntityId::new(id))
3350 .unwrap()
3351 .data
3352 .as_row()
3353 .unwrap()
3354 .columns,
3355 vec![Value::text(replay_crash_payload(id, 2))]
3356 );
3357 }
3358 reuse.push(reused);
3359 }
3360 assert!(
3361 reuse[0] >= reuse[2] && reuse[1] >= reuse[2],
3362 "recovery must not lose retired pages before allocator publication: before={} enqueued={} after={}",
3363 reuse[0],
3364 reuse[1],
3365 reuse[2]
3366 );
3367 }
3368
3369 #[cfg(target_os = "linux")]
3370 #[test]
3371 fn experimental_retaining_released_root_is_not_recoverable() {
3372 let directory = tempfile::tempdir().unwrap();
3373 let path = directory.path().join("data.rdb");
3374 let config = || {
3375 UnifiedStoreConfig::default()
3376 .with_durability_mode(DurabilityMode::Strict)
3377 .with_page_cache_slots(4)
3378 };
3379 {
3380 let store = UnifiedStore::open_with_config(&path, config()).unwrap();
3381 store.create_collection("rows").unwrap();
3382 for id in 1..=200 {
3383 store
3384 .insert(
3385 "rows",
3386 UnifiedEntity::table_row(
3387 EntityId::new(id),
3388 "rows",
3389 id,
3390 vec![Value::text(format!("initial-{id}"))],
3391 ),
3392 )
3393 .unwrap();
3394 }
3395 store.persist().unwrap();
3396 for id in 1..=200 {
3397 let entity = UnifiedEntity::table_row(
3398 EntityId::new(id),
3399 "rows",
3400 id,
3401 vec![Value::text(replay_crash_payload(id, 1))],
3402 );
3403 let record =
3404 UnifiedStore::serialize_entity_record(&entity, None, STORE_VERSION_V11);
3405 store.apply_replayed_upsert("rows", &record, false).unwrap();
3406 }
3407 store.persist().unwrap();
3408 }
3409 let output = std::process::Command::new(std::env::current_exe().unwrap())
3410 .args(["--exact", "storage::unified::store::commit::tests::interrupted_retired_reclamation_preserves_reuse", "--nocapture"])
3411 .env("REDDB_EXPERIMENTAL_RETIRED_PATH", &path)
3412 .env("REDDB_EXPERIMENTAL_RETAIN_RELEASED_ROOT", "1")
3413 .env("REDDB_EXPERIMENTAL_EXIT_AFTER_RETIRED_FREE", "1")
3414 .output().unwrap();
3415 assert_eq!(
3416 output.status.code(),
3417 Some(185),
3418 "{}",
3419 String::from_utf8_lossy(&output.stderr)
3420 );
3421 assert!(String::from_utf8_lossy(&output.stderr)
3422 .contains("experimental exit after retired free"));
3423
3424 let reopened = UnifiedStore::open_with_config(&path, config()).unwrap();
3425 for id in 1..=200 {
3426 assert_eq!(
3427 reopened
3428 .get("rows", EntityId::new(id))
3429 .unwrap()
3430 .data
3431 .as_row()
3432 .unwrap()
3433 .columns,
3434 vec![Value::text(replay_crash_payload(id, 2))]
3435 );
3436 }
3437 let retired_roots: Vec<_> = reopened
3438 .retired_btrees
3439 .lock()
3440 .iter()
3441 .map(|entry| entry.tree.root_page_id())
3442 .collect();
3443 let pager = reopened.pager.as_ref().unwrap();
3444 let root_types: Vec<_> = retired_roots
3445 .iter()
3446 .map(|id| pager.read_page(*id).unwrap().page_type().unwrap())
3447 .collect();
3448 assert!(
3449 root_types.contains(&crate::storage::engine::page::PageType::FreelistTrunk),
3450 "expected a retained root to have become a freelist trunk: {root_types:?}"
3451 );
3452 let error = reopened
3453 .persist()
3454 .expect_err("retained root was reused as allocator metadata");
3455 assert!(
3456 error
3457 .to_string()
3458 .contains("unexpected retired tree page type"),
3459 "{error}"
3460 );
3461 eprintln!("retained_root_types={root_types:?} persist_error={error}");
3462 }
3463
3464 #[cfg(target_os = "linux")]
3465 fn interrupted_replay_reclaims_pages(after_publication: bool, force_sync: bool) {
3466 use std::os::unix::fs::MetadataExt;
3467 const CHILD_PATH: &str = "REDDB_EXPERIMENTAL_REPLAY_REBUILD_PATH";
3468 let config = || {
3469 UnifiedStoreConfig::default()
3470 .with_durability_mode(DurabilityMode::Strict)
3471 .with_page_cache_slots(4)
3472 };
3473 if let Some(path) = std::env::var_os(CHILD_PATH) {
3474 let _store = UnifiedStore::open_with_config(path, config())
3475 .expect("must exit while replay rebuilds");
3476 panic!("replay rebuild hook was not reached");
3477 }
3478 let directory = tempfile::tempdir().unwrap();
3479 let path = directory.path().join("data.rdb");
3480 {
3481 let store = UnifiedStore::open_with_config(&path, config()).unwrap();
3482 store.create_collection("rows").unwrap();
3483 for id in 1..=200 {
3484 store
3485 .insert(
3486 "rows",
3487 UnifiedEntity::table_row(
3488 EntityId::new(id),
3489 "rows",
3490 id,
3491 vec![Value::text(format!("initial-{id}"))],
3492 ),
3493 )
3494 .unwrap();
3495 }
3496 store.persist().unwrap();
3497 }
3498 {
3499 let coordinator = StoreCommitCoordinator::open(
3500 reddb_file::layout::unified_wal_path(&path),
3501 DurabilityMode::Strict,
3502 GroupCommitOptions::default(),
3503 )
3504 .unwrap();
3505 let actions = (1..=200)
3506 .map(|id| {
3507 let entity = UnifiedEntity::table_row(
3508 EntityId::new(id),
3509 "rows",
3510 id,
3511 vec![Value::text(replay_crash_payload(id, 8))],
3512 );
3513 StoreWalAction::upsert_entity("rows", &entity, None, STORE_VERSION_V11)
3514 })
3515 .collect::<Vec<_>>();
3516 coordinator.append_actions(&actions).unwrap();
3517 }
3518 let snapshot = || {
3519 let data = std::fs::metadata(&path).unwrap();
3520 let wal = std::fs::metadata(reddb_file::layout::unified_wal_path(&path)).unwrap();
3521 (
3522 data.len(),
3523 data.blocks() * 512,
3524 wal.len(),
3525 wal.blocks() * 512,
3526 )
3527 };
3528 let baseline = snapshot();
3529 let mut previous = baseline;
3530 let mut first_failure = baseline;
3531 for cycle in 1..=12 {
3532 let test_name = if after_publication {
3533 "storage::unified::store::commit::tests::interrupted_replay_preserves_published_pages"
3534 } else if force_sync {
3535 "storage::unified::store::commit::tests::interrupted_replay_reclaims_durable_unpublished_pages"
3536 } else {
3537 "storage::unified::store::commit::tests::interrupted_replay_reclaims_unpublished_pages"
3538 };
3539 let mut command = std::process::Command::new(std::env::current_exe().unwrap());
3540 command
3541 .args(["--exact", test_name, "--nocapture"])
3542 .env(CHILD_PATH, &path);
3543 if !force_sync {
3544 command.env("REDDB_EXPERIMENTAL_REPLAY_NO_SYNC", "1");
3545 } else {
3546 command.env_remove("REDDB_EXPERIMENTAL_REPLAY_NO_SYNC");
3547 }
3548 let message = if after_publication {
3549 command.env("REDDB_EXPERIMENTAL_EXIT_AFTER_REPLAY_PUBLICATION", "1");
3550 "experimental replay exit after manifest"
3551 } else {
3552 command.env("REDDB_EXPERIMENTAL_EXIT_DURING_REPLAY_REBUILD", "1");
3553 "experimental replay rebuild exit before manifest"
3554 };
3555 let output = command.output().unwrap();
3556 assert_eq!(
3557 output.status.code(),
3558 Some(182),
3559 "cycle {cycle}: {}",
3560 String::from_utf8_lossy(&output.stderr)
3561 );
3562 assert!(String::from_utf8_lossy(&output.stderr).contains(message));
3563 let now = snapshot();
3564 eprintln!("replay_exit_cycle={cycle} data_logical={} data_allocated={} wal_logical={} wal_allocated={}",now.0,now.1,now.2,now.3);
3565 if cycle == 1 {
3566 first_failure = now;
3567 }
3568 previous = now;
3569 }
3570 let recovered = UnifiedStore::open_with_config(&path, config()).unwrap();
3571 assert_eq!(recovered.get_collection("rows").unwrap().count(), 200);
3572 for id in 1..=200 {
3573 assert_eq!(
3574 recovered
3575 .get("rows", EntityId::new(id))
3576 .unwrap()
3577 .data
3578 .as_row()
3579 .unwrap()
3580 .columns,
3581 vec![Value::text(replay_crash_payload(id, 8))]
3582 );
3583 }
3584 let pages = recovered.pager.as_ref().unwrap().page_count().unwrap();
3585 let free = recovered.pager.as_ref().unwrap().free_page_count().unwrap();
3586 recovered.persist().unwrap();
3587 drop(recovered);
3588 let final_sizes = snapshot();
3589 let reopened = UnifiedStore::open_with_config(&path, config()).unwrap();
3590 assert_eq!(reopened.get_collection("rows").unwrap().count(), 200);
3591 for id in 1..=200 {
3592 assert_eq!(
3593 reopened
3594 .get("rows", EntityId::new(id))
3595 .unwrap()
3596 .data
3597 .as_row()
3598 .unwrap()
3599 .columns,
3600 vec![Value::text(replay_crash_payload(id, 8))]
3601 );
3602 }
3603 eprintln!("replay_rebuild baseline={baseline:?} after_12={previous:?} after_recovery_checkpoint={final_sizes:?} pages={pages} free={free}");
3604 let pager = reopened.pager.as_ref().unwrap();
3608 let before_probe = pager.page_count().unwrap();
3609 let mut ids = std::collections::HashSet::new();
3610 let mut reused = 0;
3611 for _ in 0..256 {
3612 let page = pager
3613 .allocate_page(crate::storage::engine::page::PageType::BTreeLeaf)
3614 .unwrap();
3615 let id = page.page_id();
3616 assert!(ids.insert(id), "allocator returned the same page twice");
3617 reused += u32::from(id < before_probe);
3618 pager.write_page(id, page).unwrap();
3619 }
3620 pager.sync().unwrap();
3621 let after_probe = pager.page_count().unwrap();
3622 eprintln!("replay_reuse_probe count=256 before_pages={before_probe} after_pages={after_probe} reused={reused} appended={}", 256 - reused);
3623 drop(reopened);
3624 let verified = UnifiedStore::open_with_config(&path, config()).unwrap();
3625 for id in 1..=200 {
3626 assert_eq!(
3627 verified
3628 .get("rows", EntityId::new(id))
3629 .unwrap()
3630 .data
3631 .as_row()
3632 .unwrap()
3633 .columns,
3634 vec![Value::text(replay_crash_payload(id, 8))]
3635 );
3636 }
3637 assert!(
3638 previous.0 <= first_failure.0 + 2 * crate::storage::engine::PAGE_SIZE as u64,
3639 "repeated interrupted replay must bound page growth: first={}, after_12={}",
3640 first_failure.0,
3641 previous.0
3642 );
3643 }
3644
3645 #[test]
3646 fn paged_wal_replay_survives_immediate_process_exit() {
3647 const CHILD_PATH: &str = "REDDB_TEST_REPLAY_CRASH_PATH";
3648 let config = || UnifiedStoreConfig::default().with_page_cache_slots(4);
3649 if let Some(path) = std::env::var_os(CHILD_PATH) {
3650 let store = UnifiedStore::open_with_config(path, config()).expect("replay child");
3651 assert_eq!(store.get_collection("rows").unwrap().count(), 400);
3652 std::process::exit(0);
3654 }
3655 let directory = tempfile::tempdir().unwrap();
3656 let path = directory.path().join("data.rdb");
3657 {
3658 let store = UnifiedStore::open_with_config(&path, config()).unwrap();
3659 store.create_collection("rows").unwrap();
3660 for id in 1..=400 {
3661 store
3662 .insert(
3663 "rows",
3664 UnifiedEntity::table_row(
3665 EntityId::new(id),
3666 "rows",
3667 id,
3668 vec![Value::text(format!("initial-{id}"))],
3669 ),
3670 )
3671 .unwrap();
3672 }
3673 store.persist().unwrap();
3674 }
3675 {
3676 let coordinator = StoreCommitCoordinator::open(
3677 reddb_file::layout::unified_wal_path(&path),
3678 DurabilityMode::Strict,
3679 GroupCommitOptions::default(),
3680 )
3681 .unwrap();
3682 for round in 0..3 {
3683 let actions = (1..=400)
3684 .map(|id| {
3685 let payload = replay_crash_payload(id, round);
3686 let entity = UnifiedEntity::table_row(
3687 EntityId::new(id),
3688 "rows",
3689 id,
3690 vec![Value::text(payload)],
3691 );
3692 StoreWalAction::upsert_entity("rows", &entity, None, STORE_VERSION_V11)
3693 })
3694 .collect::<Vec<_>>();
3695 coordinator.append_actions(&actions).unwrap();
3696 }
3697 }
3698 let status = std::process::Command::new(std::env::current_exe().unwrap())
3699 .args(["--exact", "storage::unified::store::commit::tests::paged_wal_replay_survives_immediate_process_exit", "--nocapture"])
3700 .env(CHILD_PATH, &path).status().unwrap();
3701 assert!(status.success(), "replay process failed");
3702 let reopened = UnifiedStore::open_with_config(&path, config())
3703 .expect("cold reopen after replay crash");
3704 assert_eq!(reopened.get_collection("rows").unwrap().count(), 400);
3705 for id in 1..=400 {
3706 let entity = reopened
3707 .get("rows", EntityId::new(id))
3708 .expect("retained entity");
3709 let EntityData::Row(row) = entity.data else {
3710 panic!("expected row")
3711 };
3712 assert_eq!(row.columns, vec![Value::text(replay_crash_payload(id, 2))]);
3713 }
3714 drop(reopened);
3715 let stable_bytes = std::fs::metadata(&path).unwrap().len();
3716 let repeated = UnifiedStore::open_with_config(&path, config()).unwrap();
3717 assert_eq!(repeated.get_collection("rows").unwrap().count(), 400);
3718 assert_eq!(
3719 std::fs::metadata(&path).unwrap().len(),
3720 stable_bytes,
3721 "idempotent replay must not allocate another tree generation"
3722 );
3723 }
3724
3725 #[test]
3726 fn checkpoint_reset_waits_for_an_enqueued_commit_acknowledgement() {
3727 let _env = env_lock().lock().unwrap_or_else(|p| p.into_inner());
3728 let path = temp_wal("checkpoint-ack");
3729 let coordinator = Arc::new(
3730 StoreCommitCoordinator::open(
3731 path.clone(),
3732 DurabilityMode::WalDurableGrouped,
3733 GroupCommitOptions::default(),
3734 )
3735 .unwrap(),
3736 );
3737 let state = coordinator.state.0.lock();
3740 let (written_tx, written_rx) = std::sync::mpsc::channel();
3741 let writer_coordinator = Arc::clone(&coordinator);
3742 let writer = std::thread::spawn(move || {
3743 let result = writer_coordinator.append_actions(&[StoreWalAction::CreateCollection {
3744 name: "acknowledged".into(),
3745 }]);
3746 written_tx.send(result).unwrap();
3747 });
3748 let deadline = Instant::now() + Duration::from_secs(2);
3749 while !coordinator.queue.has_pending() {
3750 assert!(Instant::now() < deadline, "writer never enqueued");
3751 std::thread::yield_now();
3752 }
3753 let (started_tx, started_rx) = std::sync::mpsc::channel();
3754 let (reset_tx, reset_rx) = std::sync::mpsc::channel();
3755 let reset_coordinator = Arc::clone(&coordinator);
3756 let reset = std::thread::spawn(move || {
3757 started_tx.send(()).unwrap();
3758 reset_tx.send(reset_coordinator.truncate()).unwrap();
3759 });
3760 started_rx.recv_timeout(Duration::from_secs(2)).unwrap();
3761 let deadline = Instant::now() + Duration::from_millis(100);
3762 while coordinator.queue.has_pending() && Instant::now() < deadline {
3763 std::thread::sleep(Duration::from_millis(1));
3764 }
3765 let discarded_before_ack = !coordinator.queue.has_pending();
3766 drop(state);
3767 let acknowledged = written_rx.recv_timeout(Duration::from_secs(2));
3768 if acknowledged.is_err() {
3769 let mut state = coordinator.state.0.lock();
3772 state.shutdown = true;
3773 state.last_error = Some("checkpoint acknowledgement regression cleanup".into());
3774 coordinator.state.1.notify_all();
3775 }
3776 writer.join().unwrap();
3777 reset_rx
3778 .recv_timeout(Duration::from_secs(2))
3779 .unwrap()
3780 .unwrap();
3781 reset.join().unwrap();
3782 assert!(
3783 !discarded_before_ack,
3784 "checkpoint discarded an unacknowledged WAL record"
3785 );
3786 assert!(
3787 acknowledged.unwrap().is_ok(),
3788 "durable writer did not finish"
3789 );
3790 drop(coordinator);
3791 let _ = std::fs::remove_file(path);
3792 }
3793
3794 #[test]
3805 fn group_commit_coalesces_concurrent_autocommits() {
3806 let _env = env_lock().lock().unwrap_or_else(|p| p.into_inner());
3807 std::env::remove_var("REDDB_GROUP_COMMIT_WINDOW_US");
3810
3811 let path = temp_wal("coalesce");
3812 let coord = Arc::new(
3813 StoreCommitCoordinator::open(
3814 path.clone(),
3815 DurabilityMode::WalDurableGrouped,
3816 GroupCommitOptions::default(),
3817 )
3818 .expect("open commit coordinator"),
3819 );
3820
3821 const WRITERS: usize = 32;
3822 let barrier = Arc::new(Barrier::new(WRITERS));
3823 let mut handles = Vec::with_capacity(WRITERS);
3824 for tx in 0..WRITERS {
3825 let coord_c = Arc::clone(&coord);
3826 let barrier_c = Arc::clone(&barrier);
3827 handles.push(std::thread::spawn(move || {
3828 barrier_c.wait();
3832 let action = StoreWalAction::CreateCollection {
3833 name: format!("c{tx}"),
3834 };
3835 coord_c
3836 .append_actions(std::slice::from_ref(&action))
3837 .expect("append_actions");
3838 }));
3839 }
3840 for h in handles {
3841 h.join().expect("writer thread");
3842 }
3843
3844 let fsyncs = coord.fsync_count();
3845 assert!(fsyncs > 0, "expected at least one fsync, got {fsyncs}");
3846 assert!(
3847 fsyncs < WRITERS as u64,
3848 "expected fsyncs ({fsyncs}) to be strictly less than \
3849 concurrent writers ({WRITERS}); coalescing failed"
3850 );
3851
3852 drop(coord);
3853 let _ = std::fs::remove_file(&path);
3854 }
3855
3856 #[test]
3862 fn zero_window_disables_coalescing_floor() {
3863 let _env = env_lock().lock().unwrap_or_else(|p| p.into_inner());
3864 std::env::set_var("REDDB_GROUP_COMMIT_WINDOW_US", "0");
3865
3866 let path = temp_wal("zero_window");
3867 let coord = Arc::new(
3868 StoreCommitCoordinator::open(
3869 path.clone(),
3870 DurabilityMode::WalDurableGrouped,
3871 GroupCommitOptions::default(),
3872 )
3873 .expect("open commit coordinator"),
3874 );
3875
3876 const WRITERS: usize = 8;
3877 let barrier = Arc::new(Barrier::new(WRITERS));
3878 let mut handles = Vec::with_capacity(WRITERS);
3879 for tx in 0..WRITERS {
3880 let coord_c = Arc::clone(&coord);
3881 let barrier_c = Arc::clone(&barrier);
3882 handles.push(std::thread::spawn(move || {
3883 barrier_c.wait();
3884 let action = StoreWalAction::CreateCollection {
3885 name: format!("z{tx}"),
3886 };
3887 coord_c
3888 .append_actions(std::slice::from_ref(&action))
3889 .expect("append_actions");
3890 }));
3891 }
3892 for h in handles {
3893 h.join().expect("writer thread");
3894 }
3895
3896 let fsyncs = coord.fsync_count();
3902 assert!(fsyncs >= 1, "expected at least one fsync, got {fsyncs}");
3903
3904 std::env::remove_var("REDDB_GROUP_COMMIT_WINDOW_US");
3905 drop(coord);
3906 let _ = std::fs::remove_file(&path);
3907 }
3908
3909 #[test]
3911 fn resolve_window_precedence() {
3912 let _env = env_lock().lock().unwrap_or_else(|p| p.into_inner());
3913 std::env::remove_var("REDDB_GROUP_COMMIT_WINDOW_US");
3915 let cfg = GroupCommitOptions::default();
3916 assert_eq!(
3917 StoreCommitCoordinator::resolve_window(&cfg),
3918 Duration::from_micros(DEFAULT_ADAPTIVE_WINDOW_US)
3919 );
3920
3921 let cfg_ms = GroupCommitOptions {
3923 window_ms: 5,
3924 ..GroupCommitOptions::default()
3925 };
3926 assert_eq!(
3927 StoreCommitCoordinator::resolve_window(&cfg_ms),
3928 Duration::from_millis(5)
3929 );
3930
3931 std::env::set_var("REDDB_GROUP_COMMIT_WINDOW_US", "750");
3933 assert_eq!(
3934 StoreCommitCoordinator::resolve_window(&cfg),
3935 Duration::from_micros(750)
3936 );
3937 assert_eq!(
3938 StoreCommitCoordinator::resolve_window(&cfg_ms),
3939 Duration::from_micros(750)
3940 );
3941
3942 std::env::set_var("REDDB_GROUP_COMMIT_WINDOW_US", "0");
3944 assert_eq!(
3945 StoreCommitCoordinator::resolve_window(&cfg),
3946 Duration::from_micros(0)
3947 );
3948
3949 std::env::remove_var("REDDB_GROUP_COMMIT_WINDOW_US");
3950 }
3951}