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