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 has_page_writes = false;
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 { .. } => has_page_writes = true,
1111                WalRecord::Checkpoint { .. } => {}
1112                WalRecord::VectorInsert {
1113                    collection,
1114                    entity_id,
1115                    vector,
1116                } => {
1117                    // Issue #694 — capture `vector.turbo` WAL inserts so the
1118                    // boot-time TurboQuant index rebuild can drain them in
1119                    // WAL order (deterministic against the pre-restart
1120                    // state under a fixed codec seed). The legacy `vector`
1121                    // path doesn't read this map.
1122                    let mut map = store.replayed_turbo_inserts.lock();
1123                    map.entry(
1124                        reddb_engine::storage::system_collection_names::canonical_owned(collection),
1125                    )
1126                    .or_default()
1127                    .push((entity_id, vector));
1128                }
1129                WalRecord::ProbabilisticDelta {
1130                    kind,
1131                    operation,
1132                    name,
1133                    operands,
1134                } => {
1135                    let mut deltas = store.replayed_probabilistic_deltas.lock();
1136                    deltas.push((kind, operation, name, operands));
1137                }
1138                WalRecord::FullPageImage { .. } => {
1139                    // Pager-level FPI (gh-478); store replay ignores.
1140                }
1141            }
1142        }
1143
1144        if incomplete_wal_tail && !ownership_journal.is_empty() {
1145            return Err(io::Error::new(
1146                io::ErrorKind::InvalidData,
1147                "incomplete WAL prevents ownership recovery",
1148            ));
1149        }
1150        self.recover_page_ownership(store, &ownership_journal)?;
1151
1152        if has_page_writes {
1153            // Do not retain every transaction's page payload while scanning a
1154            // large WAL. The writer remains open and ownership recovery may
1155            // append a Finish record, but it does not truncate the journal.
1156            for item in WalReader::open(&self.wal_path)?.iter() {
1157                let record = match item {
1158                    Ok((_, record)) => record,
1159                    Err(err) if err.kind() == io::ErrorKind::UnexpectedEof => break,
1160                    Err(err) => return Err(err),
1161                };
1162                let WalRecord::PageWrite { tx_id, data, .. } = record else {
1163                    continue;
1164                };
1165                if !tx_states.get(&tx_id).copied().unwrap_or(false) {
1166                    continue;
1167                }
1168                let action = StoreWalAction::decode(&data)?;
1169                applied = true;
1170                store.apply_replayed_action(&action, false).map_err(|err| {
1171                    io::Error::other(format!("failed to replay store wal action: {err}"))
1172                })?;
1173                store.remember_checkpoint_rows(vec![action], Default::default());
1174            }
1175        }
1176
1177        if applied && store.config.resident_memory_bytes_max.is_some() {
1178            // The immutable base plus committed WAL overlay is authoritative.
1179            // Keep the WAL until a paced checkpoint publishes the replacement;
1180            // boot must not copy every cold payload for a few replayed rows.
1181            return Ok(());
1182        }
1183        if applied {
1184            // Opening is exclusive: no client can mutate published trees
1185            // while replay constructs replacement generations.
1186            let epoch = latest_ownership_epoch
1187                .max(store.ownership_catalog_epoch.load(Ordering::Acquire))
1188                .checked_add(1)
1189                .ok_or_else(|| io::Error::other("page ownership epoch exhausted"))?;
1190            self.begin_page_ownership_epoch(store, epoch)?;
1191            // Even when the replayed actions leave all B-trees unchanged, the
1192            // epoch must be in the catalog before its journal can finish.
1193            store.mark_paged_registry_dirty();
1194            store
1195                .reconcile_replayed_btrees()
1196                .map_err(|err| io::Error::other(err.to_string()))?;
1197            self.finish_page_ownership_epoch(store, epoch)?;
1198        }
1199        Ok(())
1200    }
1201
1202    fn wait_until_durable(&self, target_lsn: u64, wal_bytes: u64) -> io::Result<()> {
1203        match self.mode {
1204            DurabilityMode::Strict => self.force_sync(),
1205            // Async: record the pending target so the background
1206            // flusher eventually covers it, but don't block the
1207            // caller. Matches PG `synchronous_commit=off` semantics —
1208            // crash inside the flush window loses unflushed commits.
1209            DurabilityMode::Async => {
1210                let (state_lock, cond) = &*self.state;
1211                let mut state = state_lock.lock();
1212                state.pending_target_lsn = state.pending_target_lsn.max(target_lsn);
1213                state.pending_statements = state.pending_statements.saturating_add(1);
1214                state.pending_wal_bytes = state.pending_wal_bytes.saturating_add(wal_bytes);
1215                state.first_pending_at.get_or_insert_with(Instant::now);
1216                cond.notify_all();
1217                Ok(())
1218            }
1219            DurabilityMode::WalDurableGrouped => {
1220                let (state_lock, cond) = &*self.state;
1221                let mut state = state_lock.lock();
1222                state.pending_target_lsn = state.pending_target_lsn.max(target_lsn);
1223                state.pending_statements = state.pending_statements.saturating_add(1);
1224                state.pending_wal_bytes = state.pending_wal_bytes.saturating_add(wal_bytes);
1225                state.first_pending_at.get_or_insert_with(Instant::now);
1226                cond.notify_all();
1227
1228                loop {
1229                    if let Some(err) = state.last_error.clone() {
1230                        return Err(io::Error::other(err));
1231                    }
1232                    if state.durable_lsn >= target_lsn {
1233                        return Ok(());
1234                    }
1235                    // parking_lot::Condvar mutates the guard in place —
1236                    // no LockResult to unwrap, no poisoning to fold.
1237                    cond.wait(&mut state);
1238                }
1239            }
1240        }
1241    }
1242
1243    fn run_group_commit_loop(
1244        wal: Arc<WalMutex>,
1245        queue: Arc<WalAppendQueue>,
1246        state: Arc<(CommitStateMutex, CommitStateCondvar)>,
1247        fsync_count: Arc<AtomicU64>,
1248        window: Duration,
1249        max_statements: usize,
1250        max_wal_bytes: u64,
1251    ) {
1252        let (state_lock, cond) = &*state;
1253        loop {
1254            let target_lsn = {
1255                let mut guard = state_lock.lock();
1256
1257                while !guard.shutdown && guard.pending_target_lsn <= guard.durable_lsn {
1258                    cond.wait(&mut guard);
1259                }
1260
1261                if guard.shutdown {
1262                    return;
1263                }
1264
1265                let immediate = window.is_zero()
1266                    || guard.pending_statements >= max_statements
1267                    || guard.pending_wal_bytes >= max_wal_bytes;
1268
1269                if !immediate {
1270                    let deadline = guard.first_pending_at.unwrap_or_else(Instant::now) + window;
1271                    let now = Instant::now();
1272                    if now < deadline {
1273                        let timeout = deadline.saturating_duration_since(now);
1274                        let _ = cond.wait_for(&mut guard, timeout);
1275                        if guard.shutdown {
1276                            return;
1277                        }
1278                        if guard.pending_target_lsn <= guard.durable_lsn {
1279                            continue;
1280                        }
1281                        let should_wait_again = guard.pending_statements < max_statements
1282                            && guard.pending_wal_bytes < max_wal_bytes
1283                            && guard
1284                                .first_pending_at
1285                                .map(|first| first.elapsed() < window)
1286                                .unwrap_or(false);
1287                        if should_wait_again {
1288                            continue;
1289                        }
1290                    }
1291                }
1292
1293                guard.pending_target_lsn
1294            };
1295
1296            // Drain the current epoch even when reset is waiting: outstanding
1297            // append leases cannot finish until this flusher acknowledges them.
1298            // Retain the lease through state publication to exclude stale ACKs
1299            // after a checkpoint resets offsets back to the WAL header.
1300            let _epoch = queue.epoch.read_recursive();
1301
1302            // Drain all queued entries. Since `WalAppendQueue::enqueue`
1303            // assigns LSN and pushes under a single mutex, the drained
1304            // tuples are guaranteed to form a contiguous byte range
1305            // starting at `wal.current_lsn()` — no gaps, no leftover
1306            // handling.
1307            let batches = queue.drain_sorted();
1308
1309            let sync_result = {
1310                let mut wal = wal.lock();
1311                let mut write_err: Option<io::Error> = None;
1312                for (_lsn, bytes) in batches {
1313                    if let Err(e) = wal.append_bytes(&bytes) {
1314                        write_err = Some(e);
1315                        break;
1316                    }
1317                }
1318                match write_err {
1319                    Some(e) => Err(e),
1320                    None => wal.sync().map(|_| {
1321                        // Count the fsync exactly once per drain
1322                        // cycle so tests can compare it against the
1323                        // number of `append_actions` callers that
1324                        // entered the queue.
1325                        fsync_count.fetch_add(1, Ordering::Relaxed);
1326                        wal.durable_lsn()
1327                    }),
1328                }
1329            };
1330
1331            // Late enqueuers that arrived after our drain stay in the
1332            // queue — don't clear the `pending_*` counters yet and
1333            // don't claim we reached `target_lsn` unless the fsync
1334            // actually covers it.
1335            let more_pending = queue.has_pending();
1336
1337            let mut guard = state_lock.lock();
1338            match sync_result {
1339                Ok(durable_lsn) => {
1340                    guard.durable_lsn = durable_lsn;
1341                    if !more_pending {
1342                        guard.pending_statements = 0;
1343                        guard.pending_wal_bytes = 0;
1344                        guard.first_pending_at = None;
1345                    }
1346                    guard.last_error = None;
1347                    let _ = target_lsn;
1348                }
1349                Err(err) => {
1350                    guard.last_error = Some(err.to_string());
1351                }
1352            }
1353            cond.notify_all();
1354        }
1355    }
1356}
1357
1358impl Drop for StoreCommitCoordinator {
1359    fn drop(&mut self) {
1360        let (state_lock, cond) = &*self.state;
1361        // parking_lot::Mutex::lock is infallible (no poisoning).
1362        let mut state = state_lock.lock();
1363        state.shutdown = true;
1364        cond.notify_all();
1365    }
1366}
1367
1368impl UnifiedStore {
1369    pub(crate) fn deferred_store_wal_capture_active() -> bool {
1370        deferred_store_wal_capture_active()
1371    }
1372
1373    pub(crate) fn begin_deferred_store_wal_capture() {
1374        begin_deferred_store_wal_capture();
1375    }
1376
1377    pub(crate) fn take_deferred_store_wal_capture() -> DeferredStoreWalActions {
1378        take_deferred_store_wal_capture()
1379    }
1380
1381    pub(crate) fn append_deferred_store_wal_actions(
1382        &self,
1383        actions: DeferredStoreWalActions,
1384    ) -> Result<(), StoreError> {
1385        if actions.actions.is_empty() {
1386            return Ok(());
1387        }
1388        #[cfg(test)]
1389        if self
1390            .test_require_deferred_publication_fence
1391            .swap(false, Ordering::AcqRel)
1392            && !self.vault_publication_lock.is_owned_by_current_thread()
1393        {
1394            return Err(StoreError::Io(io::Error::other(
1395                "deferred WAL missing checkpoint publication fence",
1396            )));
1397        }
1398        #[cfg(test)]
1399        if self
1400            .test_fail_deferred_wal_append
1401            .swap(false, Ordering::AcqRel)
1402        {
1403            return Err(StoreError::Io(io::Error::other(
1404                "injected deferred WAL append failure",
1405            )));
1406        }
1407        if self.config.embedded_wal_path.is_some() {
1408            return self.append_embedded_store_wal_actions(&actions.actions);
1409        }
1410        // Deferred transactions need a committed WAL batch even in Strict
1411        // mode. WAL-only versions have no pager pages to flush yet.
1412        if let Some(commit) = &self.commit {
1413            let references = commit
1414                .append_checkpoint_actions(&actions.actions)
1415                .map_err(StoreError::Io)?;
1416            self.remember_checkpoint_rows(actions.actions, references);
1417            Ok(())
1418        } else {
1419            self.flush_paged_state()
1420        }
1421    }
1422
1423    /// Emit a [`WalRecord::VectorInsert`] for a `vector.turbo`
1424    /// collection (issue #693). Returns `Ok(())` when no commit
1425    /// coordinator is configured (in-memory mode) so callers don't
1426    /// have to special-case the missing WAL — the in-memory index
1427    /// update remains durable enough for in-memory runtimes by
1428    /// construction.
1429    pub(crate) fn append_vector_insert_record(
1430        &self,
1431        collection: &str,
1432        entity_id: u64,
1433        vector: &[f32],
1434    ) -> std::io::Result<()> {
1435        let Some(commit) = &self.commit else {
1436            return Ok(());
1437        };
1438        let record = WalRecord::VectorInsert {
1439            collection: collection.to_string(),
1440            entity_id,
1441            vector: vector.to_vec(),
1442        };
1443        commit.append_single_record(record)
1444    }
1445
1446    pub(crate) fn append_probabilistic_delta_record(
1447        &self,
1448        kind: u8,
1449        operation: u8,
1450        name: &str,
1451        operands: Vec<Vec<u8>>,
1452    ) -> std::io::Result<()> {
1453        let Some(commit) = &self.commit else {
1454            return Ok(());
1455        };
1456        let record = WalRecord::ProbabilisticDelta {
1457            kind,
1458            operation,
1459            name: name.to_string(),
1460            operands,
1461        };
1462        commit.append_single_record(record)
1463    }
1464
1465    pub(crate) fn finish_paged_write(
1466        &self,
1467        actions: impl IntoIterator<Item = StoreWalAction>,
1468    ) -> Result<(), StoreError> {
1469        self.finish_paged_write_for_projection(
1470            actions,
1471            self.config.resident_memory_bytes_max.is_some(),
1472        )
1473    }
1474
1475    pub(crate) fn finish_paged_write_for_projection(
1476        &self,
1477        actions: impl IntoIterator<Item = StoreWalAction>,
1478        track: bool,
1479    ) -> Result<(), StoreError> {
1480        let actions: Vec<StoreWalAction> = actions.into_iter().collect();
1481        if deferred_store_wal_capture_active() {
1482            let captured = capture_deferred_store_wal_actions(actions);
1483            debug_assert!(captured);
1484            return Ok(());
1485        }
1486        if self.config.embedded_wal_path.is_some() {
1487            return self.append_embedded_store_wal_actions(&actions);
1488        }
1489        let mut references = super::checkpoint_wal::CheckpointWalBatch::default();
1490        let result = match self.config.durability_mode {
1491            DurabilityMode::Strict => {
1492                if let Some(commit) = &self.commit {
1493                    if track {
1494                        references = commit
1495                            .append_checkpoint_actions(&actions)
1496                            .map_err(StoreError::Io)?;
1497                    } else {
1498                        commit.append_actions(&actions).map_err(StoreError::Io)?;
1499                    }
1500                }
1501                self.flush_paged_state()
1502            }
1503            DurabilityMode::WalDurableGrouped | DurabilityMode::Async => {
1504                if let Some(commit) = &self.commit {
1505                    if track {
1506                        references = commit
1507                            .append_checkpoint_actions(&actions)
1508                            .map_err(StoreError::Io)?;
1509                    } else {
1510                        commit.append_actions(&actions).map_err(StoreError::Io)?;
1511                    }
1512                    Ok(())
1513                } else {
1514                    self.flush_paged_state()
1515                }
1516            }
1517        };
1518        result?;
1519        if track {
1520            self.remember_checkpoint_rows(actions, references);
1521        }
1522        Ok(())
1523    }
1524
1525    pub(crate) fn apply_encoded_store_wal_action(&self, payload: &[u8]) -> Result<(), StoreError> {
1526        let action = StoreWalAction::decode(payload).map_err(StoreError::Io)?;
1527        self.apply_replayed_action(&action, true)
1528    }
1529
1530    fn append_embedded_store_wal_actions(
1531        &self,
1532        actions: &[StoreWalAction],
1533    ) -> Result<(), StoreError> {
1534        let Some(path) = self.config.embedded_wal_path.as_deref() else {
1535            return Ok(());
1536        };
1537        if actions.is_empty() {
1538            return Ok(());
1539        }
1540        let payloads: Vec<Vec<u8>> = actions.iter().map(StoreWalAction::encode).collect();
1541        let encoded_len = crate::storage::EmbeddedRdbArtifact::wal_payloads_encoded_len(&payloads)
1542            .map_err(|err| StoreError::Io(std::io::Error::other(err.to_string())))?;
1543        match self.append_embedded_payloads(path, &payloads) {
1544            Ok(_) => Ok(()),
1545            Err(crate::api::RedDBError::InvalidOperation(msg))
1546                if msg.contains("embedded wal region full") =>
1547            {
1548                self.recover_full_embedded_wal(path, &payloads, encoded_len)
1549            }
1550            Err(err) => Err(StoreError::Io(std::io::Error::other(err.to_string()))),
1551        }
1552    }
1553
1554    fn recover_full_embedded_wal(
1555        &self,
1556        path: &Path,
1557        payloads: &[Vec<u8>],
1558        encoded_len: u64,
1559    ) -> Result<(), StoreError> {
1560        let _vault_guard = self.vault_publication_lock.lock();
1561        // The failed append preceded this lock: another writer may already
1562        // have reclaimed space. Retry through the same FIFO coordinator so
1563        // stale full results cannot each serialize another complete snapshot.
1564        match self.append_embedded_payloads(path, payloads) {
1565            Ok(_) => return Ok(()),
1566            Err(crate::api::RedDBError::InvalidOperation(message))
1567                if message.contains("embedded wal region full") => {}
1568            Err(error) => return Err(StoreError::Io(std::io::Error::other(error.to_string()))),
1569        }
1570        // A checkpoint can fail after publishing its image. Preserve the
1571        // session failure fence through errors and unwinding, just as append's
1572        // FlushBatch guard does; in-memory rollback alone cannot resolve disk.
1573        struct CheckpointFence<'a>(Option<&'a crate::storage::embedded::EmbeddedCommitSession>);
1574        impl Drop for CheckpointFence<'_> {
1575            fn drop(&mut self) {
1576                if let Some(session) = self.0 {
1577                    session.fence_interrupted_write();
1578                }
1579            }
1580        }
1581        let mut fence = CheckpointFence(Some(
1582            self.embedded_commit_session
1583                .get()
1584                .expect("WAL-full recovery follows an append attempt"),
1585        ));
1586        let started = Instant::now();
1587        let snapshot = self.to_binary_dump_bytes();
1588        let capacity = encoded_len.max((snapshot.len() as u64).min(EMBEDDED_WAL_HEADROOM_LIMIT));
1589        let checkpoint = crate::storage::EmbeddedRdbArtifact::write_snapshot_with_wal_capacity(
1590            path, &snapshot, capacity,
1591        )
1592        .map_err(|err| StoreError::Io(std::io::Error::other(err.to_string())))?;
1593        fence.0 = None;
1594        tracing::debug!(
1595            target: "reddb::embedded_checkpoint",
1596            snapshot_bytes = snapshot.len(),
1597            wal_region_bytes = checkpoint.manifest.wal_region_bytes,
1598            elapsed_us = started.elapsed().as_micros() as u64,
1599            "embedded WAL overflow checkpoint"
1600        );
1601        self.append_embedded_payloads(path, payloads)
1602            .map(|_| ())
1603            .map_err(|err| StoreError::Io(std::io::Error::other(err.to_string())))
1604    }
1605
1606    fn append_embedded_payloads(
1607        &self,
1608        path: &Path,
1609        payloads: &[Vec<u8>],
1610    ) -> crate::RedDBResult<crate::storage::EmbeddedRdbOpen> {
1611        let session = self
1612            .embedded_commit_session
1613            .get_or_init(|| crate::storage::EmbeddedRdbArtifact::group_commit_session(path));
1614        let options = self.config.group_commit;
1615        let grouped = self.config.durability_mode == DurabilityMode::WalDurableGrouped;
1616        let window = if grouped {
1617            StoreCommitCoordinator::resolve_window(&options)
1618        } else {
1619            Duration::ZERO
1620        };
1621        session.append(
1622            payloads,
1623            reddb_file::embedded::EmbeddedGroupCommitOptions {
1624                window,
1625                max_statements: if grouped { options.max_statements } else { 1 },
1626                max_wal_bytes: options.max_wal_bytes,
1627            },
1628        )
1629    }
1630
1631    pub(super) fn enqueue_grouped_actions(
1632        &self,
1633        actions: &[StoreWalAction],
1634    ) -> Result<Option<crate::storage::embedded::PendingEmbeddedAppend>, StoreError> {
1635        if self.config.durability_mode != DurabilityMode::WalDurableGrouped
1636            || deferred_store_wal_capture_active()
1637        {
1638            return Ok(None);
1639        }
1640        let Some(path) = self.config.embedded_wal_path.as_deref() else {
1641            return Ok(None);
1642        };
1643        let session = self
1644            .embedded_commit_session
1645            .get_or_init(|| crate::storage::EmbeddedRdbArtifact::group_commit_session(path));
1646        let payloads: Vec<Vec<u8>> = actions.iter().map(StoreWalAction::encode).collect();
1647        let options = self.config.group_commit;
1648        session
1649            .enqueue(
1650                &payloads,
1651                reddb_file::embedded::EmbeddedGroupCommitOptions {
1652                    window: StoreCommitCoordinator::resolve_window(&options),
1653                    max_statements: options.max_statements,
1654                    max_wal_bytes: options.max_wal_bytes,
1655                },
1656            )
1657            .map(Some)
1658            .map_err(|error| StoreError::Io(io::Error::other(error.to_string())))
1659    }
1660
1661    pub(super) fn complete_grouped_actions(
1662        &self,
1663        pending: &crate::storage::embedded::PendingEmbeddedAppend,
1664        actions: &[StoreWalAction],
1665    ) -> Result<(), StoreError> {
1666        match pending.wait() {
1667            Ok(_) => Ok(()),
1668            Err(crate::RedDBError::InvalidOperation(message))
1669                if message.contains("embedded wal region full") =>
1670            {
1671                self.append_embedded_store_wal_actions(actions)
1672            }
1673            Err(error) => Err(StoreError::Io(io::Error::other(error.to_string()))),
1674        }
1675    }
1676
1677    pub(crate) fn apply_replayed_action(
1678        &self,
1679        action: &StoreWalAction,
1680        update_btrees: bool,
1681    ) -> Result<(), StoreError> {
1682        reddb_engine::storage::store_wal::apply_replayed_action(
1683            &NativeReplayTarget {
1684                store: self,
1685                update_btrees,
1686            },
1687            action,
1688        )
1689    }
1690
1691    /// Atomic full-collection replace — issue #595 slice 9c.
1692    ///
1693    /// Builds a fresh `SegmentManager` populated with `entities`,
1694    /// atomically swaps it into the collections map, rebuilds the
1695    /// paged B-tree from the same records, and emits a single
1696    /// `RefreshCollection` WAL action.
1697    ///
1698    /// Concurrent readers that resolved an `Arc<SegmentManager>` for
1699    /// this collection before the swap continue reading the prior
1700    /// contents; readers after the swap see the new contents. The
1701    /// transition is single-pointer so partial state is unobservable.
1702    ///
1703    /// A crash between in-memory mutation and WAL fsync leaves the
1704    /// previous WAL stream intact — on recovery the prior
1705    /// `RefreshCollection` (or whatever last bulk-applied to the
1706    /// collection) is replayed, so the prior contents are observed.
1707    pub fn refresh_collection(
1708        &self,
1709        name: &str,
1710        entities: Vec<UnifiedEntity>,
1711    ) -> Result<Vec<Vec<u8>>, StoreError> {
1712        let name = Self::canonical_collection(name);
1713        let fv = self.format_version();
1714
1715        // Build a fresh manager. Done outside the collections lock so
1716        // the critical section is just the pointer swap below.
1717        let new_manager = Arc::new(SegmentManager::with_config(
1718            name,
1719            self.config.manager_config.clone(),
1720        ));
1721
1722        let mut prepared = entities;
1723        for entity in &mut prepared {
1724            entity.canonicalize_system_collection_names();
1725            if entity.id.raw() == 0 {
1726                entity.id = self.next_entity_id();
1727            } else {
1728                self.register_entity_id(entity.id);
1729            }
1730            if let EntityKind::TableRow { ref mut row_id, .. } = entity.kind {
1731                if *row_id == 0 {
1732                    *row_id = new_manager.next_row_id();
1733                } else {
1734                    new_manager.register_row_id(*row_id);
1735                }
1736            }
1737            entity.ensure_table_logical_id();
1738        }
1739
1740        let serialized: Vec<Vec<u8>> = prepared
1741            .iter()
1742            .map(|e| Self::serialize_entity_record(e, None, fv))
1743            .collect();
1744
1745        new_manager.bulk_insert(prepared.clone()).map_err(|e| {
1746            StoreError::Io(std::io::Error::other(format!(
1747                "refresh_collection: bulk_insert into new manager failed: {e}"
1748            )))
1749        })?;
1750
1751        self.swap_collection_state(name, new_manager, &prepared, &serialized, true);
1752
1753        self.finish_paged_write([StoreWalAction::RefreshCollection {
1754            collection: name.to_string(),
1755            records: serialized.clone(),
1756        }])?;
1757
1758        Ok(serialized)
1759    }
1760
1761    /// Atomic swap of the in-memory collection state. Used by both
1762    /// the live `refresh_collection` path and WAL replay. The new
1763    /// manager is already populated; this fn replaces the live Arc,
1764    /// purges side-state pointing at the previous contents, and
1765    /// rebuilds the paged B-tree from the supplied records.
1766    fn swap_collection_state(
1767        &self,
1768        name: &str,
1769        new_manager: Arc<SegmentManager>,
1770        prepared: &[UnifiedEntity],
1771        serialized: &[Vec<u8>],
1772        update_btrees: bool,
1773    ) {
1774        self.resident
1775            .swap_collection_state(name, new_manager, prepared);
1776
1777        if let Some(pager) = self
1778            .pager
1779            .as_ref()
1780            .filter(|_| update_btrees && self.config.resident_memory_bytes_max.is_none())
1781        {
1782            let new_btree = Arc::new(BTree::new(Arc::clone(pager)));
1783            let mut sorted: Vec<(Vec<u8>, Vec<u8>)> = prepared
1784                .iter()
1785                .zip(serialized.iter())
1786                .map(|(e, r)| (e.id.raw().to_be_bytes().to_vec(), r.clone()))
1787                .collect();
1788            sorted.sort_by(|a, b| a.0.cmp(&b.0));
1789            if !sorted.is_empty() {
1790                let _ = new_btree.bulk_insert_sorted(&sorted);
1791            }
1792            self.btree_indices
1793                .write()
1794                .insert(name.to_string(), new_btree);
1795            self.mark_paged_registry_dirty();
1796        }
1797    }
1798
1799    fn apply_replayed_refresh_collection(
1800        &self,
1801        collection: &str,
1802        records: &[Vec<u8>],
1803        update_btrees: bool,
1804    ) -> Result<(), StoreError> {
1805        let collection = Self::canonical_collection(collection);
1806        let (new_manager, prepared) = self.resident.prepare_replayed_refresh_collection(
1807            collection,
1808            records,
1809            self.format_version(),
1810            &self.config.manager_config,
1811        )?;
1812        self.swap_collection_state(collection, new_manager, &prepared, records, update_btrees);
1813        Ok(())
1814    }
1815
1816    /// Replica-side analogue of [`Self::refresh_collection`] — issue
1817    /// #596 slice 9d. Takes pre-serialized record bytes from the
1818    /// primary's CDC stream (the same bytes the primary wrote into the
1819    /// `RefreshCollection` WAL action), applies the atomic swap, and
1820    /// emits the same `RefreshCollection` WAL action against the
1821    /// replica's local store WAL so the post-swap state survives a
1822    /// replica restart through the normal recovery path.
1823    ///
1824    /// Idempotency: re-applying the same records bytes is a full
1825    /// rebuild from the same payload, so the resulting backing-
1826    /// collection contents are equal to the prior call's result.
1827    /// The primary-side `LogicalChangeApplier` short-circuits exact
1828    /// duplicate LSN+payload combinations before reaching here.
1829    pub fn refresh_collection_from_records(
1830        &self,
1831        name: &str,
1832        records: Vec<Vec<u8>>,
1833    ) -> Result<(), StoreError> {
1834        let name = Self::canonical_collection(name);
1835        self.apply_replayed_refresh_collection(name, &records, true)?;
1836        self.finish_paged_write([StoreWalAction::RefreshCollection {
1837            collection: name.to_string(),
1838            records,
1839        }])?;
1840        Ok(())
1841    }
1842
1843    pub(crate) fn create_collection_in_memory(&self, name: &str) -> Result<(), StoreError> {
1844        let name = Self::canonical_collection(name);
1845        self.resident
1846            .create_collection_in_memory(name, &self.config.manager_config)
1847    }
1848
1849    fn drop_collection_in_memory(&self, name: &str) -> Result<(), StoreError> {
1850        let name = Self::canonical_collection(name);
1851        self.resident.drop_collection_in_memory(name, || {
1852            if let Some(tree) = self.btree_indices.write().remove(name) {
1853                self.retire_btree(tree);
1854            }
1855        })
1856    }
1857
1858    fn apply_replayed_upsert(
1859        &self,
1860        collection: &str,
1861        record: &[u8],
1862        update_btrees: bool,
1863    ) -> Result<(), StoreError> {
1864        let collection = Self::canonical_collection(collection);
1865        let id = self.resident.apply_replayed_upsert(
1866            collection,
1867            record,
1868            self.format_version(),
1869            &self.config.manager_config,
1870        )?;
1871
1872        if let Some(pager) = self
1873            .pager
1874            .as_ref()
1875            .filter(|_| update_btrees && self.config.resident_memory_bytes_max.is_none())
1876        {
1877            let mut btree_indices = self.btree_indices.write();
1878            let btree = btree_indices
1879                .entry(collection.to_string())
1880                .or_insert_with(|| Arc::new(BTree::new(Arc::clone(pager))));
1881            let root_before = btree.root_page_id();
1882            let key = id.raw().to_be_bytes();
1883            match btree.insert(&key, record) {
1884                Ok(_) => {}
1885                Err(BTreeError::DuplicateKey) => {
1886                    let _ = btree.delete(&key);
1887                    let _ = btree.insert(&key, record);
1888                }
1889                Err(err) => {
1890                    return Err(StoreError::Io(io::Error::other(format!(
1891                        "replay upsert btree error: {err}"
1892                    ))));
1893                }
1894            }
1895            if root_before != btree.root_page_id() {
1896                self.mark_paged_registry_dirty();
1897            }
1898        }
1899
1900        Ok(())
1901    }
1902
1903    fn apply_replayed_delete(
1904        &self,
1905        collection: &str,
1906        id: EntityId,
1907        update_btrees: bool,
1908    ) -> Result<(), StoreError> {
1909        let collection = Self::canonical_collection(collection);
1910        self.resident.apply_replayed_delete(collection, id, || {
1911            if self.pager.is_some()
1912                && update_btrees
1913                && self.config.resident_memory_bytes_max.is_none()
1914            {
1915                let btree_indices = self.btree_indices.read();
1916                if let Some(btree) = btree_indices.get(collection) {
1917                    let root_before = btree.root_page_id();
1918                    let key = id.raw().to_be_bytes();
1919                    let _ = btree.delete(&key);
1920                    if root_before != btree.root_page_id() {
1921                        self.mark_paged_registry_dirty();
1922                    }
1923                }
1924            }
1925        })
1926    }
1927}
1928
1929#[cfg(all(test, target_os = "linux"))]
1930#[path = "storage_apocalypse.rs"]
1931mod storage_apocalypse;
1932
1933#[cfg(test)]
1934mod tests {
1935    use super::*;
1936    use crate::api::{DurabilityMode, GroupCommitOptions};
1937    use std::sync::{Barrier, Mutex as StdMutex, OnceLock};
1938    use std::time::SystemTime;
1939
1940    #[test]
1941    fn ownership_epoch_rejects_nested_begin_and_wrong_finish() {
1942        let directory = tempfile::tempdir().expect("temporary store");
1943        let store = UnifiedStore::open(directory.path().join("data.rdb")).expect("store");
1944        store.create_collection("rows").expect("collection");
1945        store.persist().expect("baseline");
1946        let coordinator = store.commit.as_ref().expect("WAL coordinator");
1947        assert!(coordinator.begin_page_ownership_epoch(&store, 0).is_err());
1948        assert!(coordinator.finish_page_ownership_epoch(&store, 1).is_err());
1949        coordinator
1950            .begin_page_ownership_epoch(&store, 2)
1951            .expect("begin epoch");
1952        assert!(coordinator.begin_page_ownership_epoch(&store, 3).is_err());
1953        assert!(coordinator.finish_page_ownership_epoch(&store, 3).is_err());
1954        assert!(coordinator.finish_page_ownership_epoch(&store, 2).is_err());
1955        store.flush_paged_registry().expect("publish epoch");
1956        coordinator
1957            .finish_page_ownership_epoch(&store, 2)
1958            .expect("finish epoch");
1959        assert_eq!(store.ownership_active_epoch.load(Ordering::Acquire), 0);
1960    }
1961
1962    #[test]
1963    fn native_pager_records_page_ownership_in_durable_wal() {
1964        use reddb_file::page_ownership_journal::PageOwnershipRecord;
1965
1966        let directory = tempfile::tempdir().expect("temporary store");
1967        let path = directory.path().join("data.rdb");
1968        let wal_path = reddb_file::layout::unified_wal_path(&path);
1969        let pager = crate::storage::engine::Pager::open(&path, Default::default())
1970            .expect("open native pager");
1971        let coordinator = Arc::new(
1972            StoreCommitCoordinator::open(
1973                &wal_path,
1974                DurabilityMode::Async,
1975                GroupCommitOptions::default(),
1976            )
1977            .expect("open WAL"),
1978        );
1979        let recorder = Arc::new(
1980            StoreOwnershipRecorder::begin(Arc::clone(&coordinator), 7).expect("begin epoch"),
1981        );
1982        pager
1983            .install_ownership_recorder(recorder)
1984            .expect("install recorder");
1985        let page = pager
1986            .allocate_page(crate::storage::engine::PageType::BTreeLeaf)
1987            .expect("allocate")
1988            .page_id();
1989        pager
1990            .record_retirement_intent(&[page])
1991            .expect("record retirement");
1992        pager.free_retired_pages(&[page]).expect("retire");
1993        let records: Vec<_> = WalReader::open(&wal_path)
1994            .expect("read WAL")
1995            .iter()
1996            .map(|item| item.expect("WAL record").1)
1997            .collect();
1998        assert_eq!(
1999            records,
2000            vec![
2001                WalRecord::PageOwnership {
2002                    record: PageOwnershipRecord::Begin { epoch: 7 }
2003                },
2004                WalRecord::PageOwnership {
2005                    record: PageOwnershipRecord::Reserve {
2006                        epoch: 7,
2007                        page_id: page
2008                    }
2009                },
2010                WalRecord::PageOwnership {
2011                    record: PageOwnershipRecord::Retire {
2012                        epoch: 7,
2013                        page_id: page
2014                    }
2015                },
2016                WalRecord::PageOwnership {
2017                    record: PageOwnershipRecord::Retire {
2018                        epoch: 7,
2019                        page_id: page
2020                    }
2021                },
2022            ]
2023        );
2024        assert!(
2025            coordinator.fsync_count() >= 3,
2026            "async ownership writes must sync"
2027        );
2028        assert!(
2029            coordinator.truncate().is_err(),
2030            "ownership WAL remains protected"
2031        );
2032    }
2033
2034    #[test]
2035    fn replay_reclaims_unpublished_ownership_reservations() {
2036        use reddb_file::page_ownership_journal::PageOwnershipRecord;
2037
2038        let directory = tempfile::tempdir().expect("temporary store");
2039        let path = directory.path().join("data.rdb");
2040        {
2041            let store = UnifiedStore::open(&path).expect("create store");
2042            store.create_collection("rows").expect("collection");
2043            store.persist().expect("persist baseline");
2044        }
2045        let wal_path = reddb_file::layout::unified_wal_path(&path);
2046        let ids;
2047        {
2048            let pager =
2049                crate::storage::engine::Pager::open(&path, Default::default()).expect("pager");
2050            let first = pager.page_count().expect("page count");
2051            let coordinator = StoreCommitCoordinator::open(
2052                &wal_path,
2053                DurabilityMode::Strict,
2054                GroupCommitOptions::default(),
2055            )
2056            .expect("WAL");
2057            coordinator
2058                .append_durable_page_ownership_batch(&[
2059                    PageOwnershipRecord::Begin { epoch: 2 },
2060                    PageOwnershipRecord::Reserve {
2061                        epoch: 2,
2062                        page_id: first,
2063                    },
2064                    PageOwnershipRecord::Reserve {
2065                        epoch: 2,
2066                        page_id: first + 1,
2067                    },
2068                ])
2069                .expect("reserve pages durably");
2070            ids = [
2071                pager
2072                    .allocate_page(crate::storage::engine::PageType::BTreeLeaf)
2073                    .expect("allocate")
2074                    .page_id(),
2075                pager
2076                    .allocate_page(crate::storage::engine::PageType::BTreeLeaf)
2077                    .expect("allocate")
2078                    .page_id(),
2079            ];
2080            assert_eq!(ids, [first, first + 1]);
2081            pager.sync().expect("persist abandoned pages");
2082        }
2083        let reopened = UnifiedStore::open(&path).expect("recover ownership");
2084        let pager = reopened.pager.as_ref().expect("pager");
2085        let a = pager
2086            .allocate_page(crate::storage::engine::PageType::BTreeLeaf)
2087            .expect("reuse")
2088            .page_id();
2089        let b = pager
2090            .allocate_page(crate::storage::engine::PageType::BTreeLeaf)
2091            .expect("reuse")
2092            .page_id();
2093        assert_ne!(a, b);
2094        assert!(ids.contains(&a) && ids.contains(&b));
2095        let records: Vec<_> = WalReader::open(&wal_path)
2096            .expect("read WAL")
2097            .iter()
2098            .collect::<io::Result<Vec<_>>>()
2099            .expect("records");
2100        assert!(matches!(
2101            records.last(),
2102            Some((
2103                _,
2104                WalRecord::PageOwnership {
2105                    record: PageOwnershipRecord::Finish { epoch: 2 }
2106                }
2107            ))
2108        ));
2109        reopened
2110            .commit
2111            .as_ref()
2112            .expect("coordinator")
2113            .truncate()
2114            .expect("completed ownership can be truncated");
2115    }
2116
2117    #[cfg(target_os = "linux")]
2118    #[test]
2119    fn ownership_recovery_survives_interruption_and_retry() {
2120        use reddb_file::page_ownership_journal::PageOwnershipRecord;
2121
2122        const CHILD: &str = "REDDB_EXPERIMENTAL_OWNERSHIP_RECOVERY_PATH";
2123        if let Some(path) = std::env::var_os(CHILD) {
2124            let _store = UnifiedStore::open(path).expect("recover store");
2125            panic!("recovery interruption hook was not reached");
2126        }
2127        for (published, hook, exit_code) in [
2128            (
2129                false,
2130                "REDDB_EXPERIMENTAL_EXIT_DURING_OWNERSHIP_RECOVERY",
2131                187,
2132            ),
2133            (false, "REDDB_EXPERIMENTAL_EXIT_AFTER_OWNERSHIP_SYNC", 188),
2134            (false, "REDDB_EXPERIMENTAL_EXIT_AFTER_OWNERSHIP_FINISH", 189),
2135            (
2136                true,
2137                "REDDB_EXPERIMENTAL_EXIT_DURING_OWNERSHIP_RECOVERY",
2138                187,
2139            ),
2140            (true, "REDDB_EXPERIMENTAL_EXIT_AFTER_OWNERSHIP_SYNC", 188),
2141            (true, "REDDB_EXPERIMENTAL_EXIT_AFTER_OWNERSHIP_FINISH", 189),
2142        ] {
2143            let directory = tempfile::tempdir().expect("temporary store");
2144            let path = directory.path().join("data.rdb");
2145            {
2146                let store = UnifiedStore::open(&path).expect("create store");
2147                store.create_collection("rows").expect("collection");
2148                store.persist().expect("baseline");
2149                if published {
2150                    store.ownership_active_epoch.store(2, Ordering::Release);
2151                    store.flush_paged_registry().expect("publish epoch");
2152                    store.ownership_active_epoch.store(0, Ordering::Release);
2153                }
2154            }
2155            let wal_path = reddb_file::layout::unified_wal_path(&path);
2156            let ids;
2157            {
2158                let pager =
2159                    crate::storage::engine::Pager::open(&path, Default::default()).expect("pager");
2160                let first = pager.page_count().expect("page count");
2161                let coordinator = StoreCommitCoordinator::open(
2162                    &wal_path,
2163                    DurabilityMode::Strict,
2164                    GroupCommitOptions::default(),
2165                )
2166                .expect("WAL");
2167                let intent = |page_id| {
2168                    if published {
2169                        PageOwnershipRecord::Retire { epoch: 2, page_id }
2170                    } else {
2171                        PageOwnershipRecord::Reserve { epoch: 2, page_id }
2172                    }
2173                };
2174                coordinator
2175                    .append_durable_page_ownership_batch(&[
2176                        PageOwnershipRecord::Begin { epoch: 2 },
2177                        intent(first),
2178                        intent(first + 1),
2179                    ])
2180                    .expect("reserve");
2181                ids = [
2182                    pager
2183                        .allocate_page(crate::storage::engine::PageType::BTreeLeaf)
2184                        .expect("allocate")
2185                        .page_id(),
2186                    pager
2187                        .allocate_page(crate::storage::engine::PageType::BTreeLeaf)
2188                        .expect("allocate")
2189                        .page_id(),
2190                ];
2191                pager.sync().expect("persist abandoned pages");
2192            }
2193            for _ in 0..if exit_code == 189 { 1 } else { 2 } {
2194                let output = std::process::Command::new(std::env::current_exe().expect("test executable"))
2195                    .args(["--exact", "storage::unified::store::commit::tests::ownership_recovery_survives_interruption_and_retry", "--nocapture"])
2196                    .env(CHILD, &path)
2197                    .env(hook, "1")
2198                    .output().expect("run interrupted recovery");
2199                assert_eq!(
2200                    output.status.code(),
2201                    Some(exit_code),
2202                    "published={published} {hook}: {}",
2203                    String::from_utf8_lossy(&output.stderr)
2204                );
2205            }
2206            let reopened = UnifiedStore::open(&path).expect("recover after interruptions");
2207            let pager = reopened.pager.as_ref().expect("pager");
2208            let a = pager
2209                .allocate_page(crate::storage::engine::PageType::BTreeLeaf)
2210                .expect("reuse")
2211                .page_id();
2212            let b = pager
2213                .allocate_page(crate::storage::engine::PageType::BTreeLeaf)
2214                .expect("reuse")
2215                .page_id();
2216            assert_ne!(a, b, "published={published} {hook}: duplicate page");
2217            assert!(
2218                ids.contains(&a) && ids.contains(&b),
2219                "published={published} {hook}: lost recovered page"
2220            );
2221        }
2222    }
2223
2224    #[test]
2225    fn ownership_intents_are_durable_in_every_mode() {
2226        use reddb_file::page_ownership_journal::PageOwnershipRecord;
2227
2228        for mode in [
2229            DurabilityMode::Strict,
2230            DurabilityMode::WalDurableGrouped,
2231            DurabilityMode::Async,
2232        ] {
2233            let directory = tempfile::tempdir().expect("temporary WAL");
2234            let path = directory.path().join("ownership");
2235            let coordinator =
2236                StoreCommitCoordinator::open(&path, mode, GroupCommitOptions::default())
2237                    .expect("open coordinator");
2238            let intent = PageOwnershipRecord::Reserve {
2239                epoch: 1,
2240                page_id: 67,
2241            };
2242            let detach = PageOwnershipRecord::DetachFree {
2243                epoch: 1,
2244                page_id: 68,
2245            };
2246            coordinator
2247                .append_durable_page_ownership_batch(&[intent, detach])
2248                .expect("durable intent");
2249            assert!(
2250                coordinator.fsync_count() > 0,
2251                "ownership intent must be synced in {mode:?}"
2252            );
2253            let records: Vec<_> = WalReader::open(&path)
2254                .expect("open WAL")
2255                .iter()
2256                .collect::<io::Result<Vec<_>>>()
2257                .expect("read records");
2258            assert!(
2259                matches!(records.as_slice(), [(_, WalRecord::PageOwnership { record: first }), (_, WalRecord::PageOwnership { record: second })] if *first == intent && *second == detach)
2260            );
2261            assert!(coordinator
2262                .append_single_record(WalRecord::PageOwnership { record: intent })
2263                .is_err());
2264            assert!(coordinator
2265                .append_durable_page_ownership(PageOwnershipRecord::Begin { epoch: 0 })
2266                .is_err());
2267            assert!(
2268                coordinator.truncate().is_err(),
2269                "active ownership must prevent truncation"
2270            );
2271            let before = std::fs::read(&path).expect("ownership WAL");
2272            drop(coordinator);
2273            let reopened = StoreCommitCoordinator::open(&path, mode, GroupCommitOptions::default())
2274                .expect("reopen coordinator");
2275            assert!(
2276                reopened.truncate().is_err(),
2277                "reopen must rediscover ownership records"
2278            );
2279            assert_eq!(std::fs::read(&path).expect("preserved WAL"), before);
2280        }
2281    }
2282
2283    #[test]
2284    fn unresolved_ownership_intent_blocks_store_replay() {
2285        for truncated in [false, true] {
2286            let directory = tempfile::tempdir().expect("temporary store");
2287            let path = directory.path().join("data.rdb");
2288            {
2289                let store = UnifiedStore::open(&path).expect("create store");
2290                store.create_collection("rows").expect("create collection");
2291                store.persist().expect("persist baseline");
2292            }
2293            let wal_path = reddb_file::layout::unified_wal_path(&path);
2294            {
2295                let mut writer = WalWriter::open(&wal_path).expect("open WAL");
2296                writer
2297                    .append(&WalRecord::PageOwnership {
2298                        record: reddb_file::page_ownership_journal::PageOwnershipRecord::Reserve {
2299                            epoch: 1,
2300                            page_id: 67,
2301                        },
2302                    })
2303                    .expect("append ownership intent");
2304                writer.sync().expect("sync ownership intent");
2305            }
2306            if truncated {
2307                let wal = std::fs::OpenOptions::new()
2308                    .write(true)
2309                    .open(&wal_path)
2310                    .expect("WAL");
2311                let length = wal.metadata().expect("length").len();
2312                wal.set_len(length - 1).expect("truncate last byte");
2313                wal.sync_all().expect("sync truncation");
2314            }
2315            let before = std::fs::read(&path).expect("baseline data");
2316            let error = match UnifiedStore::open(&path) {
2317                Ok(_) => panic!("unresolved ownership must prevent opening"),
2318                Err(error) => error,
2319            };
2320            let expected = if truncated {
2321                "incomplete page ownership WAL record"
2322            } else {
2323                "page ownership journal requires recovery"
2324            };
2325            assert!(error.to_string().contains(expected), "{error}");
2326            assert_eq!(std::fs::read(&path).expect("preserved data"), before);
2327        }
2328    }
2329
2330    #[test]
2331    fn logical_wal_replay_restores_a_deleted_physical_identity() {
2332        for flat in [false, true] {
2333            let store = UnifiedStore::new();
2334            store.create_collection("replay").expect("collection");
2335            let id = EntityId::new(42);
2336            let entity = UnifiedEntity::table_row(id, "replay", 42, vec![Value::Integer(1)]);
2337            if flat {
2338                store
2339                    .bulk_insert("replay", vec![entity])
2340                    .expect("flat seed");
2341            } else {
2342                store.insert_auto("replay", entity).expect("hash seed");
2343            }
2344            let metadata = Metadata::with_fields(HashMap::from([(
2345                "source".into(),
2346                MetadataValue::String("wal".into()),
2347            )]));
2348            for sequence in 100..104 {
2349                let delete = StoreWalAction::DeleteEntityRecord {
2350                    collection: "replay".into(),
2351                    entity_id: id.raw(),
2352                };
2353                store
2354                    .apply_encoded_store_wal_action(&delete.encode())
2355                    .expect("replay delete");
2356                let mut restored =
2357                    UnifiedEntity::table_row(id, "replay", 42, vec![Value::Integer(2)]);
2358                restored.ensure_table_logical_id();
2359                restored.sequence_id = sequence;
2360                let upsert = StoreWalAction::upsert_entity(
2361                    "replay",
2362                    &restored,
2363                    Some(&metadata),
2364                    store.format_version(),
2365                );
2366                store
2367                    .apply_encoded_store_wal_action(&upsert.encode())
2368                    .expect("replay upsert");
2369                let manager = store.get_collection("replay").expect("restored collection");
2370                let stored = manager.get(id).expect("restored entity is readable");
2371                assert_eq!(store.entity_cache_stats().entries, 0);
2372                assert_eq!(manager.count(), 1);
2373                assert_eq!(manager.query_all(|_| true).len(), 1);
2374                assert_eq!(stored.sequence_id, sequence);
2375                assert_eq!(
2376                    UnifiedStore::serialize_entity_record(
2377                        &stored,
2378                        manager.get_metadata(id).as_ref(),
2379                        store.format_version()
2380                    ),
2381                    UnifiedStore::serialize_entity_record(
2382                        &restored,
2383                        Some(&metadata),
2384                        store.format_version()
2385                    )
2386                );
2387                assert!(store.get_any(id).is_some());
2388            }
2389        }
2390    }
2391
2392    fn embedded_fixture(
2393        mode: DurabilityMode,
2394        background_bytes: usize,
2395    ) -> (tempfile::TempDir, PathBuf, UnifiedStore) {
2396        let directory = tempfile::tempdir().expect("directory");
2397        let path = directory.path().join("data.rdb");
2398        let mut store =
2399            UnifiedStore::with_config(UnifiedStoreConfig::default().with_durability_mode(mode));
2400        store.create_collection("keys").expect("collection");
2401        store.set_aux_metadata(vec![42; background_bytes]);
2402        crate::storage::EmbeddedRdbArtifact::create_with_snapshot(
2403            &path,
2404            &store.to_binary_dump_bytes(),
2405        )
2406        .expect("snapshot");
2407        store.config.embedded_wal_path = Some(path.clone());
2408        (directory, path, store)
2409    }
2410
2411    #[test]
2412    fn embedded_stale_full_result_does_not_checkpoint_reclaimed_space() {
2413        for mode in [DurabilityMode::Strict, DurabilityMode::WalDurableGrouped] {
2414            let (_directory, path, store) = embedded_fixture(mode, 0);
2415            store
2416                .append_embedded_store_wal_actions(&[StoreWalAction::CreateCollection {
2417                    name: "keys".into(),
2418                }])
2419                .expect("another writer appended after reclaiming space");
2420            let before = crate::storage::EmbeddedRdbArtifact::open(&path).expect("before");
2421            let payloads = vec![StoreWalAction::CreateCollection {
2422                name: "after".into(),
2423            }
2424            .encode()];
2425            let length = crate::storage::EmbeddedRdbArtifact::wal_payloads_encoded_len(&payloads)
2426                .expect("encoded length");
2427            // Model a full result obtained before that other writer's checkpoint.
2428            store
2429                .recover_full_embedded_wal(&path, &payloads, length)
2430                .expect("revalidate after acquiring publication");
2431            let after = crate::storage::EmbeddedRdbArtifact::open(&path).expect("after");
2432            assert_eq!(
2433                after.selected_superblock.generation,
2434                before.selected_superblock.generation + 1,
2435                "only the retry is published, without an unnecessary checkpoint"
2436            );
2437            assert_eq!(
2438                crate::storage::EmbeddedRdbArtifact::read_wal_payloads(&after)
2439                    .expect("both appends retained")
2440                    .len(),
2441                2
2442            );
2443        }
2444    }
2445
2446    #[test]
2447    fn embedded_full_wal_reserves_bounded_headroom_for_populated_store() {
2448        for mode in [DurabilityMode::Strict, DurabilityMode::WalDurableGrouped] {
2449            let (_directory, path, store) = embedded_fixture(mode, 2 * 1024 * 1024);
2450            let initial = crate::storage::EmbeddedRdbArtifact::open(&path).expect("initial");
2451            let action = StoreWalAction::CreateCollection {
2452                name: "keys".into(),
2453            };
2454            let payloads = vec![action.encode()];
2455            let length = crate::storage::EmbeddedRdbArtifact::wal_payloads_encoded_len(&payloads)
2456                .expect("encoded length");
2457            crate::storage::EmbeddedRdbArtifact::append_wal_payloads(
2458                &path,
2459                &vec![payloads[0].clone(); (initial.manifest.wal_region_bytes / length) as usize],
2460            )
2461            .expect("fill the initial region with valid records");
2462            store
2463                .append_embedded_store_wal_actions(&[action])
2464                .expect("overflow");
2465            let open = crate::storage::EmbeddedRdbArtifact::open(&path).expect("checkpoint");
2466            assert_eq!(open.manifest.wal_region_bytes, 1024 * 1024);
2467            let snapshot = crate::storage::EmbeddedRdbArtifact::read_snapshot(&open)
2468                .expect("snapshot")
2469                .expect("populated image");
2470            let recovered =
2471                UnifiedStore::load_from_bytes_with_config(&snapshot, UnifiedStoreConfig::default())
2472                    .expect("recover snapshot");
2473            for payload in
2474                crate::storage::EmbeddedRdbArtifact::read_wal_payloads(&open).expect("WAL")
2475            {
2476                recovered
2477                    .apply_encoded_store_wal_action(&payload)
2478                    .expect("replay");
2479            }
2480            assert_eq!(
2481                recovered.resident.aux_metadata.read().len(),
2482                2 * 1024 * 1024
2483            );
2484            assert!(recovered.get_collection("keys").is_some());
2485        }
2486    }
2487
2488    #[test]
2489    fn embedded_grouped_overwrites_recover_across_repeated_full_wal_regions() {
2490        fn row(writer: usize, round: usize) -> UnifiedEntity {
2491            let mut state = (writer as u64 + 1) * 31 + round as u64 * 13;
2492            let payload: String = (0..4096)
2493                .map(|_| {
2494                    state = state.wrapping_mul(1664525).wrapping_add(1013904223);
2495                    char::from(b'!' + ((state >> 24) % 90) as u8)
2496                })
2497                .collect();
2498            let mut entity = UnifiedEntity::table_row(EntityId::new(0), "keys", 0, vec![]);
2499            entity.data = EntityData::Row(RowData::with_names(
2500                vec![
2501                    Value::text(format!("k{writer}")),
2502                    Value::text(format!("{round}:{payload}")),
2503                ],
2504                vec!["key".into(), "value".into()],
2505            ));
2506            entity
2507        }
2508        let (_directory, path, store) =
2509            embedded_fixture(DurabilityMode::WalDurableGrouped, 2 * 1024 * 1024);
2510        assert!(
2511            StoreWalAction::upsert_entity("keys", &row(0, 0), None, store.format_version())
2512                .encode()
2513                .len()
2514                >= 2048,
2515            "fixture must exert physical WAL pressure after value compression"
2516        );
2517        let mut ids: Vec<_> = (0..8)
2518            .map(|writer| {
2519                store
2520                    .write_keyed_row("keys", row(writer, 0), None, None)
2521                    .expect("seed")
2522            })
2523            .collect();
2524        let initial = crate::storage::EmbeddedRdbArtifact::open(&path).expect("initial roots");
2525        let mut previous_snapshot = crate::storage::EmbeddedRdbArtifact::read_snapshot(&initial)
2526            .expect("initial snapshot")
2527            .expect("image");
2528        // Each wave writes over 2 MiB through the same 1 MiB reservation.
2529        // Checking image changes after each wave prevents a larger WAL from
2530        // merely postponing every checkpoint beyond the test's duration.
2531        for wave in 0..3 {
2532            let barrier = Barrier::new(ids.len());
2533            ids = std::thread::scope(|scope| {
2534                let workers: Vec<_> = ids
2535                    .iter()
2536                    .enumerate()
2537                    .map(|(writer, previous)| {
2538                        let store = &store;
2539                        let barrier = &barrier;
2540                        scope.spawn(move || {
2541                            barrier.wait();
2542                            let mut id = *previous;
2543                            for round in 0..64 {
2544                                id = store
2545                                    .write_keyed_row(
2546                                        "keys",
2547                                        row(writer, wave * 64 + round),
2548                                        None,
2549                                        Some(id),
2550                                    )
2551                                    .expect("durable overwrite");
2552                            }
2553                            id
2554                        })
2555                    })
2556                    .collect();
2557                workers
2558                    .into_iter()
2559                    .map(|worker| worker.join().expect("writer"))
2560                    .collect()
2561            });
2562            let open = crate::storage::EmbeddedRdbArtifact::open(&path).expect("wave roots");
2563            assert_eq!(open.manifest.wal_region_bytes, EMBEDDED_WAL_HEADROOM_LIMIT);
2564            let snapshot = crate::storage::EmbeddedRdbArtifact::read_snapshot(&open)
2565                .expect("wave snapshot")
2566                .expect("image");
2567            assert!(snapshot != previous_snapshot, "wave {wave} must checkpoint");
2568            previous_snapshot = snapshot;
2569        }
2570        let open = crate::storage::EmbeddedRdbArtifact::open(&path).expect("recover roots");
2571        let snapshot = crate::storage::EmbeddedRdbArtifact::read_snapshot(&open)
2572            .expect("snapshot")
2573            .expect("image");
2574        let recovered =
2575            UnifiedStore::load_from_bytes_with_config(&snapshot, UnifiedStoreConfig::default())
2576                .expect("snapshot replay");
2577        for payload in crate::storage::EmbeddedRdbArtifact::read_wal_payloads(&open).expect("WAL") {
2578            recovered
2579                .apply_encoded_store_wal_action(&payload)
2580                .expect("WAL replay");
2581        }
2582        assert_eq!(recovered.get_collection("keys").expect("keys").count(), 8);
2583        for (writer, id) in ids.iter().enumerate() {
2584            let entity = recovered
2585                .get("keys", *id)
2586                .expect("last acknowledged version");
2587            assert_eq!(
2588                entity.data.as_row().expect("row").get_field("value"),
2589                row(writer, 191)
2590                    .data
2591                    .as_row()
2592                    .expect("expected row")
2593                    .get_field("value")
2594            );
2595        }
2596    }
2597
2598    /// Serialise tests that mutate `REDDB_GROUP_COMMIT_WINDOW_US`.
2599    /// The env table is process-global, so two parallel test threads
2600    /// flipping it would race the assertions in the test that
2601    /// happens to read it last.
2602    fn env_lock() -> &'static StdMutex<()> {
2603        static LOCK: OnceLock<StdMutex<()>> = OnceLock::new();
2604        LOCK.get_or_init(|| StdMutex::new(()))
2605    }
2606
2607    fn temp_wal(name: &str) -> PathBuf {
2608        let nanos = SystemTime::now()
2609            .duration_since(std::time::UNIX_EPOCH)
2610            .unwrap()
2611            .as_nanos();
2612        let path = reddb_file::layout::store_commit_coord_temp_wal_path(
2613            &std::env::temp_dir(),
2614            name,
2615            std::process::id(),
2616            nanos,
2617        );
2618        let _ = std::fs::remove_file(&path);
2619        path
2620    }
2621
2622    fn replay_crash_payload(id: u64, round: u64) -> String {
2623        let mut state = id * 31 + round * 13 + 1;
2624        (0..600)
2625            .map(|_| {
2626                state = state.wrapping_mul(1664525).wrapping_add(1013904223);
2627                char::from(b'!' + ((state >> 24) % 90) as u8)
2628            })
2629            .collect()
2630    }
2631
2632    #[cfg(target_os = "linux")]
2633    #[test]
2634    fn interrupted_replay_reclaims_unpublished_pages() {
2635        interrupted_replay_reclaims_pages(false, false);
2636    }
2637
2638    #[cfg(target_os = "linux")]
2639    #[test]
2640    fn interrupted_replay_reclaims_durable_unpublished_pages() {
2641        interrupted_replay_reclaims_pages(false, true);
2642    }
2643
2644    #[cfg(target_os = "linux")]
2645    #[test]
2646    fn interrupted_replay_preserves_published_pages() {
2647        interrupted_replay_reclaims_pages(true, false);
2648    }
2649
2650    #[cfg(target_os = "linux")]
2651    #[test]
2652    fn interrupted_checkpoint_reclaims_unpublished_pages() {
2653        const CHILD_PATH: &str = "REDDB_EXPERIMENTAL_CHECKPOINT_REBUILD_PATH";
2654        let config = || {
2655            UnifiedStoreConfig::default()
2656                .with_durability_mode(DurabilityMode::Strict)
2657                .with_page_cache_slots(4)
2658        };
2659        if let Some(path) = std::env::var_os(CHILD_PATH) {
2660            let store = UnifiedStore::open_with_config(path, config()).unwrap();
2661            // Inject resident changes without a WAL record: after the aborted
2662            // checkpoint the old durable rows must remain authoritative.
2663            for id in 1..=200 {
2664                let entity = UnifiedEntity::table_row(
2665                    EntityId::new(id),
2666                    "rows",
2667                    id,
2668                    vec![Value::text(replay_crash_payload(id, 8))],
2669                );
2670                let record =
2671                    UnifiedStore::serialize_entity_record(&entity, None, STORE_VERSION_V11);
2672                store.apply_replayed_upsert("rows", &record, false).unwrap();
2673            }
2674            store.persist().expect("must exit during checkpoint");
2675            panic!("checkpoint rebuild hook was not reached");
2676        }
2677        let directory = tempfile::tempdir().unwrap();
2678        let path = directory.path().join("data.rdb");
2679        {
2680            let store = UnifiedStore::open_with_config(&path, config()).unwrap();
2681            store.create_collection("rows").unwrap();
2682            for id in 1..=200 {
2683                store
2684                    .insert(
2685                        "rows",
2686                        UnifiedEntity::table_row(
2687                            EntityId::new(id),
2688                            "rows",
2689                            id,
2690                            vec![Value::text(format!("initial-{id}"))],
2691                        ),
2692                    )
2693                    .unwrap();
2694            }
2695            store.persist().unwrap();
2696        }
2697        let mut first = 0;
2698        let mut last = 0;
2699        for cycle in 1..=12 {
2700            let output = std::process::Command::new(std::env::current_exe().unwrap())
2701                .args([
2702                    "--exact",
2703                    "storage::unified::store::commit::tests::interrupted_checkpoint_reclaims_unpublished_pages",
2704                    "--nocapture",
2705                ])
2706                .env(CHILD_PATH, &path)
2707                .env("REDDB_EXPERIMENTAL_EXIT_DURING_CHECKPOINT_REBUILD", "1")
2708                .output()
2709                .unwrap();
2710            assert_eq!(
2711                output.status.code(),
2712                Some(183),
2713                "{}",
2714                String::from_utf8_lossy(&output.stderr)
2715            );
2716            assert!(String::from_utf8_lossy(&output.stderr)
2717                .contains("experimental checkpoint exit before manifest"));
2718            last = std::fs::metadata(&path).unwrap().len();
2719            if cycle == 1 {
2720                first = last;
2721            }
2722            eprintln!("checkpoint_exit_cycle={cycle} data_logical={last}");
2723        }
2724        let recovered = UnifiedStore::open_with_config(&path, config()).unwrap();
2725        assert_eq!(recovered.get_collection("rows").unwrap().count(), 200);
2726        for id in 1..=200 {
2727            assert_eq!(
2728                recovered
2729                    .get("rows", EntityId::new(id))
2730                    .unwrap()
2731                    .data
2732                    .as_row()
2733                    .unwrap()
2734                    .columns,
2735                vec![Value::text(format!("initial-{id}"))]
2736            );
2737        }
2738        assert!(last <= first + 2 * crate::storage::engine::PAGE_SIZE as u64,
2739            "repeated interrupted checkpoint must bound page growth: first={first}, after_12={last}");
2740    }
2741
2742    #[cfg(target_os = "linux")]
2743    #[test]
2744    fn interrupted_checkpoint_with_wal_reclaims_unpublished_pages() {
2745        use std::os::unix::fs::MetadataExt;
2746        const CHILD_PATH: &str = "REDDB_EXPERIMENTAL_CHECKPOINT_WAL_PATH";
2747        const ROUND: &str = "REDDB_EXPERIMENTAL_CHECKPOINT_WAL_ROUND";
2748        let config = || {
2749            UnifiedStoreConfig::default()
2750                .with_durability_mode(DurabilityMode::Strict)
2751                .with_page_cache_slots(4)
2752        };
2753        if let Some(path) = std::env::var_os(CHILD_PATH) {
2754            let round: u64 = std::env::var(ROUND).unwrap().parse().unwrap();
2755            let store = UnifiedStore::open_with_config(path, config()).unwrap();
2756            if round > 1 {
2757                for id in 1..=200 {
2758                    assert_eq!(
2759                        store
2760                            .get("rows", EntityId::new(id))
2761                            .unwrap()
2762                            .data
2763                            .as_row()
2764                            .unwrap()
2765                            .columns,
2766                        vec![Value::text(replay_crash_payload(id, round - 1))]
2767                    );
2768                }
2769            }
2770            let actions: Vec<_> = (1..=200)
2771                .map(|id| {
2772                    let entity = UnifiedEntity::table_row(
2773                        EntityId::new(id),
2774                        "rows",
2775                        id,
2776                        vec![Value::text(replay_crash_payload(id, round))],
2777                    );
2778                    StoreWalAction::upsert_entity("rows", &entity, None, STORE_VERSION_V11)
2779                })
2780                .collect();
2781            for action in &actions {
2782                store.apply_replayed_action(action, false).unwrap();
2783            }
2784            // Use the actual strict WAL coordinator. No checkpoint proof is
2785            // installed, so the replacement-tree path must still be taken.
2786            store
2787                .commit
2788                .as_ref()
2789                .unwrap()
2790                .append_actions(&actions)
2791                .unwrap();
2792            store.persist().expect("must exit during checkpoint");
2793            panic!("checkpoint rebuild hook was not reached");
2794        }
2795        let directory = tempfile::tempdir().unwrap();
2796        let path = directory.path().join("data.rdb");
2797        {
2798            let store = UnifiedStore::open_with_config(&path, config()).unwrap();
2799            store.create_collection("rows").unwrap();
2800            for id in 1..=200 {
2801                store
2802                    .insert(
2803                        "rows",
2804                        UnifiedEntity::table_row(
2805                            EntityId::new(id),
2806                            "rows",
2807                            id,
2808                            vec![Value::text(format!("initial-{id}"))],
2809                        ),
2810                    )
2811                    .unwrap();
2812            }
2813            store.persist().unwrap();
2814        }
2815        let mut fourth = 0;
2816        let mut last = 0;
2817        for round in 1..=12 {
2818            let output = std::process::Command::new(std::env::current_exe().unwrap())
2819                .args([
2820                    "--exact",
2821                    "storage::unified::store::commit::tests::interrupted_checkpoint_with_wal_reclaims_unpublished_pages",
2822                    "--nocapture",
2823                ])
2824                .env(CHILD_PATH, &path)
2825                .env(ROUND, round.to_string())
2826                .env("REDDB_EXPERIMENTAL_EXIT_DURING_CHECKPOINT_REBUILD", "1")
2827                .output().unwrap();
2828            assert_eq!(
2829                output.status.code(),
2830                Some(183),
2831                "round {round}: {}",
2832                String::from_utf8_lossy(&output.stderr)
2833            );
2834            assert!(String::from_utf8_lossy(&output.stderr)
2835                .contains("experimental checkpoint exit before manifest"));
2836            let meta = std::fs::metadata(&path).unwrap();
2837            last = meta.len();
2838            if round == 4 {
2839                fourth = last;
2840            }
2841            eprintln!(
2842                "checkpoint_wal_exit_round={round} data_logical={last} data_allocated={}",
2843                meta.blocks() * 512
2844            );
2845        }
2846        let recovered = UnifiedStore::open_with_config(&path, config()).unwrap();
2847        assert_eq!(recovered.get_collection("rows").unwrap().count(), 200);
2848        for id in 1..=200 {
2849            assert_eq!(
2850                recovered
2851                    .get("rows", EntityId::new(id))
2852                    .unwrap()
2853                    .data
2854                    .as_row()
2855                    .unwrap()
2856                    .columns,
2857                vec![Value::text(replay_crash_payload(id, 12))]
2858            );
2859        }
2860        recovered.persist().unwrap();
2861        drop(recovered);
2862        let reopened = UnifiedStore::open_with_config(&path, config()).unwrap();
2863        for id in 1..=200 {
2864            assert_eq!(
2865                reopened
2866                    .get("rows", EntityId::new(id))
2867                    .unwrap()
2868                    .data
2869                    .as_row()
2870                    .unwrap()
2871                    .columns,
2872                vec![Value::text(replay_crash_payload(id, 12))]
2873            );
2874        }
2875        assert!(last <= fourth + 2 * crate::storage::engine::PAGE_SIZE as u64,
2876            "repeated interrupted WAL-backed checkpoints must bound page growth: round4={fourth}, round12={last}");
2877    }
2878
2879    #[cfg(target_os = "linux")]
2880    #[test]
2881    fn interrupted_retired_reclamation_preserves_reuse() {
2882        const CHILD_PATH: &str = "REDDB_EXPERIMENTAL_RETIRED_PATH";
2883        fn mutate(store: &UnifiedStore, round: u64) {
2884            for id in 1..=200 {
2885                let entity = UnifiedEntity::table_row(
2886                    EntityId::new(id),
2887                    "rows",
2888                    id,
2889                    vec![Value::text(replay_crash_payload(id, round))],
2890                );
2891                let record =
2892                    UnifiedStore::serialize_entity_record(&entity, None, STORE_VERSION_V11);
2893                store.apply_replayed_upsert("rows", &record, false).unwrap();
2894            }
2895        }
2896        let config = || {
2897            UnifiedStoreConfig::default()
2898                .with_durability_mode(DurabilityMode::Strict)
2899                .with_page_cache_slots(4)
2900        };
2901        if let Some(path) = std::env::var_os(CHILD_PATH) {
2902            let store = UnifiedStore::open_with_config(path, config()).unwrap();
2903            mutate(&store, 2);
2904            store.persist().expect("must exit at retired free boundary");
2905            panic!("retired free hook was not reached");
2906        }
2907        let mut reuse = Vec::new();
2908        for (label, env_name, exit_code, message) in [
2909            (
2910                "before",
2911                "REDDB_EXPERIMENTAL_EXIT_BEFORE_RETIRED_FREE",
2912                184,
2913                "experimental exit before retired free",
2914            ),
2915            (
2916                "enqueued",
2917                "REDDB_EXPERIMENTAL_EXIT_AFTER_RETIRED_ENQUEUE",
2918                186,
2919                "experimental exit after retired enqueue",
2920            ),
2921            (
2922                "after",
2923                "REDDB_EXPERIMENTAL_EXIT_AFTER_RETIRED_FREE",
2924                185,
2925                "experimental exit after retired free",
2926            ),
2927        ] {
2928            let directory = tempfile::tempdir().unwrap();
2929            let path = directory.path().join("data.rdb");
2930            {
2931                let store = UnifiedStore::open_with_config(&path, config()).unwrap();
2932                store.create_collection("rows").unwrap();
2933                for id in 1..=200 {
2934                    store
2935                        .insert(
2936                            "rows",
2937                            UnifiedEntity::table_row(
2938                                EntityId::new(id),
2939                                "rows",
2940                                id,
2941                                vec![Value::text(format!("initial-{id}"))],
2942                            ),
2943                        )
2944                        .unwrap();
2945                }
2946                store.persist().unwrap();
2947                mutate(&store, 1);
2948                store.persist().unwrap();
2949            }
2950            let output = std::process::Command::new(std::env::current_exe().unwrap())
2951                .args([
2952                    "--exact",
2953                    "storage::unified::store::commit::tests::interrupted_retired_reclamation_preserves_reuse",
2954                    "--nocapture",
2955                ])
2956                .env(CHILD_PATH, &path)
2957                .env(env_name, "1")
2958                .output().unwrap();
2959            assert_eq!(
2960                output.status.code(),
2961                Some(exit_code),
2962                "{label}: {}",
2963                String::from_utf8_lossy(&output.stderr)
2964            );
2965            assert!(String::from_utf8_lossy(&output.stderr).contains(message));
2966            eprintln!(
2967                "retired_reclaim_{label} child={}",
2968                String::from_utf8_lossy(&output.stderr).trim()
2969            );
2970            let store = UnifiedStore::open_with_config(&path, config()).unwrap();
2971            for id in 1..=200 {
2972                assert_eq!(
2973                    store
2974                        .get("rows", EntityId::new(id))
2975                        .unwrap()
2976                        .data
2977                        .as_row()
2978                        .unwrap()
2979                        .columns,
2980                    vec![Value::text(replay_crash_payload(id, 2))]
2981                );
2982            }
2983            let pager = store.pager.as_ref().unwrap();
2984            let before_pages = pager.page_count().unwrap();
2985            let mut ids = std::collections::HashSet::new();
2986            let mut reused = 0;
2987            for _ in 0..256 {
2988                let page = pager
2989                    .allocate_page(crate::storage::engine::page::PageType::BTreeLeaf)
2990                    .unwrap();
2991                let id = page.page_id();
2992                assert!(ids.insert(id), "duplicate allocation");
2993                reused += u32::from(id < before_pages);
2994                pager.write_page(id, page).unwrap();
2995            }
2996            pager.sync().unwrap();
2997            eprintln!(
2998                "retired_reclaim_{label} before_pages={before_pages} reused={reused} appended={}",
2999                256 - reused
3000            );
3001            drop(store);
3002            let reopened = UnifiedStore::open_with_config(&path, config()).unwrap();
3003            for id in 1..=200 {
3004                assert_eq!(
3005                    reopened
3006                        .get("rows", EntityId::new(id))
3007                        .unwrap()
3008                        .data
3009                        .as_row()
3010                        .unwrap()
3011                        .columns,
3012                    vec![Value::text(replay_crash_payload(id, 2))]
3013                );
3014            }
3015            reuse.push(reused);
3016        }
3017        assert!(
3018            reuse[0] >= reuse[2] && reuse[1] >= reuse[2],
3019            "recovery must not lose retired pages before allocator publication: before={} enqueued={} after={}",
3020            reuse[0],
3021            reuse[1],
3022            reuse[2]
3023        );
3024    }
3025
3026    #[cfg(target_os = "linux")]
3027    #[test]
3028    fn experimental_retaining_released_root_is_not_recoverable() {
3029        let directory = tempfile::tempdir().unwrap();
3030        let path = directory.path().join("data.rdb");
3031        let config = || {
3032            UnifiedStoreConfig::default()
3033                .with_durability_mode(DurabilityMode::Strict)
3034                .with_page_cache_slots(4)
3035        };
3036        {
3037            let store = UnifiedStore::open_with_config(&path, config()).unwrap();
3038            store.create_collection("rows").unwrap();
3039            for id in 1..=200 {
3040                store
3041                    .insert(
3042                        "rows",
3043                        UnifiedEntity::table_row(
3044                            EntityId::new(id),
3045                            "rows",
3046                            id,
3047                            vec![Value::text(format!("initial-{id}"))],
3048                        ),
3049                    )
3050                    .unwrap();
3051            }
3052            store.persist().unwrap();
3053            for id in 1..=200 {
3054                let entity = UnifiedEntity::table_row(
3055                    EntityId::new(id),
3056                    "rows",
3057                    id,
3058                    vec![Value::text(replay_crash_payload(id, 1))],
3059                );
3060                let record =
3061                    UnifiedStore::serialize_entity_record(&entity, None, STORE_VERSION_V11);
3062                store.apply_replayed_upsert("rows", &record, false).unwrap();
3063            }
3064            store.persist().unwrap();
3065        }
3066        let output = std::process::Command::new(std::env::current_exe().unwrap())
3067            .args(["--exact", "storage::unified::store::commit::tests::interrupted_retired_reclamation_preserves_reuse", "--nocapture"])
3068            .env("REDDB_EXPERIMENTAL_RETIRED_PATH", &path)
3069            .env("REDDB_EXPERIMENTAL_RETAIN_RELEASED_ROOT", "1")
3070            .env("REDDB_EXPERIMENTAL_EXIT_AFTER_RETIRED_FREE", "1")
3071            .output().unwrap();
3072        assert_eq!(
3073            output.status.code(),
3074            Some(185),
3075            "{}",
3076            String::from_utf8_lossy(&output.stderr)
3077        );
3078        assert!(String::from_utf8_lossy(&output.stderr)
3079            .contains("experimental exit after retired free"));
3080
3081        let reopened = UnifiedStore::open_with_config(&path, config()).unwrap();
3082        for id in 1..=200 {
3083            assert_eq!(
3084                reopened
3085                    .get("rows", EntityId::new(id))
3086                    .unwrap()
3087                    .data
3088                    .as_row()
3089                    .unwrap()
3090                    .columns,
3091                vec![Value::text(replay_crash_payload(id, 2))]
3092            );
3093        }
3094        let retired_roots: Vec<_> = reopened
3095            .retired_btrees
3096            .lock()
3097            .iter()
3098            .map(|entry| entry.tree.root_page_id())
3099            .collect();
3100        let pager = reopened.pager.as_ref().unwrap();
3101        let root_types: Vec<_> = retired_roots
3102            .iter()
3103            .map(|id| pager.read_page(*id).unwrap().page_type().unwrap())
3104            .collect();
3105        assert!(
3106            root_types.contains(&crate::storage::engine::page::PageType::FreelistTrunk),
3107            "expected a retained root to have become a freelist trunk: {root_types:?}"
3108        );
3109        let error = reopened
3110            .persist()
3111            .expect_err("retained root was reused as allocator metadata");
3112        assert!(
3113            error
3114                .to_string()
3115                .contains("unexpected retired tree page type"),
3116            "{error}"
3117        );
3118        eprintln!("retained_root_types={root_types:?} persist_error={error}");
3119    }
3120
3121    #[cfg(target_os = "linux")]
3122    fn interrupted_replay_reclaims_pages(after_publication: bool, force_sync: bool) {
3123        use std::os::unix::fs::MetadataExt;
3124        const CHILD_PATH: &str = "REDDB_EXPERIMENTAL_REPLAY_REBUILD_PATH";
3125        let config = || {
3126            UnifiedStoreConfig::default()
3127                .with_durability_mode(DurabilityMode::Strict)
3128                .with_page_cache_slots(4)
3129        };
3130        if let Some(path) = std::env::var_os(CHILD_PATH) {
3131            let _store = UnifiedStore::open_with_config(path, config())
3132                .expect("must exit while replay rebuilds");
3133            panic!("replay rebuild hook was not reached");
3134        }
3135        let directory = tempfile::tempdir().unwrap();
3136        let path = directory.path().join("data.rdb");
3137        {
3138            let store = UnifiedStore::open_with_config(&path, config()).unwrap();
3139            store.create_collection("rows").unwrap();
3140            for id in 1..=200 {
3141                store
3142                    .insert(
3143                        "rows",
3144                        UnifiedEntity::table_row(
3145                            EntityId::new(id),
3146                            "rows",
3147                            id,
3148                            vec![Value::text(format!("initial-{id}"))],
3149                        ),
3150                    )
3151                    .unwrap();
3152            }
3153            store.persist().unwrap();
3154        }
3155        {
3156            let coordinator = StoreCommitCoordinator::open(
3157                reddb_file::layout::unified_wal_path(&path),
3158                DurabilityMode::Strict,
3159                GroupCommitOptions::default(),
3160            )
3161            .unwrap();
3162            let actions = (1..=200)
3163                .map(|id| {
3164                    let entity = UnifiedEntity::table_row(
3165                        EntityId::new(id),
3166                        "rows",
3167                        id,
3168                        vec![Value::text(replay_crash_payload(id, 8))],
3169                    );
3170                    StoreWalAction::upsert_entity("rows", &entity, None, STORE_VERSION_V11)
3171                })
3172                .collect::<Vec<_>>();
3173            coordinator.append_actions(&actions).unwrap();
3174        }
3175        let snapshot = || {
3176            let data = std::fs::metadata(&path).unwrap();
3177            let wal = std::fs::metadata(reddb_file::layout::unified_wal_path(&path)).unwrap();
3178            (
3179                data.len(),
3180                data.blocks() * 512,
3181                wal.len(),
3182                wal.blocks() * 512,
3183            )
3184        };
3185        let baseline = snapshot();
3186        let mut previous = baseline;
3187        let mut first_failure = baseline;
3188        for cycle in 1..=12 {
3189            let test_name = if after_publication {
3190                "storage::unified::store::commit::tests::interrupted_replay_preserves_published_pages"
3191            } else if force_sync {
3192                "storage::unified::store::commit::tests::interrupted_replay_reclaims_durable_unpublished_pages"
3193            } else {
3194                "storage::unified::store::commit::tests::interrupted_replay_reclaims_unpublished_pages"
3195            };
3196            let mut command = std::process::Command::new(std::env::current_exe().unwrap());
3197            command
3198                .args(["--exact", test_name, "--nocapture"])
3199                .env(CHILD_PATH, &path);
3200            if !force_sync {
3201                command.env("REDDB_EXPERIMENTAL_REPLAY_NO_SYNC", "1");
3202            } else {
3203                command.env_remove("REDDB_EXPERIMENTAL_REPLAY_NO_SYNC");
3204            }
3205            let message = if after_publication {
3206                command.env("REDDB_EXPERIMENTAL_EXIT_AFTER_REPLAY_PUBLICATION", "1");
3207                "experimental replay exit after manifest"
3208            } else {
3209                command.env("REDDB_EXPERIMENTAL_EXIT_DURING_REPLAY_REBUILD", "1");
3210                "experimental replay rebuild exit before manifest"
3211            };
3212            let output = command.output().unwrap();
3213            assert_eq!(
3214                output.status.code(),
3215                Some(182),
3216                "cycle {cycle}: {}",
3217                String::from_utf8_lossy(&output.stderr)
3218            );
3219            assert!(String::from_utf8_lossy(&output.stderr).contains(message));
3220            let now = snapshot();
3221            eprintln!("replay_exit_cycle={cycle} data_logical={} data_allocated={} wal_logical={} wal_allocated={}",now.0,now.1,now.2,now.3);
3222            if cycle == 1 {
3223                first_failure = now;
3224            }
3225            previous = now;
3226        }
3227        let recovered = UnifiedStore::open_with_config(&path, config()).unwrap();
3228        assert_eq!(recovered.get_collection("rows").unwrap().count(), 200);
3229        for id in 1..=200 {
3230            assert_eq!(
3231                recovered
3232                    .get("rows", EntityId::new(id))
3233                    .unwrap()
3234                    .data
3235                    .as_row()
3236                    .unwrap()
3237                    .columns,
3238                vec![Value::text(replay_crash_payload(id, 8))]
3239            );
3240        }
3241        let pages = recovered.pager.as_ref().unwrap().page_count().unwrap();
3242        let free = recovered.pager.as_ref().unwrap().free_page_count().unwrap();
3243        recovered.persist().unwrap();
3244        drop(recovered);
3245        let final_sizes = snapshot();
3246        let reopened = UnifiedStore::open_with_config(&path, config()).unwrap();
3247        assert_eq!(reopened.get_collection("rows").unwrap().count(), 200);
3248        for id in 1..=200 {
3249            assert_eq!(
3250                reopened
3251                    .get("rows", EntityId::new(id))
3252                    .unwrap()
3253                    .data
3254                    .as_row()
3255                    .unwrap()
3256                    .columns,
3257                vec![Value::text(replay_crash_payload(id, 8))]
3258            );
3259        }
3260        eprintln!("replay_rebuild baseline={baseline:?} after_12={previous:?} after_recovery_checkpoint={final_sizes:?} pages={pages} free={free}");
3261        // Probe actual allocation rather than the cached free-page counter:
3262        // newly allocated IDs reveal whether interrupted generations returned
3263        // to the allocator after recovery and a completed checkpoint.
3264        let pager = reopened.pager.as_ref().unwrap();
3265        let before_probe = pager.page_count().unwrap();
3266        let mut ids = std::collections::HashSet::new();
3267        let mut reused = 0;
3268        for _ in 0..256 {
3269            let page = pager
3270                .allocate_page(crate::storage::engine::page::PageType::BTreeLeaf)
3271                .unwrap();
3272            let id = page.page_id();
3273            assert!(ids.insert(id), "allocator returned the same page twice");
3274            reused += u32::from(id < before_probe);
3275            pager.write_page(id, page).unwrap();
3276        }
3277        pager.sync().unwrap();
3278        let after_probe = pager.page_count().unwrap();
3279        eprintln!("replay_reuse_probe count=256 before_pages={before_probe} after_pages={after_probe} reused={reused} appended={}", 256 - reused);
3280        drop(reopened);
3281        let verified = UnifiedStore::open_with_config(&path, config()).unwrap();
3282        for id in 1..=200 {
3283            assert_eq!(
3284                verified
3285                    .get("rows", EntityId::new(id))
3286                    .unwrap()
3287                    .data
3288                    .as_row()
3289                    .unwrap()
3290                    .columns,
3291                vec![Value::text(replay_crash_payload(id, 8))]
3292            );
3293        }
3294        assert!(
3295            previous.0 <= first_failure.0 + 2 * crate::storage::engine::PAGE_SIZE as u64,
3296            "repeated interrupted replay must bound page growth: first={}, after_12={}",
3297            first_failure.0,
3298            previous.0
3299        );
3300    }
3301
3302    #[test]
3303    fn paged_wal_replay_survives_immediate_process_exit() {
3304        const CHILD_PATH: &str = "REDDB_TEST_REPLAY_CRASH_PATH";
3305        let config = || UnifiedStoreConfig::default().with_page_cache_slots(4);
3306        if let Some(path) = std::env::var_os(CHILD_PATH) {
3307            let store = UnifiedStore::open_with_config(path, config()).expect("replay child");
3308            assert_eq!(store.get_collection("rows").unwrap().count(), 400);
3309            // No Rust destructors or graceful checkpoint may repair the image.
3310            std::process::exit(0);
3311        }
3312        let directory = tempfile::tempdir().unwrap();
3313        let path = directory.path().join("data.rdb");
3314        {
3315            let store = UnifiedStore::open_with_config(&path, config()).unwrap();
3316            store.create_collection("rows").unwrap();
3317            for id in 1..=400 {
3318                store
3319                    .insert(
3320                        "rows",
3321                        UnifiedEntity::table_row(
3322                            EntityId::new(id),
3323                            "rows",
3324                            id,
3325                            vec![Value::text(format!("initial-{id}"))],
3326                        ),
3327                    )
3328                    .unwrap();
3329            }
3330            store.persist().unwrap();
3331        }
3332        {
3333            let coordinator = StoreCommitCoordinator::open(
3334                reddb_file::layout::unified_wal_path(&path),
3335                DurabilityMode::Strict,
3336                GroupCommitOptions::default(),
3337            )
3338            .unwrap();
3339            for round in 0..3 {
3340                let actions = (1..=400)
3341                    .map(|id| {
3342                        let payload = replay_crash_payload(id, round);
3343                        let entity = UnifiedEntity::table_row(
3344                            EntityId::new(id),
3345                            "rows",
3346                            id,
3347                            vec![Value::text(payload)],
3348                        );
3349                        StoreWalAction::upsert_entity("rows", &entity, None, STORE_VERSION_V11)
3350                    })
3351                    .collect::<Vec<_>>();
3352                coordinator.append_actions(&actions).unwrap();
3353            }
3354        }
3355        let status = std::process::Command::new(std::env::current_exe().unwrap())
3356            .args(["--exact", "storage::unified::store::commit::tests::paged_wal_replay_survives_immediate_process_exit", "--nocapture"])
3357            .env(CHILD_PATH, &path).status().unwrap();
3358        assert!(status.success(), "replay process failed");
3359        let reopened = UnifiedStore::open_with_config(&path, config())
3360            .expect("cold reopen after replay crash");
3361        assert_eq!(reopened.get_collection("rows").unwrap().count(), 400);
3362        for id in 1..=400 {
3363            let entity = reopened
3364                .get("rows", EntityId::new(id))
3365                .expect("retained entity");
3366            let EntityData::Row(row) = entity.data else {
3367                panic!("expected row")
3368            };
3369            assert_eq!(row.columns, vec![Value::text(replay_crash_payload(id, 2))]);
3370        }
3371        drop(reopened);
3372        let stable_bytes = std::fs::metadata(&path).unwrap().len();
3373        let repeated = UnifiedStore::open_with_config(&path, config()).unwrap();
3374        assert_eq!(repeated.get_collection("rows").unwrap().count(), 400);
3375        assert_eq!(
3376            std::fs::metadata(&path).unwrap().len(),
3377            stable_bytes,
3378            "idempotent replay must not allocate another tree generation"
3379        );
3380    }
3381
3382    #[test]
3383    fn checkpoint_reset_waits_for_an_enqueued_commit_acknowledgement() {
3384        let _env = env_lock().lock().unwrap_or_else(|p| p.into_inner());
3385        let path = temp_wal("checkpoint-ack");
3386        let coordinator = Arc::new(
3387            StoreCommitCoordinator::open(
3388                path.clone(),
3389                DurabilityMode::WalDurableGrouped,
3390                GroupCommitOptions::default(),
3391            )
3392            .unwrap(),
3393        );
3394        // Pause the writer after enqueue, before it can register/wait for its
3395        // acknowledgement. The flusher also needs this state lock to drain.
3396        let state = coordinator.state.0.lock();
3397        let (written_tx, written_rx) = std::sync::mpsc::channel();
3398        let writer_coordinator = Arc::clone(&coordinator);
3399        let writer = std::thread::spawn(move || {
3400            let result = writer_coordinator.append_actions(&[StoreWalAction::CreateCollection {
3401                name: "acknowledged".into(),
3402            }]);
3403            written_tx.send(result).unwrap();
3404        });
3405        let deadline = Instant::now() + Duration::from_secs(2);
3406        while !coordinator.queue.has_pending() {
3407            assert!(Instant::now() < deadline, "writer never enqueued");
3408            std::thread::yield_now();
3409        }
3410        let (started_tx, started_rx) = std::sync::mpsc::channel();
3411        let (reset_tx, reset_rx) = std::sync::mpsc::channel();
3412        let reset_coordinator = Arc::clone(&coordinator);
3413        let reset = std::thread::spawn(move || {
3414            started_tx.send(()).unwrap();
3415            reset_tx.send(reset_coordinator.truncate()).unwrap();
3416        });
3417        started_rx.recv_timeout(Duration::from_secs(2)).unwrap();
3418        let deadline = Instant::now() + Duration::from_millis(100);
3419        while coordinator.queue.has_pending() && Instant::now() < deadline {
3420            std::thread::sleep(Duration::from_millis(1));
3421        }
3422        let discarded_before_ack = !coordinator.queue.has_pending();
3423        drop(state);
3424        let acknowledged = written_rx.recv_timeout(Duration::from_secs(2));
3425        if acknowledged.is_err() {
3426            // Release an old implementation's stranded waiter before failing
3427            // the assertion, so the regression itself cannot leak a worker.
3428            let mut state = coordinator.state.0.lock();
3429            state.shutdown = true;
3430            state.last_error = Some("checkpoint acknowledgement regression cleanup".into());
3431            coordinator.state.1.notify_all();
3432        }
3433        writer.join().unwrap();
3434        reset_rx
3435            .recv_timeout(Duration::from_secs(2))
3436            .unwrap()
3437            .unwrap();
3438        reset.join().unwrap();
3439        assert!(
3440            !discarded_before_ack,
3441            "checkpoint discarded an unacknowledged WAL record"
3442        );
3443        assert!(
3444            acknowledged.unwrap().is_ok(),
3445            "durable writer did not finish"
3446        );
3447        drop(coordinator);
3448        let _ = std::fs::remove_file(path);
3449    }
3450
3451    /// Concurrent autocommits MUST coalesce into far fewer fsyncs
3452    /// than the number of callers when the adaptive group-commit
3453    /// window is active. Without the 200µs floor, every caller
3454    /// would race the drain loop and pay an independent fsync.
3455    ///
3456    /// The test fires 32 concurrent `append_actions` calls and asserts
3457    /// the coordinator issued strictly fewer fsyncs than callers.
3458    /// (We don't pin to "one fsync" because timing on loaded CI hosts
3459    /// can split the burst across a couple of drain cycles — the win
3460    /// is the ratio, not the absolute count.)
3461    #[test]
3462    fn group_commit_coalesces_concurrent_autocommits() {
3463        let _env = env_lock().lock().unwrap_or_else(|p| p.into_inner());
3464        // Make sure no stale env var skews this run — the test
3465        // exercises the default 200µs floor.
3466        std::env::remove_var("REDDB_GROUP_COMMIT_WINDOW_US");
3467
3468        let path = temp_wal("coalesce");
3469        let coord = Arc::new(
3470            StoreCommitCoordinator::open(
3471                path.clone(),
3472                DurabilityMode::WalDurableGrouped,
3473                GroupCommitOptions::default(),
3474            )
3475            .expect("open commit coordinator"),
3476        );
3477
3478        const WRITERS: usize = 32;
3479        let barrier = Arc::new(Barrier::new(WRITERS));
3480        let mut handles = Vec::with_capacity(WRITERS);
3481        for tx in 0..WRITERS {
3482            let coord_c = Arc::clone(&coord);
3483            let barrier_c = Arc::clone(&barrier);
3484            handles.push(std::thread::spawn(move || {
3485                // Synchronise the start so every writer races the
3486                // drain loop together — this is the workload shape
3487                // the adaptive window is supposed to coalesce.
3488                barrier_c.wait();
3489                let action = StoreWalAction::CreateCollection {
3490                    name: format!("c{tx}"),
3491                };
3492                coord_c
3493                    .append_actions(std::slice::from_ref(&action))
3494                    .expect("append_actions");
3495            }));
3496        }
3497        for h in handles {
3498            h.join().expect("writer thread");
3499        }
3500
3501        let fsyncs = coord.fsync_count();
3502        assert!(fsyncs > 0, "expected at least one fsync, got {fsyncs}");
3503        assert!(
3504            fsyncs < WRITERS as u64,
3505            "expected fsyncs ({fsyncs}) to be strictly less than \
3506             concurrent writers ({WRITERS}); coalescing failed"
3507        );
3508
3509        drop(coord);
3510        let _ = std::fs::remove_file(&path);
3511    }
3512
3513    /// Sanity check: with the env override forcing a zero window,
3514    /// fsync coalescing degrades — callers race and we approach
3515    /// one fsync per caller. This proves the window is the actual
3516    /// knob doing the work above (i.e. the test isn't passing for
3517    /// some unrelated reason like buffered-IO coalescing).
3518    #[test]
3519    fn zero_window_disables_coalescing_floor() {
3520        let _env = env_lock().lock().unwrap_or_else(|p| p.into_inner());
3521        std::env::set_var("REDDB_GROUP_COMMIT_WINDOW_US", "0");
3522
3523        let path = temp_wal("zero_window");
3524        let coord = Arc::new(
3525            StoreCommitCoordinator::open(
3526                path.clone(),
3527                DurabilityMode::WalDurableGrouped,
3528                GroupCommitOptions::default(),
3529            )
3530            .expect("open commit coordinator"),
3531        );
3532
3533        const WRITERS: usize = 8;
3534        let barrier = Arc::new(Barrier::new(WRITERS));
3535        let mut handles = Vec::with_capacity(WRITERS);
3536        for tx in 0..WRITERS {
3537            let coord_c = Arc::clone(&coord);
3538            let barrier_c = Arc::clone(&barrier);
3539            handles.push(std::thread::spawn(move || {
3540                barrier_c.wait();
3541                let action = StoreWalAction::CreateCollection {
3542                    name: format!("z{tx}"),
3543                };
3544                coord_c
3545                    .append_actions(std::slice::from_ref(&action))
3546                    .expect("append_actions");
3547            }));
3548        }
3549        for h in handles {
3550            h.join().expect("writer thread");
3551        }
3552
3553        // With zero window, fsync count is bounded above by WRITERS
3554        // (every caller might trigger its own drain) and below by 1
3555        // (the queue may still naturally batch under contention).
3556        // The point of the assertion is to confirm the env override
3557        // is wired through — the open() above used the env knob.
3558        let fsyncs = coord.fsync_count();
3559        assert!(fsyncs >= 1, "expected at least one fsync, got {fsyncs}");
3560
3561        std::env::remove_var("REDDB_GROUP_COMMIT_WINDOW_US");
3562        drop(coord);
3563        let _ = std::fs::remove_file(&path);
3564    }
3565
3566    /// `resolve_window` precedence: env > config.window_ms > default.
3567    #[test]
3568    fn resolve_window_precedence() {
3569        let _env = env_lock().lock().unwrap_or_else(|p| p.into_inner());
3570        // Default: window_ms=0 → adaptive 200µs floor.
3571        std::env::remove_var("REDDB_GROUP_COMMIT_WINDOW_US");
3572        let cfg = GroupCommitOptions::default();
3573        assert_eq!(
3574            StoreCommitCoordinator::resolve_window(&cfg),
3575            Duration::from_micros(DEFAULT_ADAPTIVE_WINDOW_US)
3576        );
3577
3578        // Explicit ms config wins over the default floor.
3579        let cfg_ms = GroupCommitOptions {
3580            window_ms: 5,
3581            ..GroupCommitOptions::default()
3582        };
3583        assert_eq!(
3584            StoreCommitCoordinator::resolve_window(&cfg_ms),
3585            Duration::from_millis(5)
3586        );
3587
3588        // Env override wins over both.
3589        std::env::set_var("REDDB_GROUP_COMMIT_WINDOW_US", "750");
3590        assert_eq!(
3591            StoreCommitCoordinator::resolve_window(&cfg),
3592            Duration::from_micros(750)
3593        );
3594        assert_eq!(
3595            StoreCommitCoordinator::resolve_window(&cfg_ms),
3596            Duration::from_micros(750)
3597        );
3598
3599        // Env=0 explicitly disables the floor.
3600        std::env::set_var("REDDB_GROUP_COMMIT_WINDOW_US", "0");
3601        assert_eq!(
3602            StoreCommitCoordinator::resolve_window(&cfg),
3603            Duration::from_micros(0)
3604        );
3605
3606        std::env::remove_var("REDDB_GROUP_COMMIT_WINDOW_US");
3607    }
3608}