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