Skip to main content

rushdb_storage/
wal_replay.rs

1//! WAL segment export and replay (CDC / PITR / incremental backup) over the
2//! log-first segmented WAL (decision B: the log stores expanded op-sets).
3
4use super::{wal, Ordering, StorageError, Store};
5use crate::backend::StorageBackend;
6use crate::log_store;
7use std::sync::Arc;
8
9impl Store {
10    /// Open this store's segmented WAL and replay any tail beyond the shard
11    /// materializations into the backends. Returns the log and the highest
12    /// recovered commit index. The record header carries the routing shard and
13    /// LSN, so replay is direct (no expander needed) and idempotent across a
14    /// crash: a record at or below a shard's durable `LAST_APPLIED` is skipped.
15    pub(crate) fn open_log_and_replay(
16        &self,
17        path: &std::path::Path,
18        backends: &[Option<Arc<dyn StorageBackend>>],
19        materialization: &[u64],
20    ) -> Result<(log_store::LogStore, u64), StorageError> {
21        // The WAL lives beside the redb file(s) as `<file>.wal/` so each store
22        // has its own log (mirroring the `<file>.layout.json` convention).
23        let wal_dir = {
24            let mut os = path.as_os_str().to_os_string();
25            os.push(".wal");
26            std::path::PathBuf::from(os)
27        };
28        let mut log =
29            log_store::LogStore::open(&wal_dir, 0, self.resource_config.log_segment_bytes)
30                .map_err(|error| {
31                    StorageError::Durability(format!("open WAL at {}: {error}", wal_dir.display()))
32                })?;
33        // A fresh WAL (no existing segments) beside a materialization that
34        // already has a checkpoint — e.g. a store opened from a restored
35        // base backup with no accompanying WAL directory — must continue
36        // the commit sequence from that checkpoint, not restart at LSN 1
37        // and collide with LSNs the materialization already has. A no-op
38        // when the WAL has real history: see `seed_next_lsn_if_empty`.
39        let highest_checkpoint = materialization.iter().copied().max().unwrap_or(0);
40        log.seed_next_lsn_if_empty(highest_checkpoint.saturating_add(1));
41        let wal_durable = log.durable_lsn();
42        if wal_durable == 0 {
43            return Ok((log, 0));
44        }
45        let from = materialization
46            .iter()
47            .copied()
48            .min()
49            .unwrap_or(0)
50            .saturating_add(1);
51        let records = log
52            .read_from(from)
53            .map_err(|error| StorageError::Durability(format!("read WAL for recovery: {error}")))?;
54        let mut expected = from;
55        // A chained commit (large-transaction support) spans several
56        // consecutive physical records sharing one LSN; `ChainAssembler`
57        // reconstructs the whole commit from them so it is applied exactly
58        // once, atomically, never per-fragment (gap/skip detection below
59        // operates on the reassembled commit's LSN, not each fragment's).
60        let mut assembler = wal::ChainAssembler::default();
61        let deriver = self.deriver();
62        for (framed_lsn, raw) in records {
63            let (frame_header, _) = wal::decode_wal_record(&raw).map_err(|error| {
64                StorageError::Durability(format!("decode WAL record {framed_lsn}: {error}"))
65            })?;
66            if frame_header.lsn != framed_lsn {
67                return Err(StorageError::Durability(format!(
68                    "WAL recovery LSN mismatch: frame {framed_lsn}, header {}",
69                    frame_header.lsn
70                )));
71            }
72            let (header, body) = match assembler.feed(&raw, 0).map_err(|error| {
73                StorageError::Durability(format!(
74                    "reassemble chained WAL record {framed_lsn}: {error}"
75                ))
76            })? {
77                wal::ChainFeed::Pending => continue,
78                wal::ChainFeed::Complete(header, body) => (header, body),
79            };
80            let (_, ops, _command) = wal::decode_logical_body(header, &body).map_err(|error| {
81                StorageError::Durability(format!("decode WAL record {framed_lsn}: {error}"))
82            })?;
83            if header.lsn < expected {
84                continue; // already materialized on this shard
85            }
86            if header.lsn > expected {
87                return Err(StorageError::Durability(format!(
88                    "WAL recovery gap: expected LSN {expected}, found {}",
89                    header.lsn
90                )));
91            }
92            expected = header.lsn.saturating_add(1);
93            let shard = usize::try_from(header.shard_id).map_err(|_| {
94                StorageError::Durability(format!(
95                    "WAL shard {} does not fit usize",
96                    header.shard_id
97                ))
98            })?;
99            let already = materialization.get(shard).copied().unwrap_or(0);
100            if header.lsn <= already {
101                continue;
102            }
103            if let Some(Some(backend)) = backends.get(shard) {
104                if deriver.is_none() {
105                    self.underived_replay.store(true, Ordering::SeqCst);
106                }
107                Self::apply_derived_replay(deriver.as_ref(), backend.as_ref(), &ops, header.lsn)?;
108            }
109        }
110        // `LogStore::open` (above) already truncated away any trailing
111        // incomplete chain from the segment files themselves, so every
112        // record `read_from` can return must belong to a complete chain.
113        // This is a correctness invariant, not a recoverable condition: if
114        // it ever fires, recovery's own atomicity guarantee has a bug.
115        debug_assert!(
116            !assembler.is_open(),
117            "WAL replay ended with an open chain; LogStore::open should have truncated it"
118        );
119        Ok((log, wal_durable))
120    }
121
122    /// Export the committed WAL as a checksummed segment stream (manifest +
123    /// framed op-set records), bounded to the inclusive LSN range
124    /// `[from_lsn, to_lsn]`. This is the CDC / PITR / incremental-backup cursor
125    ///. Records come from the segmented WAL, which is the durable truth.
126    ///
127    /// # Errors
128    /// Returns [`StorageError::Durability`] if the WAL cannot be read or the
129    /// segment cannot be written.
130    ///
131    /// Lock poisoning fences the store until reopen.
132    pub fn export_wal_segments<W: std::io::Write>(
133        &self,
134        writer: &mut W,
135        from_lsn: u64,
136        to_lsn: u64,
137    ) -> Result<u64, StorageError> {
138        self.check_health()?;
139        let _guard = self.health.guard(self.publish_lock.lock())?;
140        let mut records: Vec<(u64, Vec<u8>)> = {
141            let guard = self.health.guard(self.log.lock())?;
142            match guard.as_ref() {
143                Some(log) => log.read_from(from_lsn).map_err(|error| {
144                    StorageError::Durability(format!("read WAL for export: {error}"))
145                })?,
146                None => Vec::new(),
147            }
148        };
149        records.retain(|(lsn, _)| *lsn <= to_lsn);
150        // Fail closed: the segment must cover the requested inclusive range
151        // `[from_lsn, to_lsn]` exactly, with no gaps. A missing record means
152        // retention pruned it or the range is beyond the committed log;
153        // certifying the export as complete would let a restore silently skip
154        // commits. `from_lsn > to_lsn` is an empty range and needs no coverage.
155        //
156        // A chained commit (large-transaction support) contributes
157        // several consecutive physical records sharing one LSN, so coverage
158        // and contiguity are checked over the *distinct* logical LSNs, not
159        // the raw physical record count — `records` is already in physical
160        // (and therefore chain-grouped) order, so consecutive duplicates are
161        // exactly one chain's fragments.
162        if from_lsn <= to_lsn {
163            let mut distinct_lsns: Vec<u64> = Vec::new();
164            for (lsn, _) in &records {
165                if distinct_lsns.last() != Some(lsn) {
166                    distinct_lsns.push(*lsn);
167                }
168            }
169            let expected = to_lsn - from_lsn + 1;
170            let covered = distinct_lsns.len() as u64 == expected
171                && distinct_lsns.first() == Some(&from_lsn)
172                && distinct_lsns.last() == Some(&to_lsn);
173            let contiguous = distinct_lsns.windows(2).all(|pair| pair[1] == pair[0] + 1);
174            if !covered || !contiguous {
175                return Err(StorageError::Durability(format!(
176                    "WAL export range [{from_lsn}, {to_lsn}] is not fully covered \
177                     ({} distinct commits, {} physical records); refusing to certify \
178                     an incomplete segment",
179                    distinct_lsns.len(),
180                    records.len()
181                )));
182            }
183        }
184        let refs: Vec<(u64, &[u8])> = records
185            .iter()
186            .map(|(lsn, payload)| (*lsn, payload.as_slice()))
187            .collect();
188        wal::write_wal_segment(writer, 0, &refs)
189            .map_err(|e| StorageError::Durability(e.to_string()))?;
190        Ok(records.len() as u64)
191    }
192
193    /// Replay a WAL segment stream produced by [`Store::export_wal_segments`]
194    /// onto this store (V2 PITR). The store must already hold the state at the
195    /// segment's base commit (a physical checkpoint, or an equivalent store);
196    /// each decoded op-set is applied directly in LSN order. Returns the final
197    /// commit index.
198    ///
199    /// # Errors
200    /// Returns [`StorageError`] if the segment is truncated/corrupt or an
201    /// op-set cannot be applied.
202    pub fn replay_wal_segments<R: std::io::Read>(
203        &self,
204        reader: &mut R,
205    ) -> Result<u64, StorageError> {
206        self.check_health()?;
207        self.replay_wal_segments_to(reader, None, None)
208    }
209
210    /// [`Store::replay_wal_segments`] bounded to `(from_lsn, to_lsn]`: records
211    /// at or below `from_lsn` are already in the base and are skipped, and
212    /// records past `to_lsn` stop replay. Reads consecutive segments from
213    /// `reader` until clean EOF.
214    ///
215    /// # Errors
216    /// Returns [`StorageError`] if a segment is truncated/corrupt or an op-set
217    /// cannot be applied.
218    pub fn replay_wal_segments_to<R: std::io::Read>(
219        &self,
220        reader: &mut R,
221        from_lsn: Option<u64>,
222        to_lsn: Option<u64>,
223    ) -> Result<u64, StorageError> {
224        self.check_health()?;
225        let mut last = self.committed_version();
226        // Reject an LSN gap — leading or interior. A missing segment must not be
227        // silently compacted by renumbering, which would produce a database that
228        // never existed. The base LSN is the caller's `from_lsn`, or the store's
229        // current commit index when unbounded; the first applied record must be
230        // its immediate successor, so a segment that starts above the base (a
231        // leading gap) fails closed instead of silently dropping commits.
232        let base_lsn = from_lsn.unwrap_or_else(|| self.committed_version());
233        let mut expected = base_lsn.saturating_add(1);
234        let mut applied_lsn = base_lsn;
235        // A chained commit (large-transaction support) contributes
236        // several consecutive records sharing one LSN; reassemble before
237        // applying, exactly as `open_log_and_replay` does, so a chain is
238        // never applied per-fragment.
239        let mut assembler = wal::ChainAssembler::default();
240        'segments: while let Some((_manifest, records)) =
241            wal::read_wal_segment(reader).map_err(|e| StorageError::Durability(e.to_string()))?
242        {
243            for record in records {
244                let (frame_header, _) = wal::decode_wal_record(&record)
245                    .map_err(|e| StorageError::Durability(e.to_string()))?;
246                if from_lsn.is_some_and(|bound| frame_header.lsn <= bound) {
247                    continue; // already present in the base
248                }
249                if to_lsn.is_some_and(|bound| frame_header.lsn > bound) {
250                    break 'segments;
251                }
252                let (header, body) = match assembler
253                    .feed(&record, 0)
254                    .map_err(StorageError::Durability)?
255                {
256                    wal::ChainFeed::Pending => continue,
257                    wal::ChainFeed::Complete(header, body) => (header, body),
258                };
259                let (_, ops, _command) =
260                    wal::decode_logical_body(header, &body).map_err(StorageError::Durability)?;
261                if header.lsn != expected {
262                    return Err(StorageError::Durability(format!(
263                        "WAL replay is missing LSN {expected} before {}",
264                        header.lsn
265                    )));
266                }
267                expected = header.lsn.saturating_add(1);
268                applied_lsn = header.lsn;
269                let shard = usize::try_from(header.shard_id).map_err(|_| {
270                    StorageError::Durability(format!(
271                        "WAL shard {} does not fit usize",
272                        header.shard_id
273                    ))
274                })?;
275                if let Some(backend) = self.backend(shard) {
276                    Self::apply_derived_replay(
277                        self.deriver().as_ref(),
278                        backend.as_ref(),
279                        &ops,
280                        header.lsn,
281                    )?;
282                }
283                last = header.lsn;
284            }
285        }
286        // A bounded replay that stops before `to_lsn` is incomplete; certifying
287        // it would let a restore silently skip the tail of the range.
288        if let Some(bound) = to_lsn {
289            if applied_lsn < bound {
290                return Err(StorageError::Durability(format!(
291                    "WAL replay stopped at LSN {applied_lsn} before reaching to_lsn {bound}"
292                )));
293            }
294        }
295        // Advance the store watermark to the replayed tail so a restored store
296        // serves the recovered commits.
297        if applied_lsn > self.committed_version() {
298            self.next_commit_index
299                .store(applied_lsn.saturating_add(1), Ordering::SeqCst);
300            self.published.store(applied_lsn, Ordering::SeqCst);
301        }
302        Ok(last)
303    }
304
305    /// Apply one replayed WAL record (base ops only) to a backend together
306    /// with the ops the installed deriver derives against the backend's
307    /// current state, exactly as live publication does.
308    fn apply_derived_replay(
309        deriver: Option<&Arc<dyn crate::Deriver>>,
310        backend: &dyn StorageBackend,
311        ops: &[crate::Op],
312        lsn: u64,
313    ) -> Result<(), StorageError> {
314        let all = match deriver {
315            Some(_) => {
316                let before = crate::derive::BackendRead(backend.read_view()?);
317                Self::derive_ops(deriver, lsn, ops, &before)?
318            }
319            None => None,
320        };
321        backend
322            .apply(all.as_deref().unwrap_or(ops), lsn)
323            .map_err(StorageError::Durability)
324    }
325}