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