Skip to main content

reddb_server/storage/unified/store/
commit.rs

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