Skip to main content

reddb_server/storage/unified/store/
commit.rs

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