Skip to main content

reddb_server/storage/unified/store/
commit.rs

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