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