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 std::cell::RefCell;
5use std::io;
6use std::path::{Path, PathBuf};
7use std::sync::atomic::{AtomicU64, Ordering};
8use std::sync::{Arc, Condvar, Mutex};
9
10/// Adaptive group-commit window applied when the configured
11/// `GroupCommitOptions::window_ms` is 0 (the historical default).
12///
13/// Sub-millisecond so individual single-writer commits don't see a
14/// visible latency penalty (a 200 µs floor sits below typical NVMe
15/// fsync latency of 50–150 µs by ~one fsync), while still giving a
16/// pipelined writer a chance to drop a second statement into the
17/// same drain cycle. A lone synchronous `insert_one` still pays one
18/// fsync per row in the worst case, but two back-to-back inserts on
19/// the same connection now coalesce into one drain. See P1 in
20/// `docs/perf/insert_sequential-2026-05-05.md`.
21///
22/// Override via `REDDB_GROUP_COMMIT_WINDOW_US` (microseconds). Set
23/// to `0` to disable the floor entirely (legacy behaviour). Set
24/// `REDDB_GROUP_COMMIT_WINDOW_MS` to any non-zero value to bypass
25/// this floor — explicit ms config wins.
26const DEFAULT_ADAPTIVE_WINDOW_US: u64 = 200;
27
28// Extra disk reservation for normal embedded overflow checkpoints, not a
29// per-write buffer allocation. Fresh files stay at 64 KiB. A single larger
30// indivisible action retains the existing capacity/oversize contract.
31const EMBEDDED_WAL_HEADROOM_LIMIT: u64 = 1024 * 1024;
32
33/// Shorthand — `Arc<parking_lot::Mutex<WalWriter>>` avoids poisoning
34/// (one writer panicking mid-append used to taint every subsequent
35/// lock acquisition) and shaves a few syscalls off the fast path.
36/// The group-commit coordinator + writer threads all acquire this
37/// mutex in the hot insert path, so the unpoison/fast-park win
38/// compounds under 16-way concurrency.
39type WalMutex = parking_lot::Mutex<WalWriter>;
40
41/// Shorthand for the group-commit coordinator's state pair. Same
42/// non-poisoning + lighter-park motivation as `WalMutex`; writer
43/// threads `wait` on this condvar until the coordinator publishes a
44/// new `durable_lsn`, so the park cost shows up on every
45/// WalDurableGrouped transaction.
46type CommitStateMutex = parking_lot::Mutex<CommitState>;
47type CommitStateCondvar = parking_lot::Condvar;
48use std::time::{Duration, Instant};
49
50static NEXT_STORE_TX_ID: AtomicU64 = AtomicU64::new(1);
51
52#[derive(Debug, Clone)]
53pub(crate) enum StoreWalAction {
54    CreateCollection {
55        name: String,
56    },
57    DropCollection {
58        name: String,
59    },
60    UpsertEntityRecord {
61        collection: String,
62        record: Vec<u8>,
63    },
64    DeleteEntityRecord {
65        collection: String,
66        entity_id: u64,
67    },
68    /// Batched upsert — one WAL action carrying N serialized entity
69    /// records for the same collection. Saves the per-row Begin/
70    /// PageWrite/Commit framing overhead on the bulk insert hot path.
71    /// Replay applies every contained record in order.
72    BulkUpsertEntityRecords {
73        collection: String,
74        records: Vec<Vec<u8>>,
75    },
76    /// Atomic full-collection replace — issue #595 slice 9c.
77    /// Replay drops the in-memory collection state and rebuilds it
78    /// from the contained records. Used by REFRESH MATERIALIZED VIEW
79    /// so a concurrent reader sees either the prior contents or the
80    /// new contents, never a partial state, and a crash mid-refresh
81    /// leaves the prior contents intact on recovery (the action is
82    /// only durable once the WAL commit lands).
83    RefreshCollection {
84        collection: String,
85        records: Vec<Vec<u8>>,
86    },
87}
88
89#[derive(Debug, Default)]
90pub(crate) struct DeferredStoreWalActions {
91    pub(super) actions: Vec<StoreWalAction>,
92}
93
94pub(super) struct StoreWalCaptureGuard<'a> {
95    active: bool,
96    session: Option<&'a crate::storage::embedded::EmbeddedCommitSession>,
97}
98
99impl<'a> StoreWalCaptureGuard<'a> {
100    pub(super) fn begin(
101        session: Option<&'a crate::storage::embedded::EmbeddedCommitSession>,
102    ) -> Self {
103        assert!(
104            !deferred_store_wal_capture_active(),
105            "store WAL captures cannot nest"
106        );
107        begin_deferred_store_wal_capture();
108        Self {
109            active: true,
110            session,
111        }
112    }
113
114    pub(super) fn finish(mut self) -> DeferredStoreWalActions {
115        let actions = take_deferred_store_wal_capture();
116        self.active = false;
117        actions
118    }
119}
120
121impl Drop for StoreWalCaptureGuard<'_> {
122    fn drop(&mut self) {
123        if self.active {
124            if let Some(session) = self.session {
125                session.fence_interrupted_write();
126            }
127            let _ = take_deferred_store_wal_capture();
128        }
129    }
130}
131
132impl DeferredStoreWalActions {
133    pub(crate) fn is_empty(&self) -> bool {
134        self.actions.is_empty()
135    }
136
137    pub(crate) fn extend(&mut self, other: Self) {
138        self.actions.extend(other.actions);
139    }
140}
141
142thread_local! {
143    static DEFERRED_STORE_WAL_ACTIONS: RefCell<Option<Vec<StoreWalAction>>> =
144        const { RefCell::new(None) };
145}
146
147fn begin_deferred_store_wal_capture() {
148    DEFERRED_STORE_WAL_ACTIONS.with(|cell| {
149        let mut guard = cell.borrow_mut();
150        debug_assert!(guard.is_none());
151        *guard = Some(Vec::new());
152    });
153}
154
155fn capture_deferred_store_wal_actions(actions: Vec<StoreWalAction>) -> bool {
156    DEFERRED_STORE_WAL_ACTIONS.with(|cell| {
157        let mut guard = cell.borrow_mut();
158        if let Some(pending) = guard.as_mut() {
159            pending.extend(actions);
160            true
161        } else {
162            false
163        }
164    })
165}
166
167fn deferred_store_wal_capture_active() -> bool {
168    DEFERRED_STORE_WAL_ACTIONS.with(|cell| cell.borrow().is_some())
169}
170
171fn take_deferred_store_wal_capture() -> DeferredStoreWalActions {
172    DEFERRED_STORE_WAL_ACTIONS.with(|cell| DeferredStoreWalActions {
173        actions: cell.borrow_mut().take().unwrap_or_default(),
174    })
175}
176
177impl StoreWalAction {
178    pub(crate) fn upsert_entity(
179        collection: &str,
180        entity: &UnifiedEntity,
181        metadata: Option<&Metadata>,
182        format_version: u32,
183    ) -> Self {
184        Self::UpsertEntityRecord {
185            collection: collection.to_string(),
186            record: UnifiedStore::serialize_entity_record(entity, metadata, format_version),
187        }
188    }
189
190    fn encode(&self) -> Vec<u8> {
191        reddb_file::encode_store_wal_action_frame(&self.to_frame())
192            .expect("encode store wal action frame")
193    }
194
195    fn to_frame(&self) -> reddb_file::StoreWalActionFrame {
196        match self {
197            Self::CreateCollection { name } => {
198                reddb_file::StoreWalActionFrame::CreateCollection { name: name.clone() }
199            }
200            Self::DropCollection { name } => {
201                reddb_file::StoreWalActionFrame::DropCollection { name: name.clone() }
202            }
203            Self::UpsertEntityRecord { collection, record } => {
204                reddb_file::StoreWalActionFrame::UpsertEntityRecord {
205                    collection: collection.clone(),
206                    record: record.clone(),
207                }
208            }
209            Self::DeleteEntityRecord {
210                collection,
211                entity_id,
212            } => reddb_file::StoreWalActionFrame::DeleteEntityRecord {
213                collection: collection.clone(),
214                entity_id: *entity_id,
215            },
216            Self::BulkUpsertEntityRecords {
217                collection,
218                records,
219            } => reddb_file::StoreWalActionFrame::BulkUpsertEntityRecords {
220                collection: collection.clone(),
221                records: records.clone(),
222            },
223            Self::RefreshCollection {
224                collection,
225                records,
226            } => reddb_file::StoreWalActionFrame::RefreshCollection {
227                collection: collection.clone(),
228                records: records.clone(),
229            },
230        }
231    }
232
233    fn decode(bytes: &[u8]) -> io::Result<Self> {
234        match reddb_file::decode_store_wal_action_frame(bytes)? {
235            reddb_file::StoreWalActionFrame::CreateCollection { name } => {
236                Ok(Self::CreateCollection { name })
237            }
238            reddb_file::StoreWalActionFrame::DropCollection { name } => {
239                Ok(Self::DropCollection { name })
240            }
241            reddb_file::StoreWalActionFrame::UpsertEntityRecord { collection, record } => {
242                Ok(Self::UpsertEntityRecord { collection, record })
243            }
244            reddb_file::StoreWalActionFrame::DeleteEntityRecord {
245                collection,
246                entity_id,
247            } => Ok(Self::DeleteEntityRecord {
248                collection,
249                entity_id,
250            }),
251            reddb_file::StoreWalActionFrame::BulkUpsertEntityRecords {
252                collection,
253                records,
254            } => Ok(Self::BulkUpsertEntityRecords {
255                collection,
256                records,
257            }),
258            reddb_file::StoreWalActionFrame::RefreshCollection {
259                collection,
260                records,
261            } => Ok(Self::RefreshCollection {
262                collection,
263                records,
264            }),
265        }
266    }
267}
268
269#[derive(Debug)]
270struct CommitState {
271    durable_lsn: u64,
272    pending_target_lsn: u64,
273    pending_statements: usize,
274    pending_wal_bytes: u64,
275    first_pending_at: Option<Instant>,
276    shutdown: bool,
277    last_error: Option<String>,
278}
279
280impl CommitState {
281    fn new(initial_durable_lsn: u64) -> Self {
282        Self {
283            durable_lsn: initial_durable_lsn,
284            pending_target_lsn: initial_durable_lsn,
285            pending_statements: 0,
286            pending_wal_bytes: 0,
287            first_pending_at: None,
288            shutdown: false,
289            last_error: None,
290        }
291    }
292}
293
294/// Lock-free append queue sitting in front of `WalWriter`.
295///
296/// Writers atomically reserve a byte range via `next_lsn.fetch_add`
297/// and push their encoded bytes into a parking_lot-guarded vector.
298/// The group-commit coordinator is the sole drainer: it sorts the
299/// pending entries by LSN (so the file bytes land at the offsets
300/// each writer reserved) and hands them to `WalWriter::append_bytes`.
301///
302/// This replaces the old `wal.lock() ... append ... append ... drop`
303/// hot path where 16 concurrent writers serialised on the WAL
304/// mutex for ~13µs each. Hold time on the queue's parking_lot
305/// mutex is ~200ns (just a Vec::push) — 65× shorter, so the mutex
306/// convoy on concurrent inserts disappears.
307pub(crate) struct WalAppendQueue {
308    /// Keep reserved offsets valid until their callers observe durability.
309    /// Reset also excludes an old flusher publishing state into the new WAL.
310    epoch: parking_lot::RwLock<()>,
311    /// Tuple of (monotonically-increasing LSN, pending-bytes vec)
312    /// protected by one mutex. Keeping the LSN reservation AND the
313    /// push under the same lock guarantees that every queue entry
314    /// is visible the moment its LSN is assigned — no gap between
315    /// `fetch_add` and `push` for the leader to spin on.
316    ///
317    /// Earlier versions reserved LSN with an `AtomicU64::fetch_add`
318    /// outside the lock; the leader drain observed reordered
319    /// `(lsn, bytes)` tuples whose pushes happened in a different
320    /// order than their fetch_add, creating "holes" in the LSN
321    /// sequence that the drain loop interpreted as "wait for the
322    /// missing enqueuer" — under tokio scheduling pressure the
323    /// missing enqueuer was preempted indefinitely and the
324    /// drain loop busy-waited forever (WAL stayed at 8 bytes).
325    pending: parking_lot::Mutex<WalQueueState>,
326    /// Bytes of encoded records sitting in `pending.entries` (ADR 0073 §2,
327    /// the `wal_buffers` pool). A relaxed atomic rather than a field inside
328    /// `WalQueueState` so a reader sampling the accounting never contends
329    /// with an enqueuer for the queue mutex.
330    queued_bytes: AtomicU64,
331}
332
333struct WalQueueState {
334    next_lsn: u64,
335    entries: Vec<(u64, Vec<u8>)>,
336}
337
338impl WalAppendQueue {
339    fn new(initial_lsn: u64) -> Self {
340        Self {
341            epoch: parking_lot::RwLock::new(()),
342            pending: parking_lot::Mutex::new(WalQueueState {
343                next_lsn: initial_lsn,
344                entries: Vec::with_capacity(64),
345            }),
346            queued_bytes: AtomicU64::new(0),
347        }
348    }
349
350    /// Reserve an LSN range of `bytes.len()` bytes and push onto the
351    /// queue. Returns the commit LSN (end of reserved range), which
352    /// the caller passes to `wait_until_durable`. LSN assignment and
353    /// push happen under the same mutex — no gap for the drain loop
354    /// to busy-spin on.
355    fn enqueue(&self, bytes: Vec<u8>) -> u64 {
356        let len = bytes.len() as u64;
357        let mut state = self.pending.lock();
358        let start_lsn = state.next_lsn;
359        state.next_lsn = start_lsn + len;
360        state.entries.push((start_lsn, bytes));
361        self.queued_bytes.fetch_add(len, Ordering::Relaxed);
362        start_lsn + len
363    }
364
365    /// Drain all queued entries in LSN order. Caller holds the WAL
366    /// file mutex while writing the drained bytes so the on-disk
367    /// layout matches the reserved LSN offsets.
368    fn drain_sorted(&self) -> Vec<(u64, Vec<u8>)> {
369        let mut state = self.pending.lock();
370        let mut v = std::mem::take(&mut state.entries);
371        let drained: u64 = v.iter().map(|(_, bytes)| bytes.len() as u64).sum();
372        drop(state);
373        // Saturating: the drain is the only consumer, so `queued_bytes` can
374        // never sit below what this call took, but an accounting counter that
375        // wraps would poison `red.stats` far past the bug that caused it.
376        let _ = self
377            .queued_bytes
378            .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |queued| {
379                Some(queued.saturating_sub(drained))
380            });
381        v.sort_by_key(|(lsn, _)| *lsn);
382        v
383    }
384
385    /// Whether any entry is queued. Leader uses this to decide
386    /// whether to spin once more or go back to the condvar.
387    fn has_pending(&self) -> bool {
388        !self.pending.lock().entries.is_empty()
389    }
390
391    /// Encoded record bytes currently buffered in memory.
392    fn queued_bytes(&self) -> u64 {
393        self.queued_bytes.load(Ordering::Relaxed)
394    }
395
396    /// Reset the LSN cursor and discard any queued entries. Used
397    /// after `wal.truncate()` — the wal-side byte counter goes back
398    /// to the header size, so the queue (which tracks LSNs in the
399    /// same byte space) must follow or every subsequent enqueue
400    /// returns a target the drain loop can never reach.
401    fn reset(&self, next_lsn: u64) {
402        let mut state = self.pending.lock();
403        state.next_lsn = next_lsn;
404        state.entries.clear();
405        drop(state);
406        self.queued_bytes.store(0, Ordering::Relaxed);
407    }
408}
409
410pub(crate) struct StoreCommitCoordinator {
411    mode: DurabilityMode,
412    config: crate::api::GroupCommitOptions,
413    wal_path: PathBuf,
414    wal: Arc<WalMutex>,
415    /// Lock-free front door for writers. Populated alongside
416    /// `WalDurableGrouped` / `Async` modes so concurrent inserts
417    /// never contend on `wal` for the append step. Strict mode
418    /// bypasses the queue and calls `WalWriter::append` directly
419    /// to preserve the one-fsync-per-commit semantic.
420    queue: Arc<WalAppendQueue>,
421    state: Arc<(CommitStateMutex, CommitStateCondvar)>,
422    /// Number of `wal.sync()` calls issued by the group-commit
423    /// drain loop. Used by tests to observe coalescing — a burst
424    /// of N concurrent commits should bump this by far less than N
425    /// when the adaptive window is doing its job. Strict-mode
426    /// commits go through `force_sync` which also bumps this.
427    fsync_count: Arc<AtomicU64>,
428    wal_generation: AtomicU64,
429}
430
431impl StoreCommitCoordinator {
432    pub(crate) fn open(
433        wal_path: impl Into<PathBuf>,
434        mode: DurabilityMode,
435        config: crate::api::GroupCommitOptions,
436    ) -> io::Result<Self> {
437        let wal_path = wal_path.into();
438        let wal = WalWriter::open(&wal_path)?;
439        let initial_durable_lsn = wal.durable_lsn();
440        let initial_current_lsn = wal.current_lsn();
441        let wal = Arc::new(WalMutex::new(wal));
442        let queue = Arc::new(WalAppendQueue::new(initial_current_lsn));
443        let state = Arc::new((
444            CommitStateMutex::new(CommitState::new(initial_durable_lsn)),
445            CommitStateCondvar::new(),
446        ));
447        let fsync_count = Arc::new(AtomicU64::new(0));
448
449        if matches!(
450            mode,
451            DurabilityMode::WalDurableGrouped | DurabilityMode::Async
452        ) {
453            let wal_bg = Arc::clone(&wal);
454            let queue_bg = Arc::clone(&queue);
455            let state_bg = Arc::clone(&state);
456            let fsync_bg = Arc::clone(&fsync_count);
457            // P1: adaptive group-commit window. Historical default is
458            // `window_ms = 0` ("no wait"), which under single-writer
459            // OLTP means one fsync per autocommit row — the throughput
460            // floor. When window_ms is 0 we fall back to a small
461            // microsecond floor (`DEFAULT_ADAPTIVE_WINDOW_US`, override
462            // via `REDDB_GROUP_COMMIT_WINDOW_US`) so a pipelined writer
463            // can drop a second statement into the same drain cycle.
464            // Explicit non-zero `window_ms` config takes precedence.
465            //
466            // The loop already short-circuits on
467            // `pending_statements >= max_statements` /
468            // `pending_wal_bytes >= max_wal_bytes`, so the window only
469            // delays the leader when the queue is otherwise empty —
470            // exactly the case we want to coalesce.
471            let window = Self::resolve_window(&config);
472            let max_statements = config.max_statements.max(1);
473            let max_wal_bytes = config.max_wal_bytes.max(1);
474            std::thread::spawn(move || {
475                Self::run_group_commit_loop(
476                    wal_bg,
477                    queue_bg,
478                    state_bg,
479                    fsync_bg,
480                    window,
481                    max_statements,
482                    max_wal_bytes,
483                );
484            });
485        }
486
487        Ok(Self {
488            mode,
489            config,
490            wal_path,
491            wal,
492            queue,
493            state,
494            fsync_count,
495            wal_generation: AtomicU64::new(0),
496        })
497    }
498
499    /// Resolve the effective group-commit window from the configured
500    /// options + the `REDDB_GROUP_COMMIT_WINDOW_US` env override.
501    ///
502    /// Precedence (highest first):
503    ///   1. `REDDB_GROUP_COMMIT_WINDOW_US=N` — N µs (0 disables).
504    ///   2. `config.window_ms != 0` — explicit ms config wins.
505    ///   3. `DEFAULT_ADAPTIVE_WINDOW_US` — adaptive floor for the
506    ///      historical zero-default. Set the env var to 0 to opt out.
507    fn resolve_window(config: &crate::api::GroupCommitOptions) -> Duration {
508        if let Ok(raw) = std::env::var("REDDB_GROUP_COMMIT_WINDOW_US") {
509            if let Ok(parsed) = raw.parse::<u64>() {
510                return Duration::from_micros(parsed);
511            }
512        }
513        if config.window_ms != 0 {
514            return Duration::from_millis(config.window_ms);
515        }
516        Duration::from_micros(DEFAULT_ADAPTIVE_WINDOW_US)
517    }
518
519    /// Total `wal.sync()` calls issued since this coordinator opened.
520    /// Public for tests that want to observe fsync coalescing under
521    /// concurrent autocommits.
522    #[cfg(test)]
523    pub(crate) fn fsync_count(&self) -> u64 {
524        self.fsync_count.load(Ordering::Relaxed)
525    }
526
527    /// WAL buffer memory held right now: encoded records waiting in the
528    /// group-commit queue plus the writer's fixed append buffer, which exists
529    /// for the coordinator's whole life (ADR 0073 §2, the `wal_buffers` pool).
530    pub(crate) fn buffered_bytes(&self) -> u64 {
531        self.queue.queued_bytes() + crate::storage::wal::WAL_BUFFER_BYTES as u64
532    }
533
534    pub(crate) fn append_actions(&self, actions: &[StoreWalAction]) -> io::Result<()> {
535        self.append_actions_impl(actions, false).map(|_| ())
536    }
537
538    pub(super) fn append_checkpoint_actions(
539        &self,
540        actions: &[StoreWalAction],
541    ) -> io::Result<super::checkpoint_wal::CheckpointWalBatch> {
542        self.append_actions_impl(actions, true)
543    }
544
545    fn append_actions_impl(
546        &self,
547        actions: &[StoreWalAction],
548        retain: bool,
549    ) -> io::Result<super::checkpoint_wal::CheckpointWalBatch> {
550        use super::checkpoint_wal::CheckpointWalBatch;
551        if actions.is_empty() {
552            return Ok(CheckpointWalBatch::default());
553        }
554        let _epoch = self.queue.epoch.read();
555        let tx_id = NEXT_STORE_TX_ID.fetch_add(1, Ordering::SeqCst);
556        let encoded: Vec<Vec<u8>> = actions.iter().map(StoreWalAction::encode).collect();
557        let wal_bytes = encoded.iter().fold(0u64, |total, action| {
558            total.saturating_add(action.len() as u64)
559        });
560        let (blob, mut references) = if retain {
561            CheckpointWalBatch::encode(
562                tx_id,
563                &encoded,
564                self.wal_generation.load(Ordering::Acquire),
565            )?
566        } else {
567            (
568                WalRecord::TxCommitBatch {
569                    tx_id,
570                    actions: encoded,
571                }
572                .encode(),
573                CheckpointWalBatch::default(),
574            )
575        };
576        let start_lsn = if matches!(self.mode, DurabilityMode::Strict) {
577            let start = self.wal.lock().append_bytes(&blob)?;
578            self.force_sync()?;
579            start
580        } else {
581            let length = blob.len() as u64;
582            let end = self.queue.enqueue(blob);
583            self.wait_until_durable(end, wal_bytes)?;
584            end.checked_sub(length).ok_or_else(|| {
585                io::Error::new(io::ErrorKind::InvalidData, "invalid WAL batch offset")
586            })?
587        };
588        references.relocate(start_lsn)?;
589        Ok(references)
590    }
591
592    /// Compare exact durable bytes in bounded chunks. A proof from an older
593    /// truncated WAL generation cannot prove a row in the current file.
594    pub(super) fn checkpoint_row_matches(
595        &self,
596        reference: super::checkpoint_wal::WalRowReference,
597        expected: &[u8],
598    ) -> io::Result<bool> {
599        if expected.len() != reference.length as usize {
600            return Ok(false);
601        }
602        let mut position = 0;
603        let mut matches = true;
604        let present = self.visit_checkpoint_row(reference, |chunk| {
605            matches &= chunk == &expected[position..position + chunk.len()];
606            position += chunk.len();
607        })?;
608        Ok(present && matches)
609    }
610
611    /// Materialize only one already-validated durable row for a projection batch.
612    pub(super) fn checkpoint_row_bytes(
613        &self,
614        reference: super::checkpoint_wal::WalRowReference,
615    ) -> io::Result<Vec<u8>> {
616        let mut bytes = Vec::new();
617        let present = self.visit_checkpoint_row(reference, |chunk| {
618            if bytes.is_empty() {
619                bytes.reserve_exact(reference.length as usize);
620            }
621            bytes.extend_from_slice(chunk);
622        })?;
623        if !present {
624            return Err(io::Error::new(
625                io::ErrorKind::InvalidData,
626                "checkpoint WAL reference changed during projection",
627            ));
628        }
629        Ok(bytes)
630    }
631
632    fn visit_checkpoint_row(
633        &self,
634        reference: super::checkpoint_wal::WalRowReference,
635        mut visit: impl FnMut(&[u8]),
636    ) -> io::Result<bool> {
637        use std::io::{Read, Seek, SeekFrom};
638        let _epoch = self.queue.epoch.read_recursive();
639        if reference.generation != self.wal_generation.load(Ordering::Acquire) {
640            return Ok(false);
641        }
642        let end = reference
643            .offset
644            .checked_add(u64::from(reference.length))
645            .ok_or_else(|| {
646                io::Error::new(io::ErrorKind::InvalidData, "WAL proof offset overflow")
647            })?;
648        let (state_lock, _) = &*self.state;
649        if end > state_lock.lock().durable_lsn {
650            return Ok(false);
651        }
652        let mut file = std::fs::File::open(&self.wal_path)?;
653        file.seek(SeekFrom::Start(reference.offset))?;
654        let mut buffer = [0u8; 16 * 1024];
655        let mut checksum = crc32fast::Hasher::new();
656        let mut remaining = reference.length as usize;
657        while remaining != 0 {
658            let length = remaining.min(buffer.len());
659            let chunk = &mut buffer[..length];
660            file.read_exact(chunk)?;
661            checksum.update(chunk);
662            visit(chunk);
663            remaining -= length;
664        }
665        if checksum.finalize() != reference.checksum {
666            return Err(io::Error::new(
667                io::ErrorKind::InvalidData,
668                "checkpoint WAL proof checksum mismatch",
669            ));
670        }
671        Ok(true)
672    }
673
674    /// Encode + enqueue a single, non-batched [`WalRecord`] (issue
675    /// #693). Honours the same Strict / Grouped / Async branching as
676    /// [`Self::append_actions`] so durability semantics are uniform
677    /// across record kinds.
678    pub(crate) fn append_single_record(&self, record: WalRecord) -> io::Result<()> {
679        let _epoch = self.queue.epoch.read();
680        let blob = record.encode();
681        let wal_bytes = blob.len() as u64;
682        if matches!(self.mode, DurabilityMode::Strict) {
683            {
684                let mut wal = self.wal.lock();
685                wal.append(&record)?;
686            }
687            self.force_sync()?;
688            return Ok(());
689        }
690        let commit_lsn = self.queue.enqueue(blob);
691        self.wait_until_durable(commit_lsn, wal_bytes)?;
692        Ok(())
693    }
694
695    pub(crate) fn force_sync(&self) -> io::Result<()> {
696        // Strict append already owns a read lease. Do not wait behind a
697        // checkpoint that needs that append to finish before resetting.
698        let _epoch = self.queue.epoch.read_recursive();
699        {
700            let mut wal = self.wal.lock();
701            wal.sync()?;
702            self.fsync_count.fetch_add(1, Ordering::Relaxed);
703            let durable = wal.durable_lsn();
704            drop(wal);
705            let (state_lock, cond) = &*self.state;
706            let mut state = state_lock.lock();
707            state.durable_lsn = durable;
708            state.pending_target_lsn = durable.max(state.pending_target_lsn);
709            state.pending_statements = 0;
710            state.pending_wal_bytes = 0;
711            state.first_pending_at = None;
712            state.last_error = None;
713            cond.notify_all();
714        }
715        Ok(())
716    }
717
718    pub(crate) fn truncate(&self) -> io::Result<()> {
719        let _epoch = self.queue.epoch.write();
720        self.wal_generation.fetch_add(1, Ordering::AcqRel);
721        let mut wal = self.wal.lock();
722        wal.truncate()?;
723        let durable = wal.durable_lsn();
724        let current = wal.current_lsn();
725        drop(wal);
726
727        // Queue's next_lsn tracks byte offsets in the same space as
728        // wal.current_lsn. After truncate both must be reset together
729        // — otherwise enqueue returns a target_lsn in the old range
730        // that drain can never reach, and wait_until_durable hangs.
731        self.queue.reset(current);
732
733        let (state_lock, cond) = &*self.state;
734        let mut state = state_lock.lock();
735        state.durable_lsn = durable;
736        state.pending_target_lsn = durable;
737        state.pending_statements = 0;
738        state.pending_wal_bytes = 0;
739        state.first_pending_at = None;
740        state.last_error = None;
741        cond.notify_all();
742        Ok(())
743    }
744
745    pub(crate) fn replay_into(&self, store: &UnifiedStore) -> io::Result<()> {
746        if !self.wal_path.exists() {
747            return Ok(());
748        }
749
750        let reader = match WalReader::open(&self.wal_path) {
751            Ok(reader) => reader,
752            Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(()),
753            Err(err) => return Err(err),
754        };
755
756        let mut tx_states = std::collections::HashMap::<u64, bool>::new();
757        let mut pending = Vec::<(u64, Vec<u8>)>::new();
758        let mut applied = false;
759
760        for record in reader.iter() {
761            let (_lsn, record) = match record {
762                Ok(record) => record,
763                Err(err) if err.kind() == io::ErrorKind::UnexpectedEof => break,
764                Err(err) => return Err(err),
765            };
766            match record {
767                WalRecord::TxCommitBatch { actions, .. } => {
768                    for payload in actions {
769                        let action = StoreWalAction::decode(&payload)?;
770                        applied = true;
771                        store.apply_replayed_action(&action, false).map_err(|err| {
772                            io::Error::other(format!("failed to replay store wal action: {err}"))
773                        })?;
774                    }
775                }
776                WalRecord::Begin { tx_id } => {
777                    tx_states.insert(tx_id, false);
778                }
779                WalRecord::Commit { tx_id } => {
780                    tx_states.insert(tx_id, true);
781                }
782                WalRecord::Rollback { tx_id } => {
783                    tx_states.remove(&tx_id);
784                }
785                WalRecord::PageWrite {
786                    tx_id,
787                    page_id: _,
788                    data,
789                } => pending.push((tx_id, data)),
790                WalRecord::Checkpoint { .. } => {}
791                WalRecord::VectorInsert {
792                    collection,
793                    entity_id,
794                    vector,
795                } => {
796                    // Issue #694 — capture `vector.turbo` WAL inserts so the
797                    // boot-time TurboQuant index rebuild can drain them in
798                    // WAL order (deterministic against the pre-restart
799                    // state under a fixed codec seed). The legacy `vector`
800                    // path doesn't read this map.
801                    let mut map = store.replayed_turbo_inserts.lock();
802                    map.entry(collection).or_default().push((entity_id, vector));
803                }
804                WalRecord::ProbabilisticDelta {
805                    kind,
806                    operation,
807                    name,
808                    operands,
809                } => {
810                    let mut deltas = store.replayed_probabilistic_deltas.lock();
811                    deltas.push((kind, operation, name, operands));
812                }
813                WalRecord::FullPageImage { .. } => {
814                    // Pager-level FPI (gh-478); store replay ignores.
815                }
816            }
817        }
818
819        for (tx_id, payload) in pending {
820            if !tx_states.get(&tx_id).copied().unwrap_or(false) {
821                continue;
822            }
823            let action = StoreWalAction::decode(&payload)?;
824            applied = true;
825            store.apply_replayed_action(&action, false).map_err(|err| {
826                io::Error::other(format!("failed to replay store wal action: {err}"))
827            })?;
828        }
829
830        if applied {
831            store
832                .reconcile_replayed_btrees()
833                .map_err(|err| io::Error::other(err.to_string()))?;
834        }
835        Ok(())
836    }
837
838    fn wait_until_durable(&self, target_lsn: u64, wal_bytes: u64) -> io::Result<()> {
839        match self.mode {
840            DurabilityMode::Strict => self.force_sync(),
841            // Async: record the pending target so the background
842            // flusher eventually covers it, but don't block the
843            // caller. Matches PG `synchronous_commit=off` semantics —
844            // crash inside the flush window loses unflushed commits.
845            DurabilityMode::Async => {
846                let (state_lock, cond) = &*self.state;
847                let mut state = state_lock.lock();
848                state.pending_target_lsn = state.pending_target_lsn.max(target_lsn);
849                state.pending_statements = state.pending_statements.saturating_add(1);
850                state.pending_wal_bytes = state.pending_wal_bytes.saturating_add(wal_bytes);
851                state.first_pending_at.get_or_insert_with(Instant::now);
852                cond.notify_all();
853                Ok(())
854            }
855            DurabilityMode::WalDurableGrouped => {
856                let (state_lock, cond) = &*self.state;
857                let mut state = state_lock.lock();
858                state.pending_target_lsn = state.pending_target_lsn.max(target_lsn);
859                state.pending_statements = state.pending_statements.saturating_add(1);
860                state.pending_wal_bytes = state.pending_wal_bytes.saturating_add(wal_bytes);
861                state.first_pending_at.get_or_insert_with(Instant::now);
862                cond.notify_all();
863
864                loop {
865                    if let Some(err) = state.last_error.clone() {
866                        return Err(io::Error::other(err));
867                    }
868                    if state.durable_lsn >= target_lsn {
869                        return Ok(());
870                    }
871                    // parking_lot::Condvar mutates the guard in place —
872                    // no LockResult to unwrap, no poisoning to fold.
873                    cond.wait(&mut state);
874                }
875            }
876        }
877    }
878
879    fn run_group_commit_loop(
880        wal: Arc<WalMutex>,
881        queue: Arc<WalAppendQueue>,
882        state: Arc<(CommitStateMutex, CommitStateCondvar)>,
883        fsync_count: Arc<AtomicU64>,
884        window: Duration,
885        max_statements: usize,
886        max_wal_bytes: u64,
887    ) {
888        let (state_lock, cond) = &*state;
889        loop {
890            let target_lsn = {
891                let mut guard = state_lock.lock();
892
893                while !guard.shutdown && guard.pending_target_lsn <= guard.durable_lsn {
894                    cond.wait(&mut guard);
895                }
896
897                if guard.shutdown {
898                    return;
899                }
900
901                let immediate = window.is_zero()
902                    || guard.pending_statements >= max_statements
903                    || guard.pending_wal_bytes >= max_wal_bytes;
904
905                if !immediate {
906                    let deadline = guard.first_pending_at.unwrap_or_else(Instant::now) + window;
907                    let now = Instant::now();
908                    if now < deadline {
909                        let timeout = deadline.saturating_duration_since(now);
910                        let _ = cond.wait_for(&mut guard, timeout);
911                        if guard.shutdown {
912                            return;
913                        }
914                        if guard.pending_target_lsn <= guard.durable_lsn {
915                            continue;
916                        }
917                        let should_wait_again = guard.pending_statements < max_statements
918                            && guard.pending_wal_bytes < max_wal_bytes
919                            && guard
920                                .first_pending_at
921                                .map(|first| first.elapsed() < window)
922                                .unwrap_or(false);
923                        if should_wait_again {
924                            continue;
925                        }
926                    }
927                }
928
929                guard.pending_target_lsn
930            };
931
932            // Drain the current epoch even when reset is waiting: outstanding
933            // append leases cannot finish until this flusher acknowledges them.
934            // Retain the lease through state publication to exclude stale ACKs
935            // after a checkpoint resets offsets back to the WAL header.
936            let _epoch = queue.epoch.read_recursive();
937
938            // Drain all queued entries. Since `WalAppendQueue::enqueue`
939            // assigns LSN and pushes under a single mutex, the drained
940            // tuples are guaranteed to form a contiguous byte range
941            // starting at `wal.current_lsn()` — no gaps, no leftover
942            // handling.
943            let batches = queue.drain_sorted();
944
945            let sync_result = {
946                let mut wal = wal.lock();
947                let mut write_err: Option<io::Error> = None;
948                for (_lsn, bytes) in batches {
949                    if let Err(e) = wal.append_bytes(&bytes) {
950                        write_err = Some(e);
951                        break;
952                    }
953                }
954                match write_err {
955                    Some(e) => Err(e),
956                    None => wal.sync().map(|_| {
957                        // Count the fsync exactly once per drain
958                        // cycle so tests can compare it against the
959                        // number of `append_actions` callers that
960                        // entered the queue.
961                        fsync_count.fetch_add(1, Ordering::Relaxed);
962                        wal.durable_lsn()
963                    }),
964                }
965            };
966
967            // Late enqueuers that arrived after our drain stay in the
968            // queue — don't clear the `pending_*` counters yet and
969            // don't claim we reached `target_lsn` unless the fsync
970            // actually covers it.
971            let more_pending = queue.has_pending();
972
973            let mut guard = state_lock.lock();
974            match sync_result {
975                Ok(durable_lsn) => {
976                    guard.durable_lsn = durable_lsn;
977                    if !more_pending {
978                        guard.pending_statements = 0;
979                        guard.pending_wal_bytes = 0;
980                        guard.first_pending_at = None;
981                    }
982                    guard.last_error = None;
983                    let _ = target_lsn;
984                }
985                Err(err) => {
986                    guard.last_error = Some(err.to_string());
987                }
988            }
989            cond.notify_all();
990        }
991    }
992}
993
994impl Drop for StoreCommitCoordinator {
995    fn drop(&mut self) {
996        let (state_lock, cond) = &*self.state;
997        // parking_lot::Mutex::lock is infallible (no poisoning).
998        let mut state = state_lock.lock();
999        state.shutdown = true;
1000        cond.notify_all();
1001    }
1002}
1003
1004impl UnifiedStore {
1005    pub(crate) fn deferred_store_wal_capture_active() -> bool {
1006        deferred_store_wal_capture_active()
1007    }
1008
1009    pub(crate) fn begin_deferred_store_wal_capture() {
1010        begin_deferred_store_wal_capture();
1011    }
1012
1013    pub(crate) fn take_deferred_store_wal_capture() -> DeferredStoreWalActions {
1014        take_deferred_store_wal_capture()
1015    }
1016
1017    pub(crate) fn append_deferred_store_wal_actions(
1018        &self,
1019        actions: DeferredStoreWalActions,
1020    ) -> Result<(), StoreError> {
1021        if actions.actions.is_empty() {
1022            return Ok(());
1023        }
1024        if self.config.embedded_wal_path.is_some() {
1025            return self.append_embedded_store_wal_actions(&actions.actions);
1026        }
1027        // Deferred transactions need a committed WAL batch even in Strict
1028        // mode. WAL-only versions have no pager pages to flush yet.
1029        if let Some(commit) = &self.commit {
1030            let references = commit
1031                .append_checkpoint_actions(&actions.actions)
1032                .map_err(StoreError::Io)?;
1033            self.remember_checkpoint_rows(actions.actions, references);
1034            Ok(())
1035        } else {
1036            self.flush_paged_state()
1037        }
1038    }
1039
1040    /// Emit a [`WalRecord::VectorInsert`] for a `vector.turbo`
1041    /// collection (issue #693). Returns `Ok(())` when no commit
1042    /// coordinator is configured (in-memory mode) so callers don't
1043    /// have to special-case the missing WAL — the in-memory index
1044    /// update remains durable enough for in-memory runtimes by
1045    /// construction.
1046    pub(crate) fn append_vector_insert_record(
1047        &self,
1048        collection: &str,
1049        entity_id: u64,
1050        vector: &[f32],
1051    ) -> std::io::Result<()> {
1052        let Some(commit) = &self.commit else {
1053            return Ok(());
1054        };
1055        let record = WalRecord::VectorInsert {
1056            collection: collection.to_string(),
1057            entity_id,
1058            vector: vector.to_vec(),
1059        };
1060        commit.append_single_record(record)
1061    }
1062
1063    pub(crate) fn append_probabilistic_delta_record(
1064        &self,
1065        kind: u8,
1066        operation: u8,
1067        name: &str,
1068        operands: Vec<Vec<u8>>,
1069    ) -> std::io::Result<()> {
1070        let Some(commit) = &self.commit else {
1071            return Ok(());
1072        };
1073        let record = WalRecord::ProbabilisticDelta {
1074            kind,
1075            operation,
1076            name: name.to_string(),
1077            operands,
1078        };
1079        commit.append_single_record(record)
1080    }
1081
1082    pub(crate) fn finish_paged_write(
1083        &self,
1084        actions: impl IntoIterator<Item = StoreWalAction>,
1085    ) -> Result<(), StoreError> {
1086        self.finish_paged_write_for_projection(actions, false)
1087    }
1088
1089    pub(crate) fn finish_paged_write_for_projection(
1090        &self,
1091        actions: impl IntoIterator<Item = StoreWalAction>,
1092        track: bool,
1093    ) -> Result<(), StoreError> {
1094        let actions: Vec<StoreWalAction> = actions.into_iter().collect();
1095        if deferred_store_wal_capture_active() {
1096            let captured = capture_deferred_store_wal_actions(actions);
1097            debug_assert!(captured);
1098            return Ok(());
1099        }
1100        if self.config.embedded_wal_path.is_some() {
1101            return self.append_embedded_store_wal_actions(&actions);
1102        }
1103        let mut references = super::checkpoint_wal::CheckpointWalBatch::default();
1104        let result = match self.config.durability_mode {
1105            DurabilityMode::Strict => {
1106                if let Some(commit) = &self.commit {
1107                    if track {
1108                        references = commit
1109                            .append_checkpoint_actions(&actions)
1110                            .map_err(StoreError::Io)?;
1111                    } else {
1112                        commit.append_actions(&actions).map_err(StoreError::Io)?;
1113                    }
1114                }
1115                self.flush_paged_state()
1116            }
1117            DurabilityMode::WalDurableGrouped | DurabilityMode::Async => {
1118                if let Some(commit) = &self.commit {
1119                    if track {
1120                        references = commit
1121                            .append_checkpoint_actions(&actions)
1122                            .map_err(StoreError::Io)?;
1123                    } else {
1124                        commit.append_actions(&actions).map_err(StoreError::Io)?;
1125                    }
1126                    Ok(())
1127                } else {
1128                    self.flush_paged_state()
1129                }
1130            }
1131        };
1132        result?;
1133        if track {
1134            self.remember_checkpoint_rows(actions, references);
1135        }
1136        Ok(())
1137    }
1138
1139    pub(crate) fn apply_encoded_store_wal_action(&self, payload: &[u8]) -> Result<(), StoreError> {
1140        let action = StoreWalAction::decode(payload).map_err(StoreError::Io)?;
1141        self.apply_replayed_action(&action, true)
1142    }
1143
1144    fn append_embedded_store_wal_actions(
1145        &self,
1146        actions: &[StoreWalAction],
1147    ) -> Result<(), StoreError> {
1148        let Some(path) = self.config.embedded_wal_path.as_deref() else {
1149            return Ok(());
1150        };
1151        if actions.is_empty() {
1152            return Ok(());
1153        }
1154        let payloads: Vec<Vec<u8>> = actions.iter().map(StoreWalAction::encode).collect();
1155        let encoded_len = crate::storage::EmbeddedRdbArtifact::wal_payloads_encoded_len(&payloads)
1156            .map_err(|err| StoreError::Io(std::io::Error::other(err.to_string())))?;
1157        match self.append_embedded_payloads(path, &payloads) {
1158            Ok(_) => Ok(()),
1159            Err(crate::api::RedDBError::InvalidOperation(msg))
1160                if msg.contains("embedded wal region full") =>
1161            {
1162                self.recover_full_embedded_wal(path, &payloads, encoded_len)
1163            }
1164            Err(err) => Err(StoreError::Io(std::io::Error::other(err.to_string()))),
1165        }
1166    }
1167
1168    fn recover_full_embedded_wal(
1169        &self,
1170        path: &Path,
1171        payloads: &[Vec<u8>],
1172        encoded_len: u64,
1173    ) -> Result<(), StoreError> {
1174        let _vault_guard = self.vault_publication_lock.lock();
1175        // The failed append preceded this lock: another writer may already
1176        // have reclaimed space. Retry through the same FIFO coordinator so
1177        // stale full results cannot each serialize another complete snapshot.
1178        match self.append_embedded_payloads(path, payloads) {
1179            Ok(_) => return Ok(()),
1180            Err(crate::api::RedDBError::InvalidOperation(message))
1181                if message.contains("embedded wal region full") => {}
1182            Err(error) => return Err(StoreError::Io(std::io::Error::other(error.to_string()))),
1183        }
1184        // A checkpoint can fail after publishing its image. Preserve the
1185        // session failure fence through errors and unwinding, just as append's
1186        // FlushBatch guard does; in-memory rollback alone cannot resolve disk.
1187        struct CheckpointFence<'a>(Option<&'a crate::storage::embedded::EmbeddedCommitSession>);
1188        impl Drop for CheckpointFence<'_> {
1189            fn drop(&mut self) {
1190                if let Some(session) = self.0 {
1191                    session.fence_interrupted_write();
1192                }
1193            }
1194        }
1195        let mut fence = CheckpointFence(Some(
1196            self.embedded_commit_session
1197                .get()
1198                .expect("WAL-full recovery follows an append attempt"),
1199        ));
1200        let started = Instant::now();
1201        let snapshot = self.to_binary_dump_bytes();
1202        let capacity = encoded_len.max((snapshot.len() as u64).min(EMBEDDED_WAL_HEADROOM_LIMIT));
1203        let checkpoint = crate::storage::EmbeddedRdbArtifact::write_snapshot_with_wal_capacity(
1204            path, &snapshot, capacity,
1205        )
1206        .map_err(|err| StoreError::Io(std::io::Error::other(err.to_string())))?;
1207        fence.0 = None;
1208        tracing::debug!(
1209            target: "reddb::embedded_checkpoint",
1210            snapshot_bytes = snapshot.len(),
1211            wal_region_bytes = checkpoint.manifest.wal_region_bytes,
1212            elapsed_us = started.elapsed().as_micros() as u64,
1213            "embedded WAL overflow checkpoint"
1214        );
1215        self.append_embedded_payloads(path, payloads)
1216            .map(|_| ())
1217            .map_err(|err| StoreError::Io(std::io::Error::other(err.to_string())))
1218    }
1219
1220    fn append_embedded_payloads(
1221        &self,
1222        path: &Path,
1223        payloads: &[Vec<u8>],
1224    ) -> crate::RedDBResult<crate::storage::EmbeddedRdbOpen> {
1225        let session = self
1226            .embedded_commit_session
1227            .get_or_init(|| crate::storage::EmbeddedRdbArtifact::group_commit_session(path));
1228        let options = self.config.group_commit;
1229        let grouped = self.config.durability_mode == DurabilityMode::WalDurableGrouped;
1230        let window = if grouped {
1231            StoreCommitCoordinator::resolve_window(&options)
1232        } else {
1233            Duration::ZERO
1234        };
1235        session.append(
1236            payloads,
1237            reddb_file::embedded::EmbeddedGroupCommitOptions {
1238                window,
1239                max_statements: if grouped { options.max_statements } else { 1 },
1240                max_wal_bytes: options.max_wal_bytes,
1241            },
1242        )
1243    }
1244
1245    pub(super) fn enqueue_grouped_actions(
1246        &self,
1247        actions: &[StoreWalAction],
1248    ) -> Result<Option<crate::storage::embedded::PendingEmbeddedAppend>, StoreError> {
1249        if self.config.durability_mode != DurabilityMode::WalDurableGrouped
1250            || deferred_store_wal_capture_active()
1251        {
1252            return Ok(None);
1253        }
1254        let Some(path) = self.config.embedded_wal_path.as_deref() else {
1255            return Ok(None);
1256        };
1257        let session = self
1258            .embedded_commit_session
1259            .get_or_init(|| crate::storage::EmbeddedRdbArtifact::group_commit_session(path));
1260        let payloads: Vec<Vec<u8>> = actions.iter().map(StoreWalAction::encode).collect();
1261        let options = self.config.group_commit;
1262        session
1263            .enqueue(
1264                &payloads,
1265                reddb_file::embedded::EmbeddedGroupCommitOptions {
1266                    window: StoreCommitCoordinator::resolve_window(&options),
1267                    max_statements: options.max_statements,
1268                    max_wal_bytes: options.max_wal_bytes,
1269                },
1270            )
1271            .map(Some)
1272            .map_err(|error| StoreError::Io(io::Error::other(error.to_string())))
1273    }
1274
1275    pub(super) fn complete_grouped_actions(
1276        &self,
1277        pending: &crate::storage::embedded::PendingEmbeddedAppend,
1278        actions: &[StoreWalAction],
1279    ) -> Result<(), StoreError> {
1280        match pending.wait() {
1281            Ok(_) => Ok(()),
1282            Err(crate::RedDBError::InvalidOperation(message))
1283                if message.contains("embedded wal region full") =>
1284            {
1285                self.append_embedded_store_wal_actions(actions)
1286            }
1287            Err(error) => Err(StoreError::Io(io::Error::other(error.to_string()))),
1288        }
1289    }
1290
1291    pub(crate) fn apply_replayed_action(
1292        &self,
1293        action: &StoreWalAction,
1294        update_btrees: bool,
1295    ) -> Result<(), StoreError> {
1296        match action {
1297            StoreWalAction::CreateCollection { name } => {
1298                if self.get_collection(name).is_none() {
1299                    let _ = self.create_collection_in_memory(name);
1300                }
1301                Ok(())
1302            }
1303            StoreWalAction::DropCollection { name } => self.drop_collection_in_memory(name),
1304            StoreWalAction::UpsertEntityRecord { collection, record } => {
1305                self.apply_replayed_upsert(collection, record, update_btrees)
1306            }
1307            StoreWalAction::DeleteEntityRecord {
1308                collection,
1309                entity_id,
1310            } => self.apply_replayed_delete(collection, EntityId::new(*entity_id), update_btrees),
1311            StoreWalAction::BulkUpsertEntityRecords {
1312                collection,
1313                records,
1314            } => {
1315                for record in records {
1316                    self.apply_replayed_upsert(collection, record, update_btrees)?;
1317                }
1318                Ok(())
1319            }
1320            StoreWalAction::RefreshCollection {
1321                collection,
1322                records,
1323            } => self.apply_replayed_refresh_collection(collection, records, update_btrees),
1324        }
1325    }
1326
1327    /// Atomic full-collection replace — issue #595 slice 9c.
1328    ///
1329    /// Builds a fresh `SegmentManager` populated with `entities`,
1330    /// atomically swaps it into the collections map, rebuilds the
1331    /// paged B-tree from the same records, and emits a single
1332    /// `RefreshCollection` WAL action.
1333    ///
1334    /// Concurrent readers that resolved an `Arc<SegmentManager>` for
1335    /// this collection before the swap continue reading the prior
1336    /// contents; readers after the swap see the new contents. The
1337    /// transition is single-pointer so partial state is unobservable.
1338    ///
1339    /// A crash between in-memory mutation and WAL fsync leaves the
1340    /// previous WAL stream intact — on recovery the prior
1341    /// `RefreshCollection` (or whatever last bulk-applied to the
1342    /// collection) is replayed, so the prior contents are observed.
1343    pub fn refresh_collection(
1344        &self,
1345        name: &str,
1346        entities: Vec<UnifiedEntity>,
1347    ) -> Result<Vec<Vec<u8>>, StoreError> {
1348        let fv = self.format_version();
1349
1350        // Build a fresh manager. Done outside the collections lock so
1351        // the critical section is just the pointer swap below.
1352        let new_manager = Arc::new(SegmentManager::with_config(
1353            name,
1354            self.config.manager_config.clone(),
1355        ));
1356
1357        let mut prepared = entities;
1358        for entity in &mut prepared {
1359            if entity.id.raw() == 0 {
1360                entity.id = self.next_entity_id();
1361            } else {
1362                self.register_entity_id(entity.id);
1363            }
1364            if let EntityKind::TableRow { ref mut row_id, .. } = entity.kind {
1365                if *row_id == 0 {
1366                    *row_id = new_manager.next_row_id();
1367                } else {
1368                    new_manager.register_row_id(*row_id);
1369                }
1370            }
1371            entity.ensure_table_logical_id();
1372        }
1373
1374        let serialized: Vec<Vec<u8>> = prepared
1375            .iter()
1376            .map(|e| Self::serialize_entity_record(e, None, fv))
1377            .collect();
1378
1379        new_manager.bulk_insert(prepared.clone()).map_err(|e| {
1380            StoreError::Io(std::io::Error::other(format!(
1381                "refresh_collection: bulk_insert into new manager failed: {e}"
1382            )))
1383        })?;
1384
1385        self.swap_collection_state(name, new_manager, &prepared, &serialized, true);
1386
1387        self.finish_paged_write([StoreWalAction::RefreshCollection {
1388            collection: name.to_string(),
1389            records: serialized.clone(),
1390        }])?;
1391
1392        Ok(serialized)
1393    }
1394
1395    /// Atomic swap of the in-memory collection state. Used by both
1396    /// the live `refresh_collection` path and WAL replay. The new
1397    /// manager is already populated; this fn replaces the live Arc,
1398    /// purges side-state pointing at the previous contents, and
1399    /// rebuilds the paged B-tree from the supplied records.
1400    fn swap_collection_state(
1401        &self,
1402        name: &str,
1403        new_manager: Arc<SegmentManager>,
1404        prepared: &[UnifiedEntity],
1405        serialized: &[Vec<u8>],
1406        update_btrees: bool,
1407    ) {
1408        {
1409            let mut collections = self.collections.write();
1410            collections.insert(name.to_string(), new_manager);
1411        }
1412
1413        self.entity_cache
1414            .retain(|_, (collection, _)| collection != name);
1415        self.remove_from_graph_label_index_batch(
1416            name,
1417            &prepared.iter().map(|e| e.id).collect::<Vec<_>>(),
1418        );
1419
1420        if let Some(pager) = self.pager.as_ref().filter(|_| update_btrees) {
1421            let new_btree = Arc::new(BTree::new(Arc::clone(pager)));
1422            let mut sorted: Vec<(Vec<u8>, Vec<u8>)> = prepared
1423                .iter()
1424                .zip(serialized.iter())
1425                .map(|(e, r)| (e.id.raw().to_be_bytes().to_vec(), r.clone()))
1426                .collect();
1427            sorted.sort_by(|a, b| a.0.cmp(&b.0));
1428            if !sorted.is_empty() {
1429                let _ = new_btree.bulk_insert_sorted(&sorted);
1430            }
1431            self.btree_indices
1432                .write()
1433                .insert(name.to_string(), new_btree);
1434            self.mark_paged_registry_dirty();
1435        }
1436    }
1437
1438    fn apply_replayed_refresh_collection(
1439        &self,
1440        collection: &str,
1441        records: &[Vec<u8>],
1442        update_btrees: bool,
1443    ) -> Result<(), StoreError> {
1444        let new_manager = Arc::new(SegmentManager::with_config(
1445            collection,
1446            self.config.manager_config.clone(),
1447        ));
1448
1449        let mut prepared: Vec<UnifiedEntity> = Vec::with_capacity(records.len());
1450        for record in records {
1451            let (entity, _metadata) =
1452                Self::deserialize_entity_record(record, self.format_version())?;
1453            self.register_entity_id(entity.id);
1454            if let EntityKind::TableRow { row_id, .. } = &entity.kind {
1455                new_manager.register_row_id(*row_id);
1456            }
1457            prepared.push(entity);
1458        }
1459
1460        if !prepared.is_empty() {
1461            new_manager.bulk_insert(prepared.clone()).map_err(|e| {
1462                StoreError::Io(std::io::Error::other(format!(
1463                    "replay refresh_collection: bulk_insert failed: {e}"
1464                )))
1465            })?;
1466        }
1467
1468        self.swap_collection_state(collection, new_manager, &prepared, records, update_btrees);
1469        Ok(())
1470    }
1471
1472    /// Replica-side analogue of [`Self::refresh_collection`] — issue
1473    /// #596 slice 9d. Takes pre-serialized record bytes from the
1474    /// primary's CDC stream (the same bytes the primary wrote into the
1475    /// `RefreshCollection` WAL action), applies the atomic swap, and
1476    /// emits the same `RefreshCollection` WAL action against the
1477    /// replica's local store WAL so the post-swap state survives a
1478    /// replica restart through the normal recovery path.
1479    ///
1480    /// Idempotency: re-applying the same records bytes is a full
1481    /// rebuild from the same payload, so the resulting backing-
1482    /// collection contents are equal to the prior call's result.
1483    /// The primary-side `LogicalChangeApplier` short-circuits exact
1484    /// duplicate LSN+payload combinations before reaching here.
1485    pub fn refresh_collection_from_records(
1486        &self,
1487        name: &str,
1488        records: Vec<Vec<u8>>,
1489    ) -> Result<(), StoreError> {
1490        self.apply_replayed_refresh_collection(name, &records, true)?;
1491        self.finish_paged_write([StoreWalAction::RefreshCollection {
1492            collection: name.to_string(),
1493            records,
1494        }])?;
1495        Ok(())
1496    }
1497
1498    pub(crate) fn create_collection_in_memory(&self, name: &str) -> Result<(), StoreError> {
1499        let mut collections = self.collections.write();
1500        if collections.contains_key(name) {
1501            return Ok(());
1502        }
1503        let manager = SegmentManager::with_config(name, self.config.manager_config.clone());
1504        collections.insert(name.to_string(), Arc::new(manager));
1505        self.mark_paged_registry_dirty();
1506        Ok(())
1507    }
1508
1509    fn drop_collection_in_memory(&self, name: &str) -> Result<(), StoreError> {
1510        let manager = {
1511            let mut collections = self.collections.write();
1512            match collections.remove(name) {
1513                Some(manager) => manager,
1514                None => return Ok(()),
1515            }
1516        };
1517
1518        let entities = manager.query_all(|_| true);
1519        let entity_ids: Vec<EntityId> = entities.iter().map(|entity| entity.id).collect();
1520        for entity_id in &entity_ids {
1521            self.context_index.remove_entity(*entity_id);
1522            let _ = self.unindex_cross_refs(*entity_id);
1523        }
1524        if let Some(tree) = self.btree_indices.write().remove(name) {
1525            self.retire_btree(tree);
1526        }
1527        self.entity_cache.retain(|entity_id, (collection, _)| {
1528            collection != name && !entity_ids.iter().any(|id| id.raw() == entity_id)
1529        });
1530        self.remove_from_graph_label_index_batch(name, &entity_ids);
1531        self.mark_paged_registry_dirty();
1532        Ok(())
1533    }
1534
1535    fn apply_replayed_upsert(
1536        &self,
1537        collection: &str,
1538        record: &[u8],
1539        update_btrees: bool,
1540    ) -> Result<(), StoreError> {
1541        self.create_collection_in_memory(collection)?;
1542        let (entity, metadata) = Self::deserialize_entity_record(record, self.format_version())?;
1543        let manager = self
1544            .get_collection(collection)
1545            .ok_or_else(|| StoreError::CollectionNotFound(collection.to_string()))?;
1546
1547        self.register_entity_id(entity.id);
1548        if let EntityKind::TableRow { row_id, .. } = &entity.kind {
1549            manager.register_row_id(*row_id);
1550        }
1551
1552        self.context_index.remove_entity(entity.id);
1553        let _ = self.unindex_cross_refs(entity.id);
1554        self.remove_from_graph_label_index(collection, entity.id);
1555
1556        if manager.get(entity.id).is_some() {
1557            manager
1558                .update_with_metadata(entity.clone(), metadata.as_ref())
1559                .map_err(StoreError::from)?;
1560        } else {
1561            manager.restore_snapshot_entity(entity.clone())?;
1562            if let Some(metadata) = metadata.as_ref() {
1563                manager.set_metadata(entity.id, metadata.clone())?;
1564            }
1565        }
1566
1567        self.context_index.index_entity(collection, &entity);
1568        if let EntityKind::GraphNode(node) = &entity.kind {
1569            self.update_graph_label_index(collection, &node.label, entity.id);
1570        }
1571        self.index_cross_refs(&entity, collection)?;
1572
1573        if let Some(pager) = self.pager.as_ref().filter(|_| update_btrees) {
1574            let mut btree_indices = self.btree_indices.write();
1575            let btree = btree_indices
1576                .entry(collection.to_string())
1577                .or_insert_with(|| Arc::new(BTree::new(Arc::clone(pager))));
1578            let root_before = btree.root_page_id();
1579            let key = entity.id.raw().to_be_bytes();
1580            match btree.insert(&key, record) {
1581                Ok(_) => {}
1582                Err(BTreeError::DuplicateKey) => {
1583                    let _ = btree.delete(&key);
1584                    let _ = btree.insert(&key, record);
1585                }
1586                Err(err) => {
1587                    return Err(StoreError::Io(io::Error::other(format!(
1588                        "replay upsert btree error: {err}"
1589                    ))));
1590                }
1591            }
1592            if root_before != btree.root_page_id() {
1593                self.mark_paged_registry_dirty();
1594            }
1595        }
1596
1597        Ok(())
1598    }
1599
1600    fn apply_replayed_delete(
1601        &self,
1602        collection: &str,
1603        id: EntityId,
1604        update_btrees: bool,
1605    ) -> Result<(), StoreError> {
1606        self.entity_cache.remove(id.raw());
1607        if let Some(manager) = self.get_collection(collection) {
1608            let deleted = manager.delete(id)?;
1609            if !deleted {
1610                return Ok(());
1611            }
1612        } else {
1613            return Ok(());
1614        }
1615
1616        if self.pager.is_some() && update_btrees {
1617            let btree_indices = self.btree_indices.read();
1618            if let Some(btree) = btree_indices.get(collection) {
1619                let root_before = btree.root_page_id();
1620                let key = id.raw().to_be_bytes();
1621                let _ = btree.delete(&key);
1622                if root_before != btree.root_page_id() {
1623                    self.mark_paged_registry_dirty();
1624                }
1625            }
1626        }
1627
1628        let _ = self.unindex_cross_refs(id);
1629        self.remove_from_graph_label_index(collection, id);
1630        self.context_index.remove_entity(id);
1631        Ok(())
1632    }
1633}
1634
1635#[cfg(test)]
1636mod tests {
1637    use super::*;
1638    use crate::api::{DurabilityMode, GroupCommitOptions};
1639    use std::sync::{Barrier, Mutex as StdMutex, OnceLock};
1640    use std::time::SystemTime;
1641
1642    fn embedded_fixture(
1643        mode: DurabilityMode,
1644        background_bytes: usize,
1645    ) -> (tempfile::TempDir, PathBuf, UnifiedStore) {
1646        let directory = tempfile::tempdir().expect("directory");
1647        let path = directory.path().join("data.rdb");
1648        let mut store =
1649            UnifiedStore::with_config(UnifiedStoreConfig::default().with_durability_mode(mode));
1650        store.create_collection("keys").expect("collection");
1651        store.set_aux_metadata(vec![42; background_bytes]);
1652        crate::storage::EmbeddedRdbArtifact::create_with_snapshot(
1653            &path,
1654            &store.to_binary_dump_bytes(),
1655        )
1656        .expect("snapshot");
1657        store.config.embedded_wal_path = Some(path.clone());
1658        (directory, path, store)
1659    }
1660
1661    #[test]
1662    fn embedded_stale_full_result_does_not_checkpoint_reclaimed_space() {
1663        for mode in [DurabilityMode::Strict, DurabilityMode::WalDurableGrouped] {
1664            let (_directory, path, store) = embedded_fixture(mode, 0);
1665            store
1666                .append_embedded_store_wal_actions(&[StoreWalAction::CreateCollection {
1667                    name: "keys".into(),
1668                }])
1669                .expect("another writer appended after reclaiming space");
1670            let before = crate::storage::EmbeddedRdbArtifact::open(&path).expect("before");
1671            let payloads = vec![StoreWalAction::CreateCollection {
1672                name: "after".into(),
1673            }
1674            .encode()];
1675            let length = crate::storage::EmbeddedRdbArtifact::wal_payloads_encoded_len(&payloads)
1676                .expect("encoded length");
1677            // Model a full result obtained before that other writer's checkpoint.
1678            store
1679                .recover_full_embedded_wal(&path, &payloads, length)
1680                .expect("revalidate after acquiring publication");
1681            let after = crate::storage::EmbeddedRdbArtifact::open(&path).expect("after");
1682            assert_eq!(
1683                after.selected_superblock.generation,
1684                before.selected_superblock.generation + 1,
1685                "only the retry is published, without an unnecessary checkpoint"
1686            );
1687            assert_eq!(
1688                crate::storage::EmbeddedRdbArtifact::read_wal_payloads(&after)
1689                    .expect("both appends retained")
1690                    .len(),
1691                2
1692            );
1693        }
1694    }
1695
1696    #[test]
1697    fn embedded_full_wal_reserves_bounded_headroom_for_populated_store() {
1698        for mode in [DurabilityMode::Strict, DurabilityMode::WalDurableGrouped] {
1699            let (_directory, path, store) = embedded_fixture(mode, 2 * 1024 * 1024);
1700            let initial = crate::storage::EmbeddedRdbArtifact::open(&path).expect("initial");
1701            let action = StoreWalAction::CreateCollection {
1702                name: "keys".into(),
1703            };
1704            let payloads = vec![action.encode()];
1705            let length = crate::storage::EmbeddedRdbArtifact::wal_payloads_encoded_len(&payloads)
1706                .expect("encoded length");
1707            crate::storage::EmbeddedRdbArtifact::append_wal_payloads(
1708                &path,
1709                &vec![payloads[0].clone(); (initial.manifest.wal_region_bytes / length) as usize],
1710            )
1711            .expect("fill the initial region with valid records");
1712            store
1713                .append_embedded_store_wal_actions(&[action])
1714                .expect("overflow");
1715            let open = crate::storage::EmbeddedRdbArtifact::open(&path).expect("checkpoint");
1716            assert_eq!(open.manifest.wal_region_bytes, 1024 * 1024);
1717            let snapshot = crate::storage::EmbeddedRdbArtifact::read_snapshot(&open)
1718                .expect("snapshot")
1719                .expect("populated image");
1720            let recovered =
1721                UnifiedStore::load_from_bytes_with_config(&snapshot, UnifiedStoreConfig::default())
1722                    .expect("recover snapshot");
1723            for payload in
1724                crate::storage::EmbeddedRdbArtifact::read_wal_payloads(&open).expect("WAL")
1725            {
1726                recovered
1727                    .apply_encoded_store_wal_action(&payload)
1728                    .expect("replay");
1729            }
1730            assert_eq!(recovered.aux_metadata.read().len(), 2 * 1024 * 1024);
1731            assert!(recovered.get_collection("keys").is_some());
1732        }
1733    }
1734
1735    #[test]
1736    fn embedded_grouped_overwrites_recover_across_repeated_full_wal_regions() {
1737        fn row(writer: usize, round: usize) -> UnifiedEntity {
1738            let mut state = (writer as u64 + 1) * 31 + round as u64 * 13;
1739            let payload: String = (0..4096)
1740                .map(|_| {
1741                    state = state.wrapping_mul(1664525).wrapping_add(1013904223);
1742                    char::from(b'!' + ((state >> 24) % 90) as u8)
1743                })
1744                .collect();
1745            let mut entity = UnifiedEntity::table_row(EntityId::new(0), "keys", 0, vec![]);
1746            entity.data = EntityData::Row(RowData::with_names(
1747                vec![
1748                    Value::text(format!("k{writer}")),
1749                    Value::text(format!("{round}:{payload}")),
1750                ],
1751                vec!["key".into(), "value".into()],
1752            ));
1753            entity
1754        }
1755        let (_directory, path, store) =
1756            embedded_fixture(DurabilityMode::WalDurableGrouped, 2 * 1024 * 1024);
1757        assert!(
1758            StoreWalAction::upsert_entity("keys", &row(0, 0), None, store.format_version())
1759                .encode()
1760                .len()
1761                >= 2048,
1762            "fixture must exert physical WAL pressure after value compression"
1763        );
1764        let mut ids: Vec<_> = (0..8)
1765            .map(|writer| {
1766                store
1767                    .write_keyed_row("keys", row(writer, 0), None, None)
1768                    .expect("seed")
1769            })
1770            .collect();
1771        let initial = crate::storage::EmbeddedRdbArtifact::open(&path).expect("initial roots");
1772        let mut previous_snapshot = crate::storage::EmbeddedRdbArtifact::read_snapshot(&initial)
1773            .expect("initial snapshot")
1774            .expect("image");
1775        // Each wave writes over 2 MiB through the same 1 MiB reservation.
1776        // Checking image changes after each wave prevents a larger WAL from
1777        // merely postponing every checkpoint beyond the test's duration.
1778        for wave in 0..3 {
1779            let barrier = Barrier::new(ids.len());
1780            ids = std::thread::scope(|scope| {
1781                let workers: Vec<_> = ids
1782                    .iter()
1783                    .enumerate()
1784                    .map(|(writer, previous)| {
1785                        let store = &store;
1786                        let barrier = &barrier;
1787                        scope.spawn(move || {
1788                            barrier.wait();
1789                            let mut id = *previous;
1790                            for round in 0..64 {
1791                                id = store
1792                                    .write_keyed_row(
1793                                        "keys",
1794                                        row(writer, wave * 64 + round),
1795                                        None,
1796                                        Some(id),
1797                                    )
1798                                    .expect("durable overwrite");
1799                            }
1800                            id
1801                        })
1802                    })
1803                    .collect();
1804                workers
1805                    .into_iter()
1806                    .map(|worker| worker.join().expect("writer"))
1807                    .collect()
1808            });
1809            let open = crate::storage::EmbeddedRdbArtifact::open(&path).expect("wave roots");
1810            assert_eq!(open.manifest.wal_region_bytes, EMBEDDED_WAL_HEADROOM_LIMIT);
1811            let snapshot = crate::storage::EmbeddedRdbArtifact::read_snapshot(&open)
1812                .expect("wave snapshot")
1813                .expect("image");
1814            assert!(snapshot != previous_snapshot, "wave {wave} must checkpoint");
1815            previous_snapshot = snapshot;
1816        }
1817        let open = crate::storage::EmbeddedRdbArtifact::open(&path).expect("recover roots");
1818        let snapshot = crate::storage::EmbeddedRdbArtifact::read_snapshot(&open)
1819            .expect("snapshot")
1820            .expect("image");
1821        let recovered =
1822            UnifiedStore::load_from_bytes_with_config(&snapshot, UnifiedStoreConfig::default())
1823                .expect("snapshot replay");
1824        for payload in crate::storage::EmbeddedRdbArtifact::read_wal_payloads(&open).expect("WAL") {
1825            recovered
1826                .apply_encoded_store_wal_action(&payload)
1827                .expect("WAL replay");
1828        }
1829        assert_eq!(recovered.get_collection("keys").expect("keys").count(), 8);
1830        for (writer, id) in ids.iter().enumerate() {
1831            let entity = recovered
1832                .get("keys", *id)
1833                .expect("last acknowledged version");
1834            assert_eq!(
1835                entity.data.as_row().expect("row").get_field("value"),
1836                row(writer, 191)
1837                    .data
1838                    .as_row()
1839                    .expect("expected row")
1840                    .get_field("value")
1841            );
1842        }
1843    }
1844
1845    /// Serialise tests that mutate `REDDB_GROUP_COMMIT_WINDOW_US`.
1846    /// The env table is process-global, so two parallel test threads
1847    /// flipping it would race the assertions in the test that
1848    /// happens to read it last.
1849    fn env_lock() -> &'static StdMutex<()> {
1850        static LOCK: OnceLock<StdMutex<()>> = OnceLock::new();
1851        LOCK.get_or_init(|| StdMutex::new(()))
1852    }
1853
1854    fn temp_wal(name: &str) -> PathBuf {
1855        let nanos = SystemTime::now()
1856            .duration_since(std::time::UNIX_EPOCH)
1857            .unwrap()
1858            .as_nanos();
1859        let path = reddb_file::layout::store_commit_coord_temp_wal_path(
1860            &std::env::temp_dir(),
1861            name,
1862            std::process::id(),
1863            nanos,
1864        );
1865        let _ = std::fs::remove_file(&path);
1866        path
1867    }
1868
1869    fn replay_crash_payload(id: u64, round: u64) -> String {
1870        let mut state = id * 31 + round * 13 + 1;
1871        (0..600)
1872            .map(|_| {
1873                state = state.wrapping_mul(1664525).wrapping_add(1013904223);
1874                char::from(b'!' + ((state >> 24) % 90) as u8)
1875            })
1876            .collect()
1877    }
1878
1879    #[test]
1880    fn paged_wal_replay_survives_immediate_process_exit() {
1881        const CHILD_PATH: &str = "REDDB_TEST_REPLAY_CRASH_PATH";
1882        let config = || UnifiedStoreConfig::default().with_page_cache_slots(4);
1883        if let Some(path) = std::env::var_os(CHILD_PATH) {
1884            let store = UnifiedStore::open_with_config(path, config()).expect("replay child");
1885            assert_eq!(store.get_collection("rows").unwrap().count(), 400);
1886            // No Rust destructors or graceful checkpoint may repair the image.
1887            std::process::exit(0);
1888        }
1889        let directory = tempfile::tempdir().unwrap();
1890        let path = directory.path().join("data.rdb");
1891        {
1892            let store = UnifiedStore::open_with_config(&path, config()).unwrap();
1893            store.create_collection("rows").unwrap();
1894            for id in 1..=400 {
1895                store
1896                    .insert(
1897                        "rows",
1898                        UnifiedEntity::table_row(
1899                            EntityId::new(id),
1900                            "rows",
1901                            id,
1902                            vec![Value::text(format!("initial-{id}"))],
1903                        ),
1904                    )
1905                    .unwrap();
1906            }
1907            store.persist().unwrap();
1908        }
1909        {
1910            let coordinator = StoreCommitCoordinator::open(
1911                reddb_file::layout::unified_wal_path(&path),
1912                DurabilityMode::Strict,
1913                GroupCommitOptions::default(),
1914            )
1915            .unwrap();
1916            for round in 0..3 {
1917                let actions = (1..=400)
1918                    .map(|id| {
1919                        let payload = replay_crash_payload(id, round);
1920                        let entity = UnifiedEntity::table_row(
1921                            EntityId::new(id),
1922                            "rows",
1923                            id,
1924                            vec![Value::text(payload)],
1925                        );
1926                        StoreWalAction::upsert_entity("rows", &entity, None, STORE_VERSION_V11)
1927                    })
1928                    .collect::<Vec<_>>();
1929                coordinator.append_actions(&actions).unwrap();
1930            }
1931        }
1932        let status = std::process::Command::new(std::env::current_exe().unwrap())
1933            .args(["--exact", "storage::unified::store::commit::tests::paged_wal_replay_survives_immediate_process_exit", "--nocapture"])
1934            .env(CHILD_PATH, &path).status().unwrap();
1935        assert!(status.success(), "replay process failed");
1936        let reopened = UnifiedStore::open_with_config(&path, config())
1937            .expect("cold reopen after replay crash");
1938        assert_eq!(reopened.get_collection("rows").unwrap().count(), 400);
1939        for id in 1..=400 {
1940            let entity = reopened
1941                .get("rows", EntityId::new(id))
1942                .expect("retained entity");
1943            let EntityData::Row(row) = entity.data else {
1944                panic!("expected row")
1945            };
1946            assert_eq!(row.columns, vec![Value::text(replay_crash_payload(id, 2))]);
1947        }
1948        drop(reopened);
1949        let stable_bytes = std::fs::metadata(&path).unwrap().len();
1950        let repeated = UnifiedStore::open_with_config(&path, config()).unwrap();
1951        assert_eq!(repeated.get_collection("rows").unwrap().count(), 400);
1952        assert_eq!(
1953            std::fs::metadata(&path).unwrap().len(),
1954            stable_bytes,
1955            "idempotent replay must not allocate another tree generation"
1956        );
1957    }
1958
1959    #[test]
1960    fn checkpoint_reset_waits_for_an_enqueued_commit_acknowledgement() {
1961        let _env = env_lock().lock().unwrap_or_else(|p| p.into_inner());
1962        let path = temp_wal("checkpoint-ack");
1963        let coordinator = Arc::new(
1964            StoreCommitCoordinator::open(
1965                path.clone(),
1966                DurabilityMode::WalDurableGrouped,
1967                GroupCommitOptions::default(),
1968            )
1969            .unwrap(),
1970        );
1971        // Pause the writer after enqueue, before it can register/wait for its
1972        // acknowledgement. The flusher also needs this state lock to drain.
1973        let state = coordinator.state.0.lock();
1974        let (written_tx, written_rx) = std::sync::mpsc::channel();
1975        let writer_coordinator = Arc::clone(&coordinator);
1976        let writer = std::thread::spawn(move || {
1977            let result = writer_coordinator.append_actions(&[StoreWalAction::CreateCollection {
1978                name: "acknowledged".into(),
1979            }]);
1980            written_tx.send(result).unwrap();
1981        });
1982        let deadline = Instant::now() + Duration::from_secs(2);
1983        while !coordinator.queue.has_pending() {
1984            assert!(Instant::now() < deadline, "writer never enqueued");
1985            std::thread::yield_now();
1986        }
1987        let (started_tx, started_rx) = std::sync::mpsc::channel();
1988        let (reset_tx, reset_rx) = std::sync::mpsc::channel();
1989        let reset_coordinator = Arc::clone(&coordinator);
1990        let reset = std::thread::spawn(move || {
1991            started_tx.send(()).unwrap();
1992            reset_tx.send(reset_coordinator.truncate()).unwrap();
1993        });
1994        started_rx.recv_timeout(Duration::from_secs(2)).unwrap();
1995        let deadline = Instant::now() + Duration::from_millis(100);
1996        while coordinator.queue.has_pending() && Instant::now() < deadline {
1997            std::thread::sleep(Duration::from_millis(1));
1998        }
1999        let discarded_before_ack = !coordinator.queue.has_pending();
2000        drop(state);
2001        let acknowledged = written_rx.recv_timeout(Duration::from_secs(2));
2002        if acknowledged.is_err() {
2003            // Release an old implementation's stranded waiter before failing
2004            // the assertion, so the regression itself cannot leak a worker.
2005            let mut state = coordinator.state.0.lock();
2006            state.shutdown = true;
2007            state.last_error = Some("checkpoint acknowledgement regression cleanup".into());
2008            coordinator.state.1.notify_all();
2009        }
2010        writer.join().unwrap();
2011        reset_rx
2012            .recv_timeout(Duration::from_secs(2))
2013            .unwrap()
2014            .unwrap();
2015        reset.join().unwrap();
2016        assert!(
2017            !discarded_before_ack,
2018            "checkpoint discarded an unacknowledged WAL record"
2019        );
2020        assert!(
2021            acknowledged.unwrap().is_ok(),
2022            "durable writer did not finish"
2023        );
2024        drop(coordinator);
2025        let _ = std::fs::remove_file(path);
2026    }
2027
2028    /// Concurrent autocommits MUST coalesce into far fewer fsyncs
2029    /// than the number of callers when the adaptive group-commit
2030    /// window is active. Without the 200µs floor, every caller
2031    /// would race the drain loop and pay an independent fsync.
2032    ///
2033    /// The test fires 32 concurrent `append_actions` calls and asserts
2034    /// the coordinator issued strictly fewer fsyncs than callers.
2035    /// (We don't pin to "one fsync" because timing on loaded CI hosts
2036    /// can split the burst across a couple of drain cycles — the win
2037    /// is the ratio, not the absolute count.)
2038    #[test]
2039    fn group_commit_coalesces_concurrent_autocommits() {
2040        let _env = env_lock().lock().unwrap_or_else(|p| p.into_inner());
2041        // Make sure no stale env var skews this run — the test
2042        // exercises the default 200µs floor.
2043        std::env::remove_var("REDDB_GROUP_COMMIT_WINDOW_US");
2044
2045        let path = temp_wal("coalesce");
2046        let coord = Arc::new(
2047            StoreCommitCoordinator::open(
2048                path.clone(),
2049                DurabilityMode::WalDurableGrouped,
2050                GroupCommitOptions::default(),
2051            )
2052            .expect("open commit coordinator"),
2053        );
2054
2055        const WRITERS: usize = 32;
2056        let barrier = Arc::new(Barrier::new(WRITERS));
2057        let mut handles = Vec::with_capacity(WRITERS);
2058        for tx in 0..WRITERS {
2059            let coord_c = Arc::clone(&coord);
2060            let barrier_c = Arc::clone(&barrier);
2061            handles.push(std::thread::spawn(move || {
2062                // Synchronise the start so every writer races the
2063                // drain loop together — this is the workload shape
2064                // the adaptive window is supposed to coalesce.
2065                barrier_c.wait();
2066                let action = StoreWalAction::CreateCollection {
2067                    name: format!("c{tx}"),
2068                };
2069                coord_c
2070                    .append_actions(std::slice::from_ref(&action))
2071                    .expect("append_actions");
2072            }));
2073        }
2074        for h in handles {
2075            h.join().expect("writer thread");
2076        }
2077
2078        let fsyncs = coord.fsync_count();
2079        assert!(fsyncs > 0, "expected at least one fsync, got {fsyncs}");
2080        assert!(
2081            fsyncs < WRITERS as u64,
2082            "expected fsyncs ({fsyncs}) to be strictly less than \
2083             concurrent writers ({WRITERS}); coalescing failed"
2084        );
2085
2086        drop(coord);
2087        let _ = std::fs::remove_file(&path);
2088    }
2089
2090    /// Sanity check: with the env override forcing a zero window,
2091    /// fsync coalescing degrades — callers race and we approach
2092    /// one fsync per caller. This proves the window is the actual
2093    /// knob doing the work above (i.e. the test isn't passing for
2094    /// some unrelated reason like buffered-IO coalescing).
2095    #[test]
2096    fn zero_window_disables_coalescing_floor() {
2097        let _env = env_lock().lock().unwrap_or_else(|p| p.into_inner());
2098        std::env::set_var("REDDB_GROUP_COMMIT_WINDOW_US", "0");
2099
2100        let path = temp_wal("zero_window");
2101        let coord = Arc::new(
2102            StoreCommitCoordinator::open(
2103                path.clone(),
2104                DurabilityMode::WalDurableGrouped,
2105                GroupCommitOptions::default(),
2106            )
2107            .expect("open commit coordinator"),
2108        );
2109
2110        const WRITERS: usize = 8;
2111        let barrier = Arc::new(Barrier::new(WRITERS));
2112        let mut handles = Vec::with_capacity(WRITERS);
2113        for tx in 0..WRITERS {
2114            let coord_c = Arc::clone(&coord);
2115            let barrier_c = Arc::clone(&barrier);
2116            handles.push(std::thread::spawn(move || {
2117                barrier_c.wait();
2118                let action = StoreWalAction::CreateCollection {
2119                    name: format!("z{tx}"),
2120                };
2121                coord_c
2122                    .append_actions(std::slice::from_ref(&action))
2123                    .expect("append_actions");
2124            }));
2125        }
2126        for h in handles {
2127            h.join().expect("writer thread");
2128        }
2129
2130        // With zero window, fsync count is bounded above by WRITERS
2131        // (every caller might trigger its own drain) and below by 1
2132        // (the queue may still naturally batch under contention).
2133        // The point of the assertion is to confirm the env override
2134        // is wired through — the open() above used the env knob.
2135        let fsyncs = coord.fsync_count();
2136        assert!(fsyncs >= 1, "expected at least one fsync, got {fsyncs}");
2137
2138        std::env::remove_var("REDDB_GROUP_COMMIT_WINDOW_US");
2139        drop(coord);
2140        let _ = std::fs::remove_file(&path);
2141    }
2142
2143    /// `resolve_window` precedence: env > config.window_ms > default.
2144    #[test]
2145    fn resolve_window_precedence() {
2146        let _env = env_lock().lock().unwrap_or_else(|p| p.into_inner());
2147        // Default: window_ms=0 → adaptive 200µs floor.
2148        std::env::remove_var("REDDB_GROUP_COMMIT_WINDOW_US");
2149        let cfg = GroupCommitOptions::default();
2150        assert_eq!(
2151            StoreCommitCoordinator::resolve_window(&cfg),
2152            Duration::from_micros(DEFAULT_ADAPTIVE_WINDOW_US)
2153        );
2154
2155        // Explicit ms config wins over the default floor.
2156        let cfg_ms = GroupCommitOptions {
2157            window_ms: 5,
2158            ..GroupCommitOptions::default()
2159        };
2160        assert_eq!(
2161            StoreCommitCoordinator::resolve_window(&cfg_ms),
2162            Duration::from_millis(5)
2163        );
2164
2165        // Env override wins over both.
2166        std::env::set_var("REDDB_GROUP_COMMIT_WINDOW_US", "750");
2167        assert_eq!(
2168            StoreCommitCoordinator::resolve_window(&cfg),
2169            Duration::from_micros(750)
2170        );
2171        assert_eq!(
2172            StoreCommitCoordinator::resolve_window(&cfg_ms),
2173            Duration::from_micros(750)
2174        );
2175
2176        // Env=0 explicitly disables the floor.
2177        std::env::set_var("REDDB_GROUP_COMMIT_WINDOW_US", "0");
2178        assert_eq!(
2179            StoreCommitCoordinator::resolve_window(&cfg),
2180            Duration::from_micros(0)
2181        );
2182
2183        std::env::remove_var("REDDB_GROUP_COMMIT_WINDOW_US");
2184    }
2185}