Skip to main content

reddb_server/storage/unified/store/
commit.rs

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