Skip to main content

reddb_server/storage/unified/store/
commit.rs

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