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