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