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}