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