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