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