Skip to main content

zeppelin_embed/wal/
mod.rs

1//! Write-ahead logging, checked recovery, and self-tuning group commit.
2
3use std::collections::VecDeque;
4use std::io::IoSlice;
5use std::ops::Range;
6use std::path::{Path, PathBuf};
7use std::sync::Arc;
8use std::sync::atomic::{AtomicU64, Ordering};
9use std::sync::{Condvar, Mutex, MutexGuard};
10
11use crate::format::RegistryError;
12use crate::lifecycle::durability::{DurabilityPolicy, SyncRequirement};
13use crate::manifest::io::DurableLog;
14use crate::vfs::{SyncKind, Vfs, VfsFile};
15
16pub mod header;
17pub mod record;
18pub mod replay;
19
20use header::encode_header;
21use record::{
22    RecordEncodeError, RecordError, WalRecord, append_record_into, encode_record_into,
23    encoded_record_len,
24};
25use replay::{ReplayTerminator, replay};
26
27/// Default upper bound for one group append, in encoded bytes.
28pub const DEFAULT_MAX_GROUP_BYTES: usize = 1_048_576;
29/// Default upper bound for a full-sync group append, in encoded bytes.
30///
31/// Fixed sync cost dominates, while the memory cost is one bounded buffer per
32/// writer. Sixteen MiB follows the InnoDB, PostgreSQL, and Lucene convention.
33/// Sync cost above one MiB is modeled, not yet measured; the plan 02 sweep is
34/// pending.
35pub const DEFAULT_MAX_GROUP_BYTES_DURABLE: usize = 16 * 1_048_576;
36
37/// Monotonic write-ahead-log sequence number.
38#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
39pub struct LogSeq(u64);
40
41impl LogSeq {
42    /// Creates a sequence number from its persisted integer.
43    #[must_use]
44    pub const fn new(value: u64) -> Self {
45        Self(value)
46    }
47
48    /// Returns the persisted integer.
49    #[must_use]
50    pub const fn get(self) -> u64 {
51        self.0
52    }
53}
54
55/// Number of fixed group-size histogram buckets.
56pub const GROUP_SIZE_HISTOGRAM_BUCKETS: usize = 18;
57/// Maximum number of completed groups retained exactly for diagnostics/tests.
58pub const RECENT_GROUP_LIMIT: usize = 64;
59
60/// One record visible from in-memory state before its group reaches storage.
61#[derive(Clone, Debug, Eq, PartialEq)]
62pub struct VisibleRecord {
63    /// Assigned monotonic sequence.
64    pub seq: LogSeq,
65    /// Caller-defined operation identifier.
66    pub op: u16,
67    encoded: Arc<Vec<u8>>,
68    encoded_range: Range<usize>,
69}
70
71impl VisibleRecord {
72    fn writer_owned(
73        seq: LogSeq,
74        op: u16,
75        encoded: Arc<Vec<u8>>,
76        encoded_range: Range<usize>,
77    ) -> Self {
78        Self {
79            seq,
80            op,
81            encoded,
82            encoded_range,
83        }
84    }
85
86    fn recovered(seq: LogSeq, op: u16, encoded: Arc<Vec<u8>>, encoded_range: Range<usize>) -> Self {
87        Self {
88            seq,
89            op,
90            encoded,
91            encoded_range,
92        }
93    }
94
95    /// Returns the checked payload borrowed from the encoded WAL allocation.
96    pub fn payload(&self) -> Result<&[u8], VisibleRecordError> {
97        let encoded = self.encoded.get(self.encoded_range.clone()).ok_or(
98            VisibleRecordError::InvalidEncodedRange {
99                start: self.encoded_range.start,
100                end: self.encoded_range.end,
101                available: self.encoded.len(),
102            },
103        )?;
104        decode_visible(encoded).map_err(VisibleRecordError::Record)
105    }
106
107    /// Returns the complete encoded record bytes, exactly as appended.
108    pub(crate) fn encoded(&self) -> Result<&[u8], VisibleRecordError> {
109        self.encoded.get(self.encoded_range.clone()).ok_or(
110            VisibleRecordError::InvalidEncodedRange {
111                start: self.encoded_range.start,
112                end: self.encoded_range.end,
113                available: self.encoded.len(),
114            },
115        )
116    }
117
118    /// Returns the complete encoded width retained for this record.
119    #[must_use]
120    pub fn encoded_len(&self) -> usize {
121        self.encoded_range
122            .end
123            .saturating_sub(self.encoded_range.start)
124    }
125}
126
127fn decode_visible(encoded: &[u8]) -> Result<&[u8], RecordError> {
128    record::decode_record(encoded).map(|decoded| decoded.record.payload)
129}
130
131/// One exact completed group retained in the bounded recent-group ring.
132#[derive(Clone, Copy, Debug, Eq, PartialEq)]
133pub struct CompletedGroup {
134    /// Number of records in the group.
135    pub records: usize,
136    /// Complete encoded bytes appended for the group.
137    pub encoded_bytes: usize,
138}
139
140/// Fixed-size logarithmic distribution of completed group record counts.
141#[derive(Clone, Copy, Debug, Eq, PartialEq)]
142pub struct GroupSizeHistogram {
143    /// Counts for `1`, `2..=3`, `4..=7`, through a final overflow bucket.
144    pub buckets: [u64; GROUP_SIZE_HISTOGRAM_BUCKETS],
145}
146
147impl GroupSizeHistogram {
148    const fn new() -> Self {
149        Self {
150            buckets: [0; GROUP_SIZE_HISTOGRAM_BUCKETS],
151        }
152    }
153
154    fn record(&mut self, records: usize) {
155        let bucket = if records == 0 {
156            0
157        } else {
158            (usize::BITS
159                .saturating_sub(records.leading_zeros())
160                .saturating_sub(1) as usize)
161                .min(GROUP_SIZE_HISTOGRAM_BUCKETS.saturating_sub(1))
162        };
163        if let Some(count) = self.buckets.get_mut(bucket) {
164            *count = count.saturating_add(1);
165        }
166    }
167}
168
169/// Deterministic group-composition and progress snapshot.
170#[derive(Clone, Debug, Eq, PartialEq)]
171pub struct WalWriterStats {
172    /// Total append groups whose flush returned.
173    pub completed_groups: u64,
174    /// Total records across completed append groups.
175    pub completed_records: u64,
176    /// Total encoded bytes across completed append groups.
177    pub completed_bytes: u64,
178    /// Fixed-size logarithmic group-size distribution.
179    pub group_size_histogram: GroupSizeHistogram,
180    /// Exact most-recent groups, oldest to newest, capped by [`RECENT_GROUP_LIMIT`].
181    pub recent_groups: Vec<CompletedGroup>,
182    /// Records waiting behind the currently in-flight group.
183    pub pending_records: usize,
184    /// Largest sequence visible in memory.
185    pub visible_end: Option<LogSeq>,
186    /// Largest sequence whose tier-specific flush returned.
187    pub durable_end: Option<LogSeq>,
188    /// Records still retained for in-memory visibility.
189    pub retained_records: usize,
190    /// Encoded bytes still retained for in-memory visibility.
191    pub retained_bytes: usize,
192    /// Shared encoded record buffers created by successful staging calls.
193    pub encoded_buffer_allocations: u64,
194}
195
196#[derive(Clone, Debug)]
197struct Failure {
198    kind: std::io::ErrorKind,
199    detail: Arc<str>,
200}
201
202#[derive(Debug)]
203struct WriterState {
204    next_seq: u64,
205    visible: VecDeque<VisibleRecord>,
206    retained_bytes: usize,
207    durable_end: Option<LogSeq>,
208    pending_groups: VecDeque<Group>,
209    spare_chunks: Vec<EncodedChunk>,
210    barrier_in_flight: bool,
211    failure: Option<Arc<Failure>>,
212    completed_groups: u64,
213    completed_records: u64,
214    completed_bytes: u64,
215    encoded_buffer_allocations: u64,
216    group_size_histogram: GroupSizeHistogram,
217    recent_groups: VecDeque<CompletedGroup>,
218}
219
220#[derive(Debug)]
221struct EncodedChunk {
222    encoded: Arc<Vec<u8>>,
223    range: Range<usize>,
224}
225
226impl EncodedChunk {
227    fn bytes(&self) -> std::io::Result<&[u8]> {
228        self.encoded
229            .get(self.range.clone())
230            .ok_or_else(|| std::io::Error::other("invalid WAL pending chunk range"))
231    }
232}
233
234#[derive(Debug)]
235struct Group {
236    chunks: Vec<EncodedChunk>,
237    encoded_bytes: usize,
238    records: usize,
239    last_seq: Option<LogSeq>,
240}
241
242/// Single-store WAL writer using caller threads as leader and followers.
243///
244/// There is no timer and no background flusher. An idle arrival immediately
245/// becomes leader. Arrivals observed while its barrier is in flight form the
246/// next byte-bounded group, which that leader issues immediately afterward.
247pub struct WalWriter {
248    file: Mutex<Box<dyn VfsFile>>,
249    created_directory_sync: Mutex<Option<CreatedDirectorySync>>,
250    state: Mutex<WriterState>,
251    changed: Condvar,
252    sync: SyncRequirement,
253    max_group_bytes: usize,
254    durable_progress: AtomicU64,
255}
256
257struct CreatedDirectorySync {
258    vfs: Arc<dyn Vfs>,
259    directory: PathBuf,
260    kind: SyncKind,
261}
262
263impl WalWriter {
264    /// Creates a writer over a new or empty WAL path.
265    ///
266    /// A resolved full-sync data-file requirement uses the 16 MiB durable group
267    /// bound. Barrier and skip requirements retain the 1 MiB default.
268    pub fn create(
269        vfs: &dyn Vfs,
270        path: &Path,
271        first_seq: LogSeq,
272        policy: DurabilityPolicy,
273    ) -> Result<Self, WalWriteError> {
274        let max_group_bytes = default_max_group_bytes(policy);
275        Self::create_with_max_group_bytes(vfs, path, first_seq, policy, max_group_bytes)
276    }
277
278    pub(crate) fn resume(
279        vfs: &dyn Vfs,
280        path: &Path,
281        recovered: CleanWalReader,
282        policy: DurabilityPolicy,
283    ) -> Result<Self, WalWriteError> {
284        let max_group_bytes = default_max_group_bytes(policy);
285        let next_seq = recovered.next_seq;
286        let durable_end = recovered.durable_end;
287        let durable_progress = durable_end.map_or(0, LogSeq::get);
288        let visible = VecDeque::from(recovered.records);
289        let retained_bytes = visible.iter().try_fold(0_usize, |total, record| {
290            total
291                .checked_add(record.encoded_len())
292                .ok_or(WalWriteError::RecoveredBytesOverflow)
293        })?;
294        let file = vfs.open_append(path).map_err(WalWriteError::from_io)?;
295        Ok(Self {
296            file: Mutex::new(file),
297            created_directory_sync: Mutex::new(None),
298            state: Mutex::new(WriterState {
299                next_seq,
300                visible,
301                retained_bytes,
302                durable_end,
303                pending_groups: VecDeque::with_capacity(2),
304                spare_chunks: Vec::with_capacity(16),
305                barrier_in_flight: false,
306                failure: None,
307                completed_groups: 0,
308                completed_records: 0,
309                completed_bytes: 0,
310                encoded_buffer_allocations: 0,
311                group_size_histogram: GroupSizeHistogram::new(),
312                recent_groups: VecDeque::with_capacity(RECENT_GROUP_LIMIT),
313            }),
314            changed: Condvar::new(),
315            sync: policy.data_file_sync(),
316            max_group_bytes,
317            durable_progress: AtomicU64::new(durable_progress),
318        })
319    }
320
321    /// Creates a writer with an explicit encoded-byte group bound.
322    pub fn create_with_max_group_bytes(
323        vfs: &dyn Vfs,
324        path: &Path,
325        first_seq: LogSeq,
326        policy: DurabilityPolicy,
327        max_group_bytes: usize,
328    ) -> Result<Self, WalWriteError> {
329        match vfs.open(path) {
330            Ok(0) => {}
331            Ok(length) => return Err(WalWriteError::NonEmptyWal { length }),
332            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
333            Err(error) => return Err(WalWriteError::from_io(error)),
334        }
335        let header = encode_header(first_seq).map_err(WalWriteError::Header)?;
336        if max_group_bytes < header.len() {
337            return Err(WalWriteError::GroupTooLarge {
338                encoded_bytes: header.len(),
339                max_group_bytes,
340            });
341        }
342        let file = vfs.open_append(path).map_err(WalWriteError::from_io)?;
343        let header_len = header.len();
344        let header = Arc::new(header);
345        let mut header_chunks = Vec::with_capacity(16);
346        header_chunks.push(EncodedChunk {
347            range: 0..header_len,
348            encoded: header,
349        });
350        let mut pending_groups = VecDeque::with_capacity(2);
351        pending_groups.push_back(Group {
352            chunks: header_chunks,
353            encoded_bytes: header_len,
354            records: 0,
355            last_seq: None,
356        });
357        let spare_chunks = Vec::with_capacity(16);
358        let recent_groups = VecDeque::with_capacity(RECENT_GROUP_LIMIT);
359        Ok(Self {
360            file: Mutex::new(file),
361            created_directory_sync: Mutex::new(None),
362            state: Mutex::new(WriterState {
363                next_seq: first_seq.get(),
364                visible: VecDeque::new(),
365                retained_bytes: 0,
366                durable_end: None,
367                pending_groups,
368                spare_chunks,
369                barrier_in_flight: false,
370                failure: None,
371                completed_groups: 0,
372                completed_records: 0,
373                completed_bytes: 0,
374                encoded_buffer_allocations: 0,
375                group_size_histogram: GroupSizeHistogram::new(),
376                recent_groups,
377            }),
378            changed: Condvar::new(),
379            sync: policy.data_file_sync(),
380            max_group_bytes,
381            durable_progress: AtomicU64::new(first_seq.get().saturating_sub(1)),
382        })
383    }
384
385    pub(crate) fn create_store_wal(
386        vfs: Arc<dyn Vfs>,
387        directory: &Path,
388        path: &Path,
389        first_seq: LogSeq,
390        policy: DurabilityPolicy,
391    ) -> Result<Self, WalWriteError> {
392        let mut writer = Self::create(vfs.as_ref(), path, first_seq, policy)?;
393        if let SyncRequirement::Sync(kind) = policy.directory_sync() {
394            writer.created_directory_sync = Mutex::new(Some(CreatedDirectorySync {
395                vfs,
396                directory: directory.to_path_buf(),
397                kind,
398            }));
399        }
400        Ok(writer)
401    }
402
403    /// Makes one record visible and returns without waiting when another
404    /// caller already owns the in-flight barrier.
405    pub fn commit(&self, op: u16, payload: &[u8]) -> Result<LogSeq, WalWriteError> {
406        let (seq, leader) = self.stage(op, payload)?;
407        if leader {
408            self.flush_as_leader()?;
409        }
410        Ok(seq)
411    }
412
413    /// Makes one record visible, then waits until its tier-specific flush returns.
414    pub fn commit_durable(&self, op: u16, payload: &[u8]) -> Result<LogSeq, WalWriteError> {
415        let (seq, leader) = self.stage(op, payload)?;
416        if leader {
417            self.flush_as_leader()?;
418        }
419        self.wait_until_durable(seq)?;
420        Ok(seq)
421    }
422
423    /// Makes every record in one caller batch visible in input order.
424    ///
425    /// Returns after every record is visible. As with [`Self::commit`], an
426    /// idle caller that becomes leader runs the pending flushes inline before
427    /// returning. An empty batch returns the empty range at the next sequence
428    /// and performs no I/O.
429    ///
430    /// The returned half-open range contains exactly the assigned sequences.
431    /// If staging fails, the batch consumes no sequence and publishes no
432    /// visibility. After staging succeeds, an append or flush failure follows
433    /// the existing poisoned-writer path: the staged records remain visible,
434    /// this call returns [`WalWriteError::Failed`], and the writer rejects
435    /// subsequent work.
436    pub fn commit_many(&self, records: &[(u16, &[u8])]) -> Result<Range<LogSeq>, WalWriteError> {
437        let (range, leader) = self.stage_many(records)?;
438        if leader {
439            self.flush_as_leader()?;
440        }
441        Ok(range)
442    }
443
444    /// Makes every record in one caller batch visible in input order, then
445    /// waits until the tier-specific flush covering the final record returns.
446    ///
447    /// An empty batch returns the empty range at the next sequence and performs
448    /// no I/O.
449    ///
450    /// The returned half-open range contains exactly the assigned sequences.
451    /// If staging fails, the batch consumes no sequence and publishes no
452    /// visibility. After staging succeeds, an append or flush failure follows
453    /// the existing poisoned-writer path: the staged records remain visible,
454    /// this call returns [`WalWriteError::Failed`], and the writer rejects
455    /// subsequent work.
456    pub fn commit_many_durable(
457        &self,
458        records: &[(u16, &[u8])],
459    ) -> Result<Range<LogSeq>, WalWriteError> {
460        let (range, leader) = self.stage_many(records)?;
461        if leader {
462            self.flush_as_leader()?;
463        }
464        if range.start != range.end {
465            let last = LogSeq::new(range.end.get().saturating_sub(1));
466            self.wait_until_durable(last)?;
467        }
468        Ok(range)
469    }
470
471    fn stage_many(&self, records: &[(u16, &[u8])]) -> Result<(Range<LogSeq>, bool), WalWriteError> {
472        self.stage_many_with_encoder(records, append_record_into)
473    }
474
475    fn stage_many_with_encoder<F>(
476        &self,
477        records: &[(u16, &[u8])],
478        mut encode: F,
479    ) -> Result<(Range<LogSeq>, bool), WalWriteError>
480    where
481        F: for<'a> FnMut(WalRecord<'a>, &mut Vec<u8>) -> Result<(), RecordEncodeError>,
482    {
483        if records.is_empty() {
484            let state = self.lock_state()?;
485            if let Some(failure) = &state.failure {
486                return Err(WalWriteError::failed(failure));
487            }
488            let next = LogSeq::new(state.next_seq);
489            return Ok((next..next, false));
490        }
491        let encoded_lengths = records
492            .iter()
493            .map(|&(op, payload)| {
494                let encoded_len =
495                    encoded_record_len(payload.len()).map_err(WalWriteError::Record)?;
496                Ok((op, payload, encoded_len))
497            })
498            .collect::<Result<Vec<_>, WalWriteError>>()?;
499        let batch_bytes = encoded_lengths
500            .iter()
501            .fold(0_usize, |total, record| total.saturating_add(record.2));
502        if let Some((_, _, encoded_bytes)) = encoded_lengths
503            .iter()
504            .find(|(_, _, encoded_bytes)| *encoded_bytes > self.max_group_bytes)
505        {
506            return Err(WalWriteError::GroupTooLarge {
507                encoded_bytes: *encoded_bytes,
508                max_group_bytes: self.max_group_bytes,
509            });
510        }
511        let mut encoded = Arc::new(Vec::with_capacity(batch_bytes));
512        let mut state = self.lock_state()?;
513        if let Some(failure) = &state.failure {
514            return Err(WalWriteError::failed(failure));
515        }
516        Self::ensure_pending_group(&mut state);
517        let first_encoded_len = encoded_lengths
518            .first()
519            .map(|(_, _, encoded_len)| *encoded_len)
520            .ok_or(WalWriteError::Poisoned("non-empty WAL batch"))?;
521        let first_required = state
522            .pending_groups
523            .back()
524            .map(|group| group.encoded_bytes.saturating_add(first_encoded_len))
525            .ok_or(WalWriteError::Poisoned("pending WAL group"))?;
526        if state
527            .pending_groups
528            .back()
529            .is_some_and(|group| group.records == 0 && first_required > self.max_group_bytes)
530        {
531            return Err(WalWriteError::GroupTooLarge {
532                encoded_bytes: first_required,
533                max_group_bytes: self.max_group_bytes,
534            });
535        }
536        let count = u64::try_from(records.len()).map_err(|_| WalWriteError::SequenceExhausted)?;
537        let start = state.next_seq;
538        let end = start
539            .checked_add(count)
540            .ok_or(WalWriteError::SequenceExhausted)?;
541        let mut seq = start;
542        let storage = Arc::get_mut(&mut encoded)
543            .ok_or(WalWriteError::Poisoned("unique preallocated WAL batch"))?;
544        for (op, payload, _) in &encoded_lengths {
545            encode(
546                WalRecord {
547                    seq: LogSeq::new(seq),
548                    op: *op,
549                    payload,
550                },
551                storage,
552            )
553            .map_err(WalWriteError::Record)?;
554            seq = seq.saturating_add(1);
555        }
556
557        seq = start;
558        let mut offset = 0_usize;
559        let mut chunk_start = 0_usize;
560        for (op, _, encoded_len) in encoded_lengths {
561            let record_seq = LogSeq::new(seq);
562            let record_start = offset;
563            let record_end = offset.saturating_add(encoded_len);
564            while offset < record_end {
565                let available = self
566                    .max_group_bytes
567                    .saturating_sub(Self::current_group_ref(&state)?.encoded_bytes);
568                if available == 0 {
569                    if chunk_start < offset {
570                        let group = Self::current_group(&mut state)?;
571                        group.chunks.push(EncodedChunk {
572                            encoded: Arc::clone(&encoded),
573                            range: chunk_start..offset,
574                        });
575                    }
576                    Self::push_empty_pending_group(&mut state);
577                    chunk_start = offset;
578                    continue;
579                }
580                let written = available.min(record_end.saturating_sub(offset));
581                let group = Self::current_group(&mut state)?;
582                group.encoded_bytes = group.encoded_bytes.saturating_add(written);
583                offset = offset.saturating_add(written);
584            }
585            let group = Self::current_group(&mut state)?;
586            group.records = group.records.saturating_add(1);
587            group.last_seq = Some(record_seq);
588            state.retained_bytes = state.retained_bytes.saturating_add(encoded_len);
589            state.visible.push_back(VisibleRecord::writer_owned(
590                record_seq,
591                op,
592                Arc::clone(&encoded),
593                record_start..record_end,
594            ));
595            seq = seq.saturating_add(1);
596        }
597        let group = Self::current_group(&mut state)?;
598        group.chunks.push(EncodedChunk {
599            encoded,
600            range: chunk_start..offset,
601        });
602        state.next_seq = end;
603        state.encoded_buffer_allocations = state.encoded_buffer_allocations.saturating_add(1);
604        let leader = !state.barrier_in_flight;
605        if leader {
606            state.barrier_in_flight = true;
607        }
608        Ok((LogSeq::new(start)..LogSeq::new(end), leader))
609    }
610
611    #[inline(always)]
612    fn current_group(state: &mut WriterState) -> Result<&mut Group, WalWriteError> {
613        state
614            .pending_groups
615            .back_mut()
616            .ok_or(WalWriteError::Poisoned("pending WAL group"))
617    }
618
619    #[inline(always)]
620    fn current_group_ref(state: &WriterState) -> Result<&Group, WalWriteError> {
621        state
622            .pending_groups
623            .back()
624            .ok_or(WalWriteError::Poisoned("pending WAL group"))
625    }
626
627    fn ensure_pending_group(state: &mut WriterState) {
628        if state.pending_groups.is_empty() {
629            Self::push_empty_pending_group(state);
630        }
631    }
632
633    fn push_empty_pending_group(state: &mut WriterState) {
634        let chunks = std::mem::take(&mut state.spare_chunks);
635        state.pending_groups.push_back(Group {
636            chunks,
637            encoded_bytes: 0,
638            records: 0,
639            last_seq: None,
640        });
641    }
642
643    /// Permanently fails this writer after its file was replaced underneath it,
644    /// so every later commit reports `error` instead of appending to a file
645    /// that recovery will never read.
646    pub(crate) fn poison(&self, error: &std::io::Error) -> Result<(), WalWriteError> {
647        let mut state = self.lock_state()?;
648        state.failure.get_or_insert_with(|| {
649            Arc::new(Failure {
650                kind: error.kind(),
651                detail: Arc::from(error.to_string()),
652            })
653        });
654        self.changed.notify_all();
655        Ok(())
656    }
657
658    /// Waits until every record visible when this call observes the writer has flushed.
659    ///
660    /// No record is appended solely to force progress. If pending records have
661    /// no current leader, this caller assumes leadership and drains them.
662    pub fn flush(&self) -> Result<(), WalWriteError> {
663        let (target, leader) = {
664            let mut state = self.lock_state()?;
665            if let Some(failure) = &state.failure {
666                return Err(WalWriteError::failed(failure));
667            }
668            let Some(target) = state.visible.back().map(|record| record.seq) else {
669                return Ok(());
670            };
671            if state
672                .durable_end
673                .is_some_and(|durable_end| durable_end >= target)
674            {
675                return Ok(());
676            }
677            let leader = if state.barrier_in_flight {
678                false
679            } else if state.pending_groups.iter().any(|group| group.records != 0) {
680                state.barrier_in_flight = true;
681                true
682            } else {
683                return Err(WalWriteError::Poisoned(
684                    "non-durable WAL record has neither a pending group nor a leader",
685                ));
686            };
687            (target, leader)
688        };
689        if leader {
690            self.flush_as_leader()?;
691        }
692        self.wait_until_durable(target)
693    }
694
695    /// Returns at most `limit` retained records at or after `first_seq`.
696    ///
697    /// Clones share the encoded WAL allocations. The explicit bound prevents
698    /// test-support callers from copying metadata for the writer's history.
699    pub fn visible_records(
700        &self,
701        first_seq: LogSeq,
702        limit: usize,
703    ) -> Result<Vec<VisibleRecord>, WalWriteError> {
704        let mut visible = Vec::with_capacity(limit);
705        let state = self.lock_state()?;
706        visible.extend(
707            state
708                .visible
709                .iter()
710                .filter(|record| record.seq >= first_seq)
711                .take(limit)
712                .cloned(),
713        );
714        Ok(visible)
715    }
716
717    /// Releases retained visibility records through an absorbed durable sequence.
718    ///
719    /// The ingest owner calls this only after incorporating the records into
720    /// its own in-memory state. Requests beyond the tier-specific durable end
721    /// fail explicitly and never clamp.
722    pub fn retire_visible_through(
723        &self,
724        absorbed_through: LogSeq,
725    ) -> Result<WalRetirement, WalRetireError> {
726        let capacity = {
727            let state = self.lock_state().map_err(WalRetireError::Writer)?;
728            if state
729                .durable_end
730                .is_none_or(|durable_end| absorbed_through > durable_end)
731            {
732                return Err(WalRetireError::BeyondDurable {
733                    requested: absorbed_through,
734                    durable_end: state.durable_end,
735                });
736            }
737            state
738                .visible
739                .iter()
740                .take_while(|record| record.seq <= absorbed_through)
741                .count()
742        };
743        let mut retired = Vec::with_capacity(capacity);
744        let retirement = {
745            let mut state = self.lock_state().map_err(WalRetireError::Writer)?;
746            if state
747                .durable_end
748                .is_none_or(|durable_end| absorbed_through > durable_end)
749            {
750                return Err(WalRetireError::BeyondDurable {
751                    requested: absorbed_through,
752                    durable_end: state.durable_end,
753                });
754            }
755            while state
756                .visible
757                .front()
758                .is_some_and(|record| record.seq <= absorbed_through)
759            {
760                let Some(record) = state.visible.pop_front() else {
761                    break;
762                };
763                state.retained_bytes = state.retained_bytes.saturating_sub(record.encoded_len());
764                retired.push(record);
765            }
766            WalRetirement {
767                absorbed_through,
768                records_released: retired.len(),
769                encoded_bytes_released: retired.iter().fold(0_usize, |total, record| {
770                    total.saturating_add(record.encoded_len())
771                }),
772                retained_records: state.visible.len(),
773                retained_bytes: state.retained_bytes,
774                durable_end: state.durable_end,
775            }
776        };
777        drop(retired);
778        Ok(retirement)
779    }
780
781    /// Returns deterministic progress and completed-group statistics.
782    pub fn stats(&self) -> Result<WalWriterStats, WalWriteError> {
783        let mut recent_groups = Vec::with_capacity(RECENT_GROUP_LIMIT);
784        let state = self.lock_state()?;
785        recent_groups.extend(state.recent_groups.iter().copied());
786        let pending_records = state
787            .pending_groups
788            .iter()
789            .fold(0_usize, |total, group| total.saturating_add(group.records));
790        Ok(WalWriterStats {
791            completed_groups: state.completed_groups,
792            completed_records: state.completed_records,
793            completed_bytes: state.completed_bytes,
794            group_size_histogram: state.group_size_histogram,
795            recent_groups,
796            pending_records,
797            visible_end: state.visible.back().map(|record| record.seq),
798            durable_end: state.durable_end,
799            retained_records: state.visible.len(),
800            retained_bytes: state.retained_bytes,
801            encoded_buffer_allocations: state.encoded_buffer_allocations,
802        })
803    }
804
805    fn stage(&self, op: u16, payload: &[u8]) -> Result<(LogSeq, bool), WalWriteError> {
806        let encoded_len = encoded_record_len(payload.len()).map_err(WalWriteError::Record)?;
807        let mut encoded = Arc::new(Vec::with_capacity(encoded_len));
808        let mut state = self.lock_state()?;
809        loop {
810            if let Some(failure) = &state.failure {
811                return Err(WalWriteError::failed(failure));
812            }
813            Self::ensure_pending_group(&mut state);
814            let seq = LogSeq::new(state.next_seq);
815            let (required, pending_records) = state
816                .pending_groups
817                .back()
818                .map(|group| {
819                    (
820                        group.encoded_bytes.saturating_add(encoded_len),
821                        group.records,
822                    )
823                })
824                .ok_or(WalWriteError::Poisoned("pending WAL group"))?;
825            if required <= self.max_group_bytes {
826                let next_seq = state
827                    .next_seq
828                    .checked_add(1)
829                    .ok_or(WalWriteError::SequenceExhausted)?;
830                let storage = Arc::get_mut(&mut encoded)
831                    .ok_or(WalWriteError::Poisoned("unique preallocated WAL record"))?;
832                encode_record_into(WalRecord { seq, op, payload }, storage)
833                    .map_err(WalWriteError::Record)?;
834                state.next_seq = next_seq;
835                state.encoded_buffer_allocations =
836                    state.encoded_buffer_allocations.saturating_add(1);
837                let group = state
838                    .pending_groups
839                    .back_mut()
840                    .ok_or(WalWriteError::Poisoned("pending WAL group"))?;
841                group.chunks.push(EncodedChunk {
842                    encoded: Arc::clone(&encoded),
843                    range: 0..encoded_len,
844                });
845                group.encoded_bytes = required;
846                group.records = group.records.saturating_add(1);
847                group.last_seq = Some(seq);
848                state.retained_bytes = state.retained_bytes.saturating_add(encoded_len);
849                state.visible.push_back(VisibleRecord::writer_owned(
850                    seq,
851                    op,
852                    encoded,
853                    0..encoded_len,
854                ));
855                let leader = !state.barrier_in_flight;
856                if leader {
857                    state.barrier_in_flight = true;
858                }
859                return Ok((seq, leader));
860            }
861            if pending_records == 0 {
862                return Err(WalWriteError::GroupTooLarge {
863                    encoded_bytes: required,
864                    max_group_bytes: self.max_group_bytes,
865                });
866            }
867            state = self
868                .changed
869                .wait(state)
870                .map_err(|_| WalWriteError::Poisoned("WAL state mutex"))?;
871        }
872    }
873
874    fn flush_as_leader(&self) -> Result<(), WalWriteError> {
875        loop {
876            let (group, last_seq) = {
877                let mut state = self.lock_state()?;
878                let group = state
879                    .pending_groups
880                    .pop_front()
881                    .ok_or(WalWriteError::Poisoned("WAL leader found no pending group"))?;
882                let last_seq = group.last_seq.ok_or(WalWriteError::Poisoned(
883                    "WAL leader found no pending sequence",
884                ))?;
885                self.changed.notify_all();
886                (group, last_seq)
887            };
888            let failure = self.write_group(&group).err().map(|error| {
889                Arc::new(Failure {
890                    kind: error.kind(),
891                    detail: Arc::from(error.to_string()),
892                })
893            });
894            let completed = CompletedGroup {
895                records: group.records,
896                encoded_bytes: group.encoded_bytes,
897            };
898            let mut reusable_chunks = group.chunks;
899            reusable_chunks.clear();
900            let mut state = self.lock_state()?;
901            state.spare_chunks = reusable_chunks;
902            match failure {
903                None => {
904                    state.durable_end = Some(last_seq);
905                    self.durable_progress
906                        .store(last_seq.get(), Ordering::Release);
907                    state.completed_groups = state.completed_groups.saturating_add(1);
908                    state.completed_records = state
909                        .completed_records
910                        .saturating_add(completed.records as u64);
911                    state.completed_bytes = state
912                        .completed_bytes
913                        .saturating_add(completed.encoded_bytes as u64);
914                    state.group_size_histogram.record(completed.records);
915                    if state.recent_groups.len() == RECENT_GROUP_LIMIT {
916                        state.recent_groups.pop_front();
917                    }
918                    state.recent_groups.push_back(completed);
919                    self.changed.notify_all();
920                    if state.pending_groups.is_empty() {
921                        state.barrier_in_flight = false;
922                        return Ok(());
923                    }
924                }
925                Some(failure) => {
926                    state.failure = Some(Arc::clone(&failure));
927                    state.barrier_in_flight = false;
928                    self.changed.notify_all();
929                    return Err(WalWriteError::failed(&failure));
930                }
931            }
932        }
933    }
934
935    fn write_group(&self, group: &Group) -> std::io::Result<()> {
936        let mut buffers = Vec::with_capacity(group.chunks.len());
937        for chunk in &group.chunks {
938            buffers.push(IoSlice::new(chunk.bytes()?));
939        }
940        let mut file = self
941            .file
942            .lock()
943            .map_err(|_| std::io::Error::other("WAL file mutex poisoned"))?;
944        file.append_vectored(&mut buffers)?;
945        match self.sync {
946            SyncRequirement::Skip => Ok(()),
947            SyncRequirement::Sync(kind) => file.sync(kind),
948        }?;
949        drop(file);
950        let mut created_directory_sync = self
951            .created_directory_sync
952            .lock()
953            .map_err(|_| std::io::Error::other("WAL directory-sync mutex poisoned"))?;
954        if let Some(sync) = created_directory_sync.as_ref() {
955            sync.vfs.sync(&sync.directory, sync.kind)?;
956            created_directory_sync.take();
957        }
958        Ok(())
959    }
960
961    fn wait_until_durable(&self, seq: LogSeq) -> Result<(), WalWriteError> {
962        let mut state = self.lock_state()?;
963        while state
964            .durable_end
965            .is_none_or(|durable_end| durable_end < seq)
966        {
967            if let Some(failure) = &state.failure {
968                return Err(WalWriteError::failed(failure));
969            }
970            state = self
971                .changed
972                .wait(state)
973                .map_err(|_| WalWriteError::Poisoned("WAL state mutex"))?;
974        }
975        Ok(())
976    }
977
978    fn lock_state(&self) -> Result<MutexGuard<'_, WriterState>, WalWriteError> {
979        self.state
980            .lock()
981            .map_err(|_| WalWriteError::Poisoned("WAL state mutex"))
982    }
983}
984
985pub(crate) fn encode_wal_image(
986    first_seq: LogSeq,
987    records: &[(u16, Vec<u8>)],
988) -> Result<Vec<u8>, WalWriteError> {
989    let mut encoded = header::encode_header(first_seq).map_err(WalWriteError::Header)?;
990    let mut sequence = first_seq.get();
991    for (op, payload) in records {
992        record::append_record_into(
993            record::WalRecord {
994                seq: LogSeq::new(sequence),
995                op: *op,
996                payload,
997            },
998            &mut encoded,
999        )
1000        .map_err(WalWriteError::Record)?;
1001        sequence = sequence
1002            .checked_add(1)
1003            .ok_or(WalWriteError::SequenceExhausted)?;
1004    }
1005    Ok(encoded)
1006}
1007
1008fn default_max_group_bytes(policy: DurabilityPolicy) -> usize {
1009    match policy.data_file_sync() {
1010        SyncRequirement::Sync(SyncKind::Full) => DEFAULT_MAX_GROUP_BYTES_DURABLE,
1011        SyncRequirement::Skip | SyncRequirement::Sync(SyncKind::Barrier) => DEFAULT_MAX_GROUP_BYTES,
1012    }
1013}
1014
1015#[cfg(test)]
1016#[allow(clippy::expect_used)]
1017mod tests {
1018    use std::path::Path;
1019
1020    use super::{LogSeq, RecordEncodeError, WalWriteError, WalWriter, append_record_into};
1021    use crate::lifecycle::durability::{CommitTier, DurabilityMode, DurabilityPolicy};
1022    use crate::vfs::CountingVfs;
1023    use crate::vfs::crash::MemoryVfs;
1024
1025    #[test]
1026    fn durable_policy_defaults_to_a_sixteen_mib_group_cap() {
1027        let counting = CountingVfs::new(MemoryVfs::new());
1028        let policy = DurabilityPolicy::new(DurabilityMode::Durable, CommitTier::Durable)
1029            .expect("durable policy");
1030        let writer = WalWriter::create(
1031            &counting,
1032            Path::new("/wal/default-durable-cap.ze"),
1033            LogSeq::new(1),
1034            policy,
1035        )
1036        .expect("writer");
1037        let payload = vec![0x5a; 1_024];
1038        let records = (0..1_024)
1039            .map(|_| (7, payload.as_slice()))
1040            .collect::<Vec<_>>();
1041
1042        writer.commit_many(&records).expect("large durable batch");
1043        let stats = writer.stats().expect("writer stats");
1044
1045        assert_eq!(
1046            (
1047                counting.append_calls(),
1048                counting.handle_full_sync_calls(),
1049                stats.completed_groups,
1050                stats.completed_records,
1051            ),
1052            (1, 1, 1, 1_024),
1053            "a 1,071,144-byte durable batch must share one append and full sync"
1054        );
1055    }
1056
1057    #[test]
1058    fn ordered_and_skip_policies_keep_the_one_mib_default() {
1059        let payload = vec![0x6b; 1_024];
1060        let records = (0..1_024)
1061            .map(|_| (8, payload.as_slice()))
1062            .collect::<Vec<_>>();
1063        let mut actual = Vec::new();
1064
1065        for (tier, path) in [
1066            (CommitTier::Ordered, "/wal/default-ordered-cap.ze"),
1067            (CommitTier::None, "/wal/default-skip-cap.ze"),
1068        ] {
1069            let counting = CountingVfs::new(MemoryVfs::new());
1070            let policy =
1071                DurabilityPolicy::new(DurabilityMode::Durable, tier).expect("supported policy");
1072            let writer = WalWriter::create(&counting, Path::new(path), LogSeq::new(1), policy)
1073                .expect("writer");
1074
1075            writer.commit_many(&records).expect("large batch");
1076            let stats = writer.stats().expect("writer stats");
1077            actual.push((
1078                tier,
1079                counting.append_calls(),
1080                counting.handle_barrier_sync_calls(),
1081                counting.handle_full_sync_calls(),
1082                stats.completed_groups,
1083            ));
1084        }
1085
1086        assert_eq!(
1087            actual,
1088            vec![
1089                (CommitTier::Ordered, 2, 2, 0, 2),
1090                (CommitTier::None, 2, 0, 0, 2),
1091            ],
1092            "ordered and skip policies must split a 1,071,144-byte batch at one MiB"
1093        );
1094    }
1095
1096    #[test]
1097    fn an_explicit_group_cap_still_wins() {
1098        let counting = CountingVfs::new(MemoryVfs::new());
1099        let policy = DurabilityPolicy::new(DurabilityMode::Durable, CommitTier::Durable)
1100            .expect("durable policy");
1101        let writer = WalWriter::create_with_max_group_bytes(
1102            &counting,
1103            Path::new("/wal/explicit-group-cap.ze"),
1104            LogSeq::new(1),
1105            policy,
1106            128,
1107        )
1108        .expect("writer");
1109        writer.commit(0, &[0; 10]).expect("flush WAL header");
1110        let groups_before = writer.stats().expect("prime stats").recent_groups.len();
1111        counting.reset();
1112        let payload = [0x7c; 10];
1113        let records = (0..12).map(|_| (9, payload.as_slice())).collect::<Vec<_>>();
1114
1115        writer.commit_many(&records).expect("explicit-cap batch");
1116        let stats = writer.stats().expect("batch stats");
1117        let groups = stats
1118            .recent_groups
1119            .iter()
1120            .skip(groups_before)
1121            .map(|group| (group.records, group.encoded_bytes))
1122            .collect::<Vec<_>>();
1123
1124        assert_eq!(
1125            (
1126                counting.append_calls(),
1127                counting.handle_full_sync_calls(),
1128                groups,
1129            ),
1130            (3, 3, vec![(4, 128), (4, 128), (4, 128)]),
1131            "an explicit 128-byte cap must override the durable default"
1132        );
1133    }
1134
1135    #[test]
1136    fn encoding_failure_in_middle_of_commit_many_is_atomic() {
1137        let policy = DurabilityPolicy::new(DurabilityMode::Durable, CommitTier::None)
1138            .expect("supported policy");
1139        let writer = WalWriter::create(
1140            &MemoryVfs::new(),
1141            Path::new("/wal/unit-encode-failure.ze"),
1142            LogSeq::new(1),
1143            policy,
1144        )
1145        .expect("writer");
1146        let records = [(51, b"first".as_slice()), (52, b"second".as_slice())];
1147        let mut encode_calls = 0_usize;
1148
1149        let result = writer.stage_many_with_encoder(&records, |record, storage| {
1150            encode_calls = encode_calls.saturating_add(1);
1151            if encode_calls == 2 {
1152                Err(RecordEncodeError::PayloadTooLarge(usize::MAX))
1153            } else {
1154                append_record_into(record, storage)
1155            }
1156        });
1157        let failure = match result {
1158            Err(WalWriteError::Record(error)) => format!("error={error}"),
1159            other => format!("unexpected={other:?}"),
1160        };
1161        let visible = writer
1162            .visible_records(LogSeq::new(1), records.len())
1163            .expect("visibility after failed encoding")
1164            .len();
1165        let next = writer
1166            .commit(53, b"after")
1167            .expect("sequence remains reusable");
1168
1169        assert_eq!(
1170            (failure, visible, next),
1171            (
1172                format!("error=WAL payload length {} exceeds u32", usize::MAX),
1173                0,
1174                LogSeq::new(1),
1175            ),
1176            "a failure encoding any batch record must consume no sequence and publish no visibility"
1177        );
1178    }
1179}
1180
1181impl DurableLog for WalWriter {
1182    fn durable_end(&self) -> u64 {
1183        self.durable_progress.load(Ordering::Acquire)
1184    }
1185}
1186
1187/// Checked access failure for a retained encoded record.
1188#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1189pub enum VisibleRecordError {
1190    /// The retained range does not fit its shared encoded allocation.
1191    InvalidEncodedRange {
1192        /// Inclusive byte offset.
1193        start: usize,
1194        /// Exclusive byte offset.
1195        end: usize,
1196        /// Bytes available in the allocation.
1197        available: usize,
1198    },
1199    /// The retained bytes no longer decode as the record published by the writer.
1200    Record(RecordError),
1201}
1202
1203impl std::fmt::Display for VisibleRecordError {
1204    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1205        match self {
1206            Self::InvalidEncodedRange {
1207                start,
1208                end,
1209                available,
1210            } => write!(
1211                formatter,
1212                "visible WAL encoded range {start}..{end} exceeds {available} bytes"
1213            ),
1214            Self::Record(error) => write!(formatter, "visible WAL record: {error}"),
1215        }
1216    }
1217}
1218
1219impl std::error::Error for VisibleRecordError {
1220    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
1221        match self {
1222            Self::InvalidEncodedRange { .. } => None,
1223            Self::Record(error) => Some(error),
1224        }
1225    }
1226}
1227
1228/// Exact effect of one visibility-retirement request.
1229#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1230pub struct WalRetirement {
1231    /// Caller-declared sequence absorbed into its own in-memory state.
1232    pub absorbed_through: LogSeq,
1233    /// Retained records released by this call.
1234    pub records_released: usize,
1235    /// Encoded record bytes released by this call.
1236    pub encoded_bytes_released: usize,
1237    /// Records that remain retained by the writer.
1238    pub retained_records: usize,
1239    /// Encoded record bytes that remain retained by the writer.
1240    pub retained_bytes: usize,
1241    /// Durable end observed while applying retirement.
1242    pub durable_end: Option<LogSeq>,
1243}
1244
1245/// Visibility retirement failure.
1246#[derive(Debug)]
1247pub enum WalRetireError {
1248    /// The caller asked to retire bytes whose tier-specific flush has not returned.
1249    BeyondDurable {
1250        /// Requested inclusive retirement sequence.
1251        requested: LogSeq,
1252        /// Largest sequence whose flush returned.
1253        durable_end: Option<LogSeq>,
1254    },
1255    /// Writer state could not be inspected safely.
1256    Writer(WalWriteError),
1257}
1258
1259impl std::fmt::Display for WalRetireError {
1260    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1261        match self {
1262            Self::BeyondDurable {
1263                requested,
1264                durable_end: Some(durable_end),
1265            } => write!(
1266                formatter,
1267                "cannot retire WAL sequence {} past durable end {}",
1268                requested.get(),
1269                durable_end.get()
1270            ),
1271            Self::BeyondDurable {
1272                requested,
1273                durable_end: None,
1274            } => write!(
1275                formatter,
1276                "cannot retire WAL sequence {} before any sequence is durable",
1277                requested.get()
1278            ),
1279            Self::Writer(error) => write!(formatter, "cannot retire visible WAL records: {error}"),
1280        }
1281    }
1282}
1283
1284impl std::error::Error for WalRetireError {
1285    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
1286        match self {
1287            Self::BeyondDurable { .. } => None,
1288            Self::Writer(error) => Some(error),
1289        }
1290    }
1291}
1292
1293/// Owned checked WAL prefix recovered from a filesystem image.
1294#[derive(Clone, Debug, Eq, PartialEq)]
1295pub struct WalReader {
1296    records: Vec<VisibleRecord>,
1297    next_seq: u64,
1298    durable_end: u64,
1299    terminator: Option<ReplayTerminator>,
1300}
1301
1302pub(crate) struct CleanWalReader {
1303    records: Vec<VisibleRecord>,
1304    next_seq: u64,
1305    durable_end: Option<LogSeq>,
1306}
1307
1308impl CleanWalReader {
1309    pub(crate) fn records(&self) -> &[VisibleRecord] {
1310        &self.records
1311    }
1312}
1313
1314impl WalReader {
1315    /// Opens the largest checksum- and sequence-valid WAL prefix.
1316    pub fn open(vfs: &dyn Vfs, path: &Path) -> Result<Self, WalReadError> {
1317        let bytes = match vfs.read(path) {
1318            Ok(bytes) => bytes,
1319            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
1320                return Ok(Self {
1321                    records: Vec::new(),
1322                    next_seq: 0,
1323                    durable_end: 0,
1324                    terminator: None,
1325                });
1326            }
1327            Err(error) => return Err(WalReadError::Io(error)),
1328        };
1329        let replayed = replay(&bytes);
1330        let terminator = replayed.terminator;
1331        if matches!(terminator, ReplayTerminator::InvalidHeader(_)) {
1332            return Ok(Self {
1333                records: Vec::new(),
1334                next_seq: 0,
1335                durable_end: 0,
1336                terminator: Some(terminator),
1337            });
1338        }
1339        let header = header::decode_header(&bytes).map_err(WalReadError::Header)?;
1340        let mut offset = header::WAL_HEADER_LEN;
1341        let recovered = replayed
1342            .records
1343            .iter()
1344            .map(|record| {
1345                let encoded_len = record::MIN_RECORD_LEN.saturating_add(record.payload.len());
1346                let range = offset..offset.saturating_add(encoded_len);
1347                offset = range.end;
1348                (record.seq, record.op, range)
1349            })
1350            .collect::<Vec<_>>();
1351        drop(replayed);
1352        let records = recovered
1353            .into_iter()
1354            .map(|(seq, op, range)| {
1355                let start = range.start;
1356                let end = range.end;
1357                let encoded = bytes
1358                    .get(range)
1359                    .ok_or(WalReadError::InvalidRecoveredRange {
1360                        start,
1361                        end,
1362                        available: bytes.len(),
1363                    })?;
1364                let encoded = Arc::new(encoded.to_vec());
1365                let length = encoded.len();
1366                Ok(VisibleRecord::recovered(seq, op, encoded, 0..length))
1367            })
1368            .collect::<Result<Vec<_>, WalReadError>>()?;
1369        let durable_end = records.last().map_or_else(
1370            || header.first_seq.get().saturating_sub(1),
1371            |record| record.seq.get(),
1372        );
1373        let next_seq = records.last().map_or(header.first_seq.get(), |record| {
1374            record.seq.get().saturating_add(1)
1375        });
1376        Ok(Self {
1377            records,
1378            next_seq,
1379            durable_end,
1380            terminator: Some(terminator),
1381        })
1382    }
1383
1384    /// Returns the recovered ordered prefix.
1385    #[must_use]
1386    pub fn records(&self) -> &[VisibleRecord] {
1387        &self.records
1388    }
1389
1390    /// Returns the checked replay boundary, or `None` when no WAL path existed.
1391    #[must_use]
1392    pub const fn terminator(&self) -> Option<ReplayTerminator> {
1393        self.terminator
1394    }
1395
1396    pub(crate) fn into_clean(self) -> Result<CleanWalReader, WalRecoveryError> {
1397        match self.terminator {
1398            Some(ReplayTerminator::CleanEnd) => {
1399                let durable_end = Some(LogSeq::new(self.durable_end));
1400                Ok(CleanWalReader {
1401                    records: self.records,
1402                    next_seq: self.next_seq,
1403                    durable_end,
1404                })
1405            }
1406            Some(ReplayTerminator::InvalidHeader(error)) => {
1407                let error = WalRecoveryError::InvalidHeader(error);
1408                #[cfg(any(test, feature = "test-seams"))]
1409                crate::lifecycle::record_storage_wal_recovery_fault(&error);
1410                Err(error)
1411            }
1412            Some(ReplayTerminator::CorruptAt { offset, reason }) => {
1413                let error = WalRecoveryError::CorruptAt { offset, reason };
1414                #[cfg(any(test, feature = "test-seams"))]
1415                crate::lifecycle::record_storage_wal_recovery_fault(&error);
1416                Err(error)
1417            }
1418            None => Err(WalRecoveryError::MissingFile),
1419        }
1420    }
1421}
1422
1423impl DurableLog for WalReader {
1424    fn durable_end(&self) -> u64 {
1425        self.durable_end
1426    }
1427}
1428
1429/// WAL creation, staging, or group-flush failure.
1430#[derive(Debug)]
1431pub enum WalWriteError {
1432    /// The requested path already contains WAL bytes.
1433    NonEmptyWal {
1434        /// Existing byte length.
1435        length: u64,
1436    },
1437    /// Recovered record byte accounting exceeded the address space.
1438    RecoveredBytesOverflow,
1439    /// One encoded record cannot fit the configured byte bound.
1440    GroupTooLarge {
1441        /// Bytes required by the pending group and record.
1442        encoded_bytes: usize,
1443        /// Configured maximum.
1444        max_group_bytes: usize,
1445    },
1446    /// The u64 sequence space has no successor.
1447    SequenceExhausted,
1448    /// WAL header registry lookup failed.
1449    Header(RegistryError),
1450    /// Record framing failed.
1451    Record(RecordEncodeError),
1452    /// A shared writer mutex was poisoned.
1453    Poisoned(&'static str),
1454    /// The writer failed permanently after visibility was published.
1455    Failed {
1456        /// Underlying I/O error class.
1457        kind: std::io::ErrorKind,
1458        /// Stable error detail retained for followers.
1459        detail: Arc<str>,
1460    },
1461}
1462
1463impl WalWriteError {
1464    fn from_io(error: std::io::Error) -> Self {
1465        Self::Failed {
1466            kind: error.kind(),
1467            detail: Arc::from(error.to_string()),
1468        }
1469    }
1470
1471    fn failed(failure: &Failure) -> Self {
1472        Self::Failed {
1473            kind: failure.kind,
1474            detail: Arc::clone(&failure.detail),
1475        }
1476    }
1477}
1478
1479impl std::fmt::Display for WalWriteError {
1480    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1481        match self {
1482            Self::NonEmptyWal { length } => {
1483                write!(formatter, "WAL path is not empty: {length} bytes")
1484            }
1485            Self::RecoveredBytesOverflow => {
1486                formatter.write_str("recovered WAL record bytes overflow usize")
1487            }
1488            Self::GroupTooLarge {
1489                encoded_bytes,
1490                max_group_bytes,
1491            } => write!(
1492                formatter,
1493                "WAL group needs {encoded_bytes} bytes, exceeding {max_group_bytes}"
1494            ),
1495            Self::SequenceExhausted => formatter.write_str("WAL sequence space exhausted"),
1496            Self::Header(error) => write!(formatter, "WAL header: {error}"),
1497            Self::Record(error) => write!(formatter, "WAL record: {error}"),
1498            Self::Poisoned(detail) => write!(formatter, "{detail} poisoned"),
1499            Self::Failed { kind, detail } => {
1500                write!(formatter, "WAL writer failed with {:?}: {}", kind, detail)
1501            }
1502        }
1503    }
1504}
1505
1506impl std::error::Error for WalWriteError {}
1507
1508/// A non-empty WAL cannot be resumed because replay did not reach a clean end.
1509#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1510pub enum WalRecoveryError {
1511    /// The requested WAL path did not exist and therefore cannot be resumed.
1512    MissingFile,
1513    /// The persisted WAL header is absent, truncated, or invalid.
1514    InvalidHeader(header::WalHeaderError),
1515    /// Record replay stopped at a typed corruption boundary.
1516    CorruptAt {
1517        /// Byte offset of the first invalid record.
1518        offset: usize,
1519        /// Exact framing, checksum, or sequence failure.
1520        reason: replay::CorruptionReason,
1521    },
1522}
1523
1524impl std::fmt::Display for WalRecoveryError {
1525    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1526        match self {
1527            Self::MissingFile => formatter.write_str("WAL recovery path is missing"),
1528            Self::InvalidHeader(error) => write!(formatter, "WAL recovery header: {error}"),
1529            Self::CorruptAt { offset, reason } => {
1530                write!(
1531                    formatter,
1532                    "WAL recovery stopped at byte {offset}: {reason:?}"
1533                )
1534            }
1535        }
1536    }
1537}
1538
1539impl std::error::Error for WalRecoveryError {
1540    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
1541        match self {
1542            Self::InvalidHeader(error) => Some(error),
1543            Self::MissingFile | Self::CorruptAt { .. } => None,
1544        }
1545    }
1546}
1547
1548/// WAL recovery failure requiring the caller to stop rather than hide corruption.
1549#[derive(Debug)]
1550pub enum WalReadError {
1551    /// Filesystem read failed.
1552    Io(std::io::Error),
1553    /// A header accepted by replay could not be decoded for resume state.
1554    Header(header::WalHeaderError),
1555    /// A replay-derived encoded record range exceeded the WAL image.
1556    InvalidRecoveredRange {
1557        /// First encoded byte of the recovered record.
1558        start: usize,
1559        /// Exclusive end of the recovered record.
1560        end: usize,
1561        /// Complete WAL image length.
1562        available: usize,
1563    },
1564}
1565
1566impl std::fmt::Display for WalReadError {
1567    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1568        match self {
1569            Self::Io(error) => write!(formatter, "WAL read: {error}"),
1570            Self::Header(error) => write!(formatter, "WAL read header: {error}"),
1571            Self::InvalidRecoveredRange {
1572                start,
1573                end,
1574                available,
1575            } => write!(
1576                formatter,
1577                "WAL recovered range {start}..{end} exceeds {available} bytes"
1578            ),
1579        }
1580    }
1581}
1582
1583impl std::error::Error for WalReadError {
1584    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
1585        match self {
1586            Self::Io(error) => Some(error),
1587            Self::Header(error) => Some(error),
1588            Self::InvalidRecoveredRange { .. } => None,
1589        }
1590    }
1591}