Skip to main content

reddb_server/storage/unified/store/
commit.rs

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