Skip to main content

reddb_server/storage/unified/store/
commit.rs

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