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