Skip to main content

mira_core/
wal.rs

1//! The write-ahead log, which exists to decouple the acknowledgement from the
2//! seal.
3//!
4//! # Why this exists, given `docs/architecture.md` section 4 says "No WAL"
5//!
6//! section 4's argument is about *recovery*, and it is still correct: a block
7//! directory is renamed into place atomically, so there is no torn state and
8//! nothing for a log to replay. This log is not for recovery. It is for
9//! latency.
10//!
11//! Before it, an export was acknowledged only after the block *containing it*
12//! was durably published, so under light load the caller waited out
13//! `max_block_age` — a measured p50 of 657 ms and p99 of 2,647 ms (section 11). The
14//! block is sealed on a timer because nothing else bounds how long a
15//! half-filled block sits there, and that timer became the caller's latency.
16//! With the log in front, the seal triggers are unchanged and nobody is waiting
17//! on them: `max_block_age` goes back to being a statement about the shape of
18//! blocks on disk rather than a latency bound.
19//!
20//! # The durability this buys, stated precisely
21//!
22//! An append is acknowledged once `write(2)` has returned — the bytes are in
23//! the kernel's page cache, not on the platter. That survives everything that
24//! kills the *process*: a panic under `panic = "abort"`, SIGKILL, the OOM
25//! killer, a flusher that took the process down with it (section 1). It does **not**
26//! survive power loss or a kernel panic, because the page cache does not.
27//!
28//! This is a deliberate choice and it is the reason there is no `fsync` on the
29//! acknowledgement path. On the machine section 11 was measured on, one
30//! `F_FULLFSYNC` costs 4,230 us, so a durably-fsynced ack could not have a p50
31//! below 4.2 ms, let alone a p99 under 5 ms. The same trade is Kafka's
32//! `acks=1` and ClickHouse's default. [`Wal::sync`] exists and is called on a
33//! timer by a background task, which bounds how much is exposed to a power cut
34//! to one sync interval — it is never called by an appender.
35//!
36//! There is deliberately no userspace buffering. A `BufWriter` would batch the
37//! syscalls, but bytes sitting in a `Vec` in this process do not survive the
38//! process dying, which is exactly the failure this log is claiming to cover.
39//! One `write(2)` per export at a few tens of microseconds is affordable
40//! because an export is a batch and not a record: the append rate is section
41//! 11's "Ingest throughput" row divided by the batch size, and the load
42//! generator sends 8,192 records per export. At one connection that row is
43//! 629,384 records/s, so 77 appends/s; at the thirty-two-connection plateau,
44//! 1,537,875 records/s is 188. Three digits of appends per second is not a rate
45//! a syscall per append can be the ceiling of.
46//!
47//! # The frame is the OTLP request, in its canonical protobuf encoding
48//!
49//! Not the bytes off the wire. Mira has three ways in — gRPC, protobuf over
50//! HTTP, and KYAML over HTTP — and only one of them still has bytes by the time
51//! anything could log them: tonic decodes before the handler is called, and a
52//! KYAML body is not protobuf at all. So the frame body is `encode_to_vec` of
53//! the decoded request, which normalises all three transports to one format
54//! with one decoder on the replay side.
55//!
56//! That costs a re-encode. Measured on an 8,192-record log export (1.29 MiB):
57//! encode 1.49 ms at 864 MiB/s, against the decode already in the path at
58//! 5.29 ms and 244 MiB/s. section 11 measured the whole engine at 190.6 MiB/s, a
59//! 6.8 ms budget for that export, so the log adds about 22%. The alternative — a
60//! custom tonic `Codec` to keep the wire bytes — buys that 22% back for a codec Mira
61//! then owns forever, which is the wrong side of principle 1's trade until
62//! something measures it as the bottleneck.
63//!
64//! Replay feeds the decoded frame through the same `ingest::{logs,traces,
65//! metrics}` the network path calls, so there is no second decode path to
66//! write, to test or to keep in step with the first.
67//!
68//! ```text
69//! ┌────────┬─────┬────────┬─────┬─────────┬────────┬──────────┬────────┐
70//! │ magic  │ ver │ signal │ pad │ seq     │ len    │ body     │ crc32  │
71//! │ 4 B    │ 2 B │ 1 B    │ 1 B │ 8 B     │ 4 B    │ len B    │ 4 B    │
72//! └────────┴─────┴────────┴─────┴─────────┴────────┴──────────┴────────┘
73//!  └──────────────── covered by the CRC ──────────────────────┘
74//! ```
75//!
76//! The CRC covers the header as well as the body, so a corrupted length is
77//! caught by the checksum rather than by whatever it would otherwise index
78//! into. That is the same lesson as the block footer: a length read out of a
79//! file is attacker-controlled-equivalent, and validating it against the file's
80//! real extent is not optional.
81//!
82//! # Recovery, and why there is still no manifest
83//!
84//! Replay needs exactly one fact — which frames are already inside a published
85//! block — and the block directory carries it, so principle 4 survives intact.
86//! Block names gain a fifth field, `wal_hi`: an *exclusive* watermark, meaning
87//! every sequence of that signal below it is inside some published block. It is
88//! not this block's own maximum, because sibling shards are filling their own
89//! blocks from the same log — it is what [`Wal::watermark_for`] answers, the
90//! lowest still-unpublished sequence this block does not hold, or one past
91//! everything the log has handed out when this block holds them all. Boot
92//! recovery therefore stays what section 4 says it is: a `readdir`, the same
93//! one the read path already does, with no extra I/O and no
94//! metadata store to keep consistent. Each signal keeps its own watermark, and
95//! that costs nothing because an OTLP export belongs to exactly one signal —
96//! `/v1/logs`, `/v1/traces` and `/v1/metrics` are three endpoints.
97//!
98//! # What this breaks, and what pays for it
99//!
100//! Read-your-writes. section 11 notes it was free, and it was free *because* the ack
101//! waited for the publish — the same rename made the data durable and visible
102//! at once. A caller acked here and querying immediately would not see its data
103//! until the block seals, which is a latency the log was built to remove.
104//!
105//! The repair is the open-block query surface: [`crate::query::search_open`]
106//! takes the flusher's in-progress builder as a snapshot and scans it alongside
107//! the sealed blocks, so read-your-writes holds with the log on or off. It is
108//! not free — that snapshot is the one allocation the read path makes — and
109//! section 7.6 is why it is paid there rather than in the scan.
110
111use std::collections::BTreeSet;
112use std::fs::{self, File, OpenOptions};
113use std::io::{Read, Write};
114use std::path::{Path, PathBuf};
115use std::sync::Mutex;
116
117use crate::error::{Error, IoContext, Result};
118
119/// `MIRAWAL0`, truncated. Present so a stray file in the WAL directory is
120/// rejected by name rather than parsed as a frame.
121const MAGIC: u32 = 0x4d_57_41_4c; // "MWAL"
122
123/// Bumped when the frame layout changes. A reader that does not recognise a
124/// version refuses the segment rather than guessing at its shape, which is the
125/// same rule the block format follows.
126pub const WAL_VERSION: u16 = 1;
127
128const HEADER_LEN: usize = 20;
129const CRC_LEN: usize = 4;
130
131/// The largest frame that will be written or believed on read.
132///
133/// This is not a tuning knob, it is a bound on trust. `len` is four bytes read
134/// out of a file that may have been corrupted, and without a ceiling a garbage
135/// value asks for an allocation of up to 4 GiB before the CRC that would have
136/// caught it has been checked. 64 MiB is four times the default
137/// `ingest.max_request_bytes`, so it cannot refuse a frame the ingest path
138/// would have accepted.
139const MAX_FRAME_BYTES: u32 = 64 << 20;
140
141/// Roll to a new segment past this size. Segments are the unit of deletion, so
142/// this trades how much dead log is retained past its watermark against how
143/// many files the directory holds. At section 11's measured 190.6 MiB/s a segment
144/// is about a third of a second of ingest.
145const SEGMENT_BYTES: u64 = 64 << 20;
146
147/// Which signal a frame belongs to.
148///
149/// Stored as one byte rather than the string the block directories use,
150/// because a frame header should be fixed-width — a variable-length field in
151/// front of a length field is how a parser gets confused about where the body
152/// starts.
153#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
154#[repr(u8)]
155pub enum Signal {
156    Logs = 0,
157    Traces = 1,
158    Metrics = 2,
159}
160
161impl Signal {
162    /// The three, in the order their watermarks are indexed.
163    pub const ALL: [Signal; 3] = [Signal::Logs, Signal::Traces, Signal::Metrics];
164
165    /// The directory name `block::publish` uses for this signal. Kept in step
166    /// with the strings the block layer already writes; a mismatch here would
167    /// put a watermark on the wrong signal's blocks.
168    pub fn as_str(self) -> &'static str {
169        match self {
170            Signal::Logs => "logs",
171            Signal::Traces => "traces",
172            Signal::Metrics => "metrics",
173        }
174    }
175
176    /// The inverse of [`Signal::as_str`], so a caller holding a
177    /// [`crate::signal::SignalBuilder`]'s `SIGNAL` does not have to keep a
178    /// fourth copy of the mapping in step with the other three.
179    pub fn named(s: &str) -> Option<Signal> {
180        Signal::ALL.into_iter().find(|sig| sig.as_str() == s)
181    }
182
183    fn from_u8(b: u8) -> Option<Signal> {
184        match b {
185            0 => Some(Signal::Logs),
186            1 => Some(Signal::Traces),
187            2 => Some(Signal::Metrics),
188            _ => None,
189        }
190    }
191
192    /// Where this signal's watermark sits in [`Watermarks`].
193    pub fn index(self) -> usize {
194        self as usize
195    }
196}
197
198/// One past the last published sequence, per signal, indexed by
199/// [`Signal::index`]. Assembled from the block directory listing at boot; see
200/// the module docs.
201///
202/// Exclusive rather than inclusive on purpose. An inclusive "highest published
203/// sequence" has no value that means *nothing published*: zero is a real
204/// sequence, so the empty state and "sequence 0 is durable" are the same
205/// number, and the first export after every restart is silently dropped from
206/// the replay. Exclusive makes the empty state `0` and needs no sentinel.
207pub type Watermarks = [u64; 3];
208
209struct Inner {
210    file: File,
211    path: PathBuf,
212    /// Bytes written to the current segment, tracked rather than `stat`ed so
213    /// the roll check costs nothing.
214    written: u64,
215    /// The next sequence to hand out. Monotonic across segments and restarts.
216    next_seq: u64,
217    /// Set by `append`, cleared by `sync`. Without it a quiet server syncs an
218    /// unchanged file on every tick, which on macOS is a 4 ms barrier bought
219    /// for nothing.
220    dirty: bool,
221    /// Every sequence this log has handed out that no block has published yet,
222    /// per signal, indexed by [`Signal::index`].
223    ///
224    /// This is what makes more than one flusher per signal safe. A block claims
225    /// a watermark by name and [`crate::block::wal_watermarks`] takes the
226    /// maximum, which is only sound if frames reach blocks in sequence order —
227    /// see [`Wal::append_then`]. Shard a signal's flusher and they no longer
228    /// do: shard 1 can publish frame 6 while shard 0 still holds frame 5, and
229    /// the maximum would say 7. So the watermark stops being "one past what
230    /// this block holds" and becomes "the oldest frame nobody has published",
231    /// which is [`Wal::watermark_for`]. Frames leave the set only on a
232    /// *successful* publish, so a block that fails to land cannot be claimed
233    /// past either — a hole the single-flusher version had, because a failed
234    /// publish dropped its sequences and the next block's maximum stepped
235    /// straight over them.
236    ///
237    /// ponytail: a `BTreeSet` walked for its minimum, on the reasoning that it
238    /// holds one entry per *export* in flight — tens, not millions — and is
239    /// touched once per append under a lock the append already holds. If a
240    /// deployment ever runs deep enough queues for the removals to show up,
241    /// the shape that replaces it is a per-shard "oldest held" cell plus the
242    /// channel's own ordering.
243    pending: [BTreeSet<u64>; 3],
244    /// Segments that have been rolled past but not yet forced, handed to the
245    /// next [`Wal::sync`].
246    ///
247    /// This list exists because the obvious alternative — syncing the outgoing
248    /// segment inside `roll` — puts a 4 ms `F_FULLFSYNC` on one appender in
249    /// every `SEGMENT_BYTES`, and `benches/wal_bench.rs` measured exactly that:
250    /// at 1 MiB bodies a segment rolls every 64 appends, so the stall landed on
251    /// 1.5% of them and the p99 was 7.2 ms against a 5 ms SLA. A rare stall is
252    /// still a stall, and a tail latency is made of rare things.
253    retired: Vec<(PathBuf, File)>,
254}
255
256/// An append-only log of OTLP export bodies.
257///
258/// Every method is synchronous and at least one of them issues a syscall that
259/// can block under writeback pressure, so **callers must not invoke these from
260/// a tokio runtime worker** — section 5's rule that blocking work goes through
261/// `spawn_blocking` applies here for the same reason it applies to `publish`.
262pub struct Wal {
263    inner: Mutex<Inner>,
264    dir: PathBuf,
265    node: u32,
266}
267
268impl Wal {
269    /// Open (or create) the log under `<root>/.wal/`, resuming the sequence
270    /// counter past anything already on disk.
271    ///
272    /// Resuming from the *files* rather than from the block watermarks is
273    /// deliberate: a sequence that went backwards would let a replay confuse a
274    /// new frame for one a block already covers, and the watermark comparison
275    /// is `>`, so the failure would be silent data loss rather than an error.
276    pub fn open(root: &Path, node: u32) -> Result<Self> {
277        let dir = root.join(".wal");
278        fs::create_dir_all(&dir).ctx(&dir)?;
279
280        let segments = Self::segments(&dir, node)?;
281        // The highest sequence actually present, which is not the same as the
282        // last segment's first sequence: a segment may be empty if the process
283        // died between creating it and its first append.
284        let mut next_seq = 0;
285        for (path, first) in &segments {
286            let mut torn = false;
287            for frame in FrameReader::open(path)? {
288                // A half-written frame at the tail is what a crash leaves, and
289                // it is the case `replay` is built to stop at rather than fail
290                // on. `open` has to agree with it: propagating the error here
291                // meant the ordinary crash this log exists to survive left a
292                // node that would not start at all.
293                let Ok(frame) = frame else {
294                    torn = true;
295                    break;
296                };
297                next_seq = next_seq.max(frame.seq + 1);
298            }
299            // Never resume onto the name of a segment that ended torn. Segments
300            // are named for their first sequence, so a crash during the *first*
301            // frame of a fresh segment leaves a file whose name is exactly the
302            // sequence being resumed at — and reopening it would append behind
303            // a tear that stops every future replay at it, losing frames that
304            // were acknowledged. One skipped sequence number costs nothing:
305            // nothing indexes by sequence, and the watermark comparisons are
306            // inequalities.
307            if torn {
308                next_seq = next_seq.max(first + 1);
309            }
310        }
311
312        let path = dir.join(format!("{node:08x}-{next_seq:020}.wal"));
313        let file = OpenOptions::new()
314            .create(true)
315            .append(true)
316            .open(&path)
317            .ctx(&path)?;
318        let written = file.metadata().ctx(&path)?.len();
319
320        Ok(Wal {
321            inner: Mutex::new(Inner {
322                file,
323                path,
324                written,
325                next_seq,
326                dirty: false,
327                pending: Default::default(),
328                retired: Vec::new(),
329            }),
330            dir,
331            node,
332        })
333    }
334
335    /// Append one OTLP export body and return the sequence it was given.
336    ///
337    /// Returns once the bytes are in the page cache. This is the call the
338    /// acknowledgement waits on, and it does not fsync — see the module docs
339    /// for exactly what that does and does not survive.
340    pub fn append(&self, signal: Signal, body: &[u8]) -> Result<u64> {
341        self.append_then(signal, body, |_| {})
342    }
343
344    /// [`append`](Self::append), running `then` on the new sequence before the
345    /// log's lock is released.
346    ///
347    /// This exists to make one specific race impossible, and it is not a
348    /// general-purpose hook.
349    ///
350    /// A block's watermark is [`watermark_for`](Self::watermark_for)'s answer
351    /// over the sequences that block holds, and that call binary-searches them,
352    /// so a shard's list of them has to be ascending. Two exporters calling
353    /// `append` concurrently get their sequences in lock order but can be
354    /// preempted between the return and the enqueue, which would land 6 in a
355    /// shard's list ahead of 5. A binary search over an unsorted list is wrong
356    /// in both directions and one of them is a false hit: the watermark steps
357    /// over a frame no block holds, and the next replay skips it. That is
358    /// silent loss, the one failure this log exists to prevent.
359    ///
360    /// So the enqueue happens under the same lock. It costs nothing: the queue
361    /// hand-off is a pointer move into a reserved slot, next to a `write(2)`
362    /// that has already been paid for.
363    ///
364    /// `then` therefore must not block, must not `.await`, and must not touch
365    /// this log — re-entering `append` from inside it deadlocks.
366    pub fn append_then(&self, signal: Signal, body: &[u8], then: impl FnOnce(u64)) -> Result<u64> {
367        let len = u32::try_from(body.len())
368            .ok()
369            .filter(|n| *n <= MAX_FRAME_BYTES);
370        let Some(len) = len else {
371            return Err(Error::WalFrameTooLarge {
372                len: body.len(),
373                max: MAX_FRAME_BYTES,
374            });
375        };
376
377        let mut inner = self.lock();
378
379        if inner.written >= SEGMENT_BYTES {
380            self.roll(&mut inner)?;
381        }
382
383        let seq = inner.next_seq;
384        let mut header = [0u8; HEADER_LEN];
385        header[0..4].copy_from_slice(&MAGIC.to_le_bytes());
386        header[4..6].copy_from_slice(&WAL_VERSION.to_le_bytes());
387        header[6] = signal as u8;
388        header[7] = 0;
389        header[8..16].copy_from_slice(&seq.to_le_bytes());
390        header[16..20].copy_from_slice(&len.to_le_bytes());
391
392        let mut hasher = crc32fast::Hasher::new();
393        hasher.update(&header);
394        hasher.update(body);
395        let crc = hasher.finalize();
396
397        // One `write_all` per region rather than one buffer built by
398        // concatenation: the body can be 16 MiB and copying it to prepend
399        // twenty bytes would double the memcpy the ingest path is already
400        // trying not to pay twice.
401        //
402        // A short write partway through leaves a torn frame at the tail, which
403        // is the case `FrameReader` is built to stop at. It cannot corrupt a
404        // frame that was already complete, because the file is opened in
405        // append mode and nothing rewrites what is behind the offset.
406        inner.file.write_all(&header).ctx(&inner.path)?;
407        inner.file.write_all(body).ctx(&inner.path)?;
408        inner.file.write_all(&crc.to_le_bytes()).ctx(&inner.path)?;
409
410        inner.written += (HEADER_LEN + body.len() + CRC_LEN) as u64;
411        inner.next_seq += 1;
412        inner.dirty = true;
413        inner.pending[signal.index()].insert(seq);
414        then(seq);
415        Ok(seq)
416    }
417
418    /// Put a sequence this log already handed out back among the unpublished —
419    /// the replay path, where the frame is read off disk rather than appended.
420    ///
421    /// Without it a recovered frame is invisible to [`watermark_for`](Self::watermark_for),
422    /// so a block sealed beside it could claim a watermark that steps over it
423    /// and the next boot would not replay it a second time. That is the one
424    /// failure this log exists to prevent, and replay is exactly when it would
425    /// bite: the frames in flight are the ones a crash already nearly lost.
426    pub fn reframed(&self, signal: Signal, seq: u64) {
427        self.lock().pending[signal.index()].insert(seq);
428    }
429
430    /// A panic cannot leave the log's state inconsistent — every critical
431    /// section is a write and a counter — so poisoning carries no information.
432    fn lock(&self) -> std::sync::MutexGuard<'_, Inner> {
433        self.inner.lock().unwrap_or_else(|e| e.into_inner())
434    }
435
436    /// The watermark a block holding `seqs` may publish: the oldest sequence of
437    /// this signal that nobody has published, or, if this block is the last of
438    /// them, one past everything the log has handed out.
439    ///
440    /// `seqs` must be sorted. Called *before* the publish, because the answer
441    /// goes in the directory name; the sequences stay unpublished until
442    /// [`published`](Self::published) says otherwise, so a sibling shard sealing
443    /// at the same moment still counts this block's frames against its own
444    /// watermark and neither can claim the other's.
445    ///
446    /// It can be too low — a shard holding an old frame pins every sibling's
447    /// watermark behind it — and that is the direction it is allowed to be
448    /// wrong in: too low re-ingests a frame a block already has, too high loses
449    /// it. Nothing is pinned for long, because the shard holding the old frame
450    /// is at most `max_block_age` from sealing it.
451    pub fn watermark_for(&self, signal: Signal, seqs: &[u64]) -> u64 {
452        let inner = self.lock();
453        inner.pending[signal.index()]
454            .iter()
455            .find(|s| seqs.binary_search(s).is_err())
456            .copied()
457            .unwrap_or(inner.next_seq)
458    }
459
460    /// Retire the sequences a block has durably published.
461    ///
462    /// Only on success. A block that failed to land leaves its frames here, and
463    /// that is what keeps the next block's watermark from claiming them.
464    pub fn published(&self, signal: Signal, seqs: &[u64]) {
465        let mut inner = self.lock();
466        let pending = &mut inner.pending[signal.index()];
467        for s in seqs {
468            pending.remove(s);
469        }
470    }
471
472    /// Force everything appended so far onto the device.
473    ///
474    /// Called on a timer by a background task to bound power-loss exposure,
475    /// never from the acknowledgement path. On Apple targets this is
476    /// `F_FULLFSYNC` and costs about 4 ms, which is the whole reason it is not
477    /// on the ack path.
478    pub fn sync(&self) -> Result<()> {
479        let mut inner = self.lock();
480        if !inner.dirty && inner.retired.is_empty() {
481            return Ok(());
482        }
483        // Retired segments first: they are older, so they are what a power cut
484        // would lose the most of. Taken out of the struct rather than iterated
485        // in place so a failure part-way through does not re-sync the ones that
486        // already succeeded on the next tick.
487        for (path, file) in std::mem::take(&mut inner.retired) {
488            crate::sync_data(&file).ctx(&path)?;
489        }
490        if inner.dirty {
491            crate::sync_data(&inner.file).ctx(&inner.path)?;
492            inner.dirty = false;
493        }
494        Ok(())
495    }
496
497    /// The sequence that will be handed to the next append.
498    pub fn next_seq(&self) -> u64 {
499        self.inner
500            .lock()
501            .unwrap_or_else(|e| e.into_inner())
502            .next_seq
503    }
504
505    /// Delete whole segments whose every frame is below `covered`, the
506    /// smallest of the per-signal [`Watermarks`] — same exclusive convention.
507    ///
508    /// Returns how many segments were removed. Deletion is per segment rather
509    /// than per frame because a log is only append-only if nothing ever
510    /// rewrites its middle; reclaiming a prefix by truncation would mean
511    /// rewriting offsets that a concurrent reader is part-way through.
512    ///
513    /// The current segment is never removed, whatever its watermark, because
514    /// `append` holds it open and unlinking it would leave writes going to a
515    /// file with no name.
516    pub fn truncate(&self, covered: u64) -> Result<usize> {
517        let current = {
518            let inner = self.lock();
519            inner.path.clone()
520        };
521        let segments = Self::segments(&self.dir, self.node)?;
522        let mut removed = 0;
523
524        for (path, _) in &segments {
525            if *path == current {
526                continue;
527            }
528            // The last frame decides, not the first: a segment is only dead
529            // once everything in it is covered.
530            let mut highest = None;
531            for frame in FrameReader::open(path)? {
532                highest = Some(frame?.seq);
533            }
534            match highest {
535                // An empty segment is a crash artefact between create and
536                // first append; nothing references it and it will never be
537                // written to again, so it goes.
538                None => {}
539                Some(hi) if hi < covered => {}
540                Some(_) => continue,
541            }
542            fs::remove_file(path).ctx(path)?;
543            // Drop the retired handle too. Unlinking a file this process still
544            // has open is legal on Unix, but the inode — and its blocks —
545            // survive until the last descriptor closes, so leaving it there
546            // means `truncate` reports space it has not actually freed.
547            {
548                let mut inner = self.lock();
549                inner.retired.retain(|(p, _)| p != path);
550            }
551            removed += 1;
552        }
553
554        if removed > 0 {
555            // The unlinks are metadata on the WAL directory, and until that is
556            // flushed a crash brings the deleted segments back and replays
557            // frames a block already covers. The watermark makes that safe
558            // rather than wrong, but replaying gigabytes at every boot is its
559            // own outage.
560            crate::sync_all(&File::open(&self.dir).ctx(&self.dir)?).ctx(&self.dir)?;
561        }
562        Ok(removed)
563    }
564
565    /// Replay every frame not yet covered by a published block, oldest first.
566    ///
567    /// `watermarks` is the exclusive watermark per signal, taken from the block
568    /// directory listing: every sequence below it is inside a published block,
569    /// which is not the same as one past the last sequence published, because
570    /// shards publish out of order. A frame is handed to `f` only if its
571    /// sequence is at or above its own signal's watermark, so a block that
572    /// sealed while another signal's was still open does not cause a
573    /// re-ingest.
574    ///
575    /// A torn or corrupt frame ends the replay of that segment rather than
576    /// failing it: the tail of the last segment is exactly where a crash
577    /// leaves a half-written frame, and refusing to start because the last
578    /// write was interrupted would turn a normal crash into an outage. Frames
579    /// before the tear are complete and are replayed.
580    ///
581    /// `f` is handed the frame's own sequence, not a fresh one. Re-appending a
582    /// replayed frame would give it a number above every watermark, so the
583    /// block that stored it would claim the new sequence and leave the old one
584    /// uncovered — and the next boot would replay it again, forever. Carrying
585    /// the original through to the block is what makes replay converge.
586    pub fn replay(
587        root: &Path,
588        node: u32,
589        watermarks: Watermarks,
590        mut f: impl FnMut(Signal, u64, &[u8]) -> Result<()>,
591    ) -> Result<Replayed> {
592        let dir = root.join(".wal");
593        if !dir.is_dir() {
594            return Ok(Replayed::default());
595        }
596        let mut out = Replayed::default();
597
598        for (path, _) in Self::segments(&dir, node)? {
599            for frame in FrameReader::open(&path)? {
600                let Ok(frame) = frame else {
601                    out.torn_segments += 1;
602                    break;
603                };
604                if frame.seq < watermarks[frame.signal.index()] {
605                    out.skipped += 1;
606                    continue;
607                }
608                f(frame.signal, frame.seq, &frame.body)?;
609                out.replayed += 1;
610                out.bytes += frame.body.len() as u64;
611            }
612        }
613        Ok(out)
614    }
615
616    /// Segments for this node, oldest first.
617    ///
618    /// Ordered by the first sequence in the name rather than by mtime, because
619    /// mtime has a one-second resolution on some filesystems and two segments
620    /// can share it. Other nodes' segments are skipped: a shared volume (section 12)
621    /// has one log per replica and replaying another's would double-write its
622    /// data.
623    fn segments(dir: &Path, node: u32) -> Result<Vec<(PathBuf, u64)>> {
624        let prefix = format!("{node:08x}-");
625        let mut out = Vec::new();
626        let entries = match fs::read_dir(dir) {
627            Ok(entries) => entries,
628            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(out),
629            Err(e) => {
630                return Err(Error::Io {
631                    path: dir.into(),
632                    source: e,
633                });
634            }
635        };
636        for entry in entries {
637            let entry = entry.ctx(dir)?;
638            let name = entry.file_name();
639            let Some(name) = name.to_str() else { continue };
640            let Some(rest) = name.strip_prefix(&prefix) else {
641                continue;
642            };
643            let Some(first) = rest.strip_suffix(".wal") else {
644                continue;
645            };
646            let Ok(first) = first.parse::<u64>() else {
647                continue;
648            };
649            out.push((entry.path(), first));
650        }
651        out.sort_by_key(|(_, first)| *first);
652        Ok(out)
653    }
654
655    /// Close the current segment and start the next one.
656    ///
657    /// Nothing is forced here — the outgoing segment goes on `retired` for the
658    /// background [`Wal::sync`] to deal with. See that field for the measured
659    /// reason. Opening the new file is two syscalls and does not touch the
660    /// device, so the appender that happens to trigger a roll pays microseconds
661    /// rather than milliseconds.
662    fn roll(&self, inner: &mut Inner) -> Result<()> {
663        let path = self
664            .dir
665            .join(format!("{:08x}-{:020}.wal", self.node, inner.next_seq));
666        let file = OpenOptions::new()
667            .create(true)
668            .append(true)
669            .open(&path)
670            .ctx(&path)?;
671        let old_file = std::mem::replace(&mut inner.file, file);
672        let old_path = std::mem::replace(&mut inner.path, path);
673        if inner.dirty {
674            inner.retired.push((old_path, old_file));
675            inner.dirty = false;
676        }
677        inner.written = 0;
678        Ok(())
679    }
680}
681
682/// What a [`Wal::replay`] did, for the line it gets logged on.
683#[derive(Debug, Default, PartialEq, Eq)]
684pub struct Replayed {
685    /// Frames handed to the callback.
686    pub replayed: u64,
687    /// Frames already covered by a published block.
688    pub skipped: u64,
689    /// Body bytes replayed.
690    pub bytes: u64,
691    /// Segments that ended in a torn or corrupt frame. One is normal after a
692    /// crash — it is the write that was in flight. More than one means
693    /// something else is wrong, which is why they are counted separately from
694    /// the frames rather than folded in.
695    pub torn_segments: u64,
696}
697
698#[cfg_attr(test, derive(Debug))]
699struct Frame {
700    signal: Signal,
701    seq: u64,
702    body: Vec<u8>,
703}
704
705/// Reads frames out of one segment, stopping at the first that is not whole.
706struct FrameReader {
707    file: File,
708    path: PathBuf,
709    done: bool,
710}
711
712impl FrameReader {
713    fn open(path: &Path) -> Result<FrameReader> {
714        Ok(FrameReader {
715            file: File::open(path).ctx(path)?,
716            path: path.to_path_buf(),
717            done: false,
718        })
719    }
720
721    /// `Ok(None)` is a clean end of segment; `Err` is a tear or corruption,
722    /// after which this reader yields nothing further.
723    fn next_frame(&mut self) -> Result<Option<Frame>> {
724        let mut header = [0u8; HEADER_LEN];
725        if !read_exact_or_eof(&mut self.file, &mut header).ctx(&self.path)? {
726            return Ok(None);
727        }
728
729        let magic = u32::from_le_bytes(header[0..4].try_into().unwrap_or_default());
730        let version = u16::from_le_bytes(header[4..6].try_into().unwrap_or_default());
731        let len = u32::from_le_bytes(header[16..20].try_into().unwrap_or_default());
732
733        if magic != MAGIC {
734            return Err(Error::WalCorrupt {
735                path: self.path.clone(),
736                why: "bad frame magic",
737            });
738        }
739        if version != WAL_VERSION {
740            return Err(Error::WalVersion {
741                path: self.path.clone(),
742                found: version,
743                expected: WAL_VERSION,
744            });
745        }
746        // Checked before the allocation, not after: the CRC that would catch a
747        // corrupt length is at the far end of the body this length describes.
748        if len > MAX_FRAME_BYTES {
749            return Err(Error::WalCorrupt {
750                path: self.path.clone(),
751                why: "frame length above the maximum",
752            });
753        }
754        let Some(signal) = Signal::from_u8(header[6]) else {
755            return Err(Error::WalCorrupt {
756                path: self.path.clone(),
757                why: "unknown signal in frame header",
758            });
759        };
760
761        let mut body = vec![0u8; len as usize];
762        if !read_exact_or_eof(&mut self.file, &mut body).ctx(&self.path)? {
763            return Err(Error::WalCorrupt {
764                path: self.path.clone(),
765                why: "truncated frame body",
766            });
767        }
768        let mut crc_bytes = [0u8; CRC_LEN];
769        if !read_exact_or_eof(&mut self.file, &mut crc_bytes).ctx(&self.path)? {
770            return Err(Error::WalCorrupt {
771                path: self.path.clone(),
772                why: "truncated frame checksum",
773            });
774        }
775
776        let mut hasher = crc32fast::Hasher::new();
777        hasher.update(&header);
778        hasher.update(&body);
779        if hasher.finalize() != u32::from_le_bytes(crc_bytes) {
780            return Err(Error::WalCorrupt {
781                path: self.path.clone(),
782                why: "frame checksum mismatch",
783            });
784        }
785
786        Ok(Some(Frame {
787            signal,
788            seq: u64::from_le_bytes(header[8..16].try_into().unwrap_or_default()),
789            body,
790        }))
791    }
792}
793
794impl Iterator for FrameReader {
795    type Item = Result<Frame>;
796
797    fn next(&mut self) -> Option<Result<Frame>> {
798        if self.done {
799            return None;
800        }
801        match self.next_frame() {
802            Ok(Some(frame)) => Some(Ok(frame)),
803            Ok(None) => {
804                self.done = true;
805                None
806            }
807            Err(e) => {
808                self.done = true;
809                Some(Err(e))
810            }
811        }
812    }
813}
814
815/// `true` if the buffer was filled, `false` on a clean EOF before any byte.
816///
817/// `Read::read_exact` cannot distinguish "the segment ends here" from "the
818/// segment ends in the middle of a frame", and those are a normal end and a
819/// tear respectively.
820fn read_exact_or_eof(file: &mut impl Read, buf: &mut [u8]) -> std::io::Result<bool> {
821    let mut filled = 0;
822    while filled < buf.len() {
823        match file.read(&mut buf[filled..]) {
824            Ok(0) => return Ok(false),
825            Ok(n) => filled += n,
826            Err(e) if e.kind() == std::io::ErrorKind::Interrupted => {}
827            Err(e) => return Err(e),
828        }
829    }
830    Ok(true)
831}
832
833#[cfg(test)]
834mod tests {
835    use super::*;
836
837    fn tmpdir(name: &str) -> PathBuf {
838        let dir = std::env::temp_dir().join(format!("mira-wal-{name}-{}", std::process::id()));
839        let _ = fs::remove_dir_all(&dir);
840        fs::create_dir_all(&dir).unwrap();
841        dir
842    }
843
844    fn collect(root: &Path, node: u32, wm: Watermarks) -> (Vec<(Signal, Vec<u8>)>, Replayed) {
845        let (got, _, stats) = collect_seqs(root, node, wm);
846        (got, stats)
847    }
848
849    #[allow(clippy::type_complexity)]
850    fn collect_seqs(
851        root: &Path,
852        node: u32,
853        wm: Watermarks,
854    ) -> (Vec<(Signal, Vec<u8>)>, Vec<u64>, Replayed) {
855        let (mut got, mut seqs) = (Vec::new(), Vec::new());
856        let stats = Wal::replay(root, node, wm, |s, seq, b| {
857            got.push((s, b.to_vec()));
858            seqs.push(seq);
859            Ok(())
860        })
861        .unwrap();
862        (got, seqs, stats)
863    }
864
865    #[test]
866    fn a_frame_round_trips_through_replay() {
867        let root = tmpdir("roundtrip");
868        let wal = Wal::open(&root, 0xab).unwrap();
869        assert_eq!(wal.append(Signal::Logs, b"one").unwrap(), 0);
870        assert_eq!(wal.append(Signal::Traces, b"two").unwrap(), 1);
871        assert_eq!(wal.append(Signal::Metrics, b"three").unwrap(), 2);
872
873        let (got, stats) = collect(&root, 0xab, [0, 0, 0]);
874        // The regression this pins: sequence 0 is a real frame and watermark 0
875        // means nothing is published, so an inclusive comparison drops the
876        // first export of every restart. It did, until the watermark was made
877        // exclusive.
878        assert_eq!(stats.replayed, 3);
879        assert_eq!(got[0], (Signal::Logs, b"one".to_vec()));
880        assert_eq!(got[2], (Signal::Metrics, b"three".to_vec()));
881    }
882
883    #[test]
884    fn a_published_block_is_not_replayed_and_each_signal_counts_separately() {
885        let root = tmpdir("watermark");
886        let wal = Wal::open(&root, 1).unwrap();
887        wal.append(Signal::Logs, b"l0").unwrap(); // seq 0
888        wal.append(Signal::Traces, b"t1").unwrap(); // seq 1
889        wal.append(Signal::Logs, b"l2").unwrap(); // seq 2
890        wal.append(Signal::Traces, b"t3").unwrap(); // seq 3
891
892        // Logs sealed through seq 2 inclusive, traces only through seq 1. Only
893        // the traces frame above its own watermark comes back — a logs seal
894        // must not suppress an unsealed trace.
895        let mut wm = [0u64; 3];
896        wm[Signal::Logs.index()] = 3;
897        wm[Signal::Traces.index()] = 2;
898        let (got, stats) = collect(&root, 1, wm);
899        assert_eq!(stats.replayed, 1);
900        assert_eq!(stats.skipped, 3);
901        assert_eq!(got, vec![(Signal::Traces, b"t3".to_vec())]);
902    }
903
904    #[test]
905    fn a_torn_tail_ends_the_segment_without_losing_what_came_before() {
906        let root = tmpdir("torn");
907        let wal = Wal::open(&root, 2).unwrap();
908        wal.append(Signal::Logs, b"complete").unwrap();
909        wal.append(Signal::Logs, b"also-complete").unwrap();
910        wal.sync().unwrap();
911        let path = {
912            let inner = wal.inner.lock().unwrap();
913            inner.path.clone()
914        };
915        drop(wal);
916
917        // Chop the last four bytes: the second frame now has no checksum,
918        // which is exactly what a crash mid-write leaves behind.
919        let len = fs::metadata(&path).unwrap().len();
920        OpenOptions::new()
921            .write(true)
922            .open(&path)
923            .unwrap()
924            .set_len(len - 4)
925            .unwrap();
926
927        let (got, stats) = collect(&root, 2, [0, 0, 0]);
928        assert_eq!(
929            stats.replayed, 1,
930            "the whole frame before the tear survives"
931        );
932        assert_eq!(stats.torn_segments, 1);
933        assert_eq!(got, vec![(Signal::Logs, b"complete".to_vec())]);
934    }
935
936    #[test]
937    fn a_flipped_bit_in_the_body_is_caught_by_the_checksum() {
938        let root = tmpdir("bitrot");
939        let wal = Wal::open(&root, 3).unwrap();
940        wal.append(Signal::Logs, b"the-quick-brown-fox").unwrap();
941        wal.sync().unwrap();
942        let path = {
943            let inner = wal.inner.lock().unwrap();
944            inner.path.clone()
945        };
946        drop(wal);
947
948        let mut bytes = fs::read(&path).unwrap();
949        bytes[HEADER_LEN + 3] ^= 0x40;
950        fs::write(&path, &bytes).unwrap();
951
952        let (got, stats) = collect(&root, 3, [0, 0, 0]);
953        assert!(
954            got.is_empty(),
955            "a corrupt frame is never handed to the callback"
956        );
957        assert_eq!(stats.torn_segments, 1);
958    }
959
960    #[test]
961    fn a_corrupt_length_is_refused_before_it_is_allocated() {
962        let root = tmpdir("badlen");
963        let wal = Wal::open(&root, 4).unwrap();
964        wal.append(Signal::Logs, b"small").unwrap();
965        wal.sync().unwrap();
966        let path = {
967            let inner = wal.inner.lock().unwrap();
968            inner.path.clone()
969        };
970        drop(wal);
971
972        // A length field of 4 GiB. Without the MAX_FRAME_BYTES check this is a
973        // 4 GiB allocation on a machine that may not have it, from four bytes
974        // on disk — the same shape as the block-footer bug.
975        let mut bytes = fs::read(&path).unwrap();
976        bytes[16..20].copy_from_slice(&u32::MAX.to_le_bytes());
977        fs::write(&path, &bytes).unwrap();
978
979        let mut reader = FrameReader::open(&path).unwrap();
980        let err = reader.next().unwrap().unwrap_err();
981        assert!(
982            matches!(&err, Error::WalCorrupt { why, .. } if why.contains("length")),
983            "got {err:?}"
984        );
985    }
986
987    #[test]
988    fn a_frame_larger_than_the_maximum_is_refused_on_append() {
989        let root = tmpdir("toobig");
990        let wal = Wal::open(&root, 5).unwrap();
991        let huge = vec![0u8; MAX_FRAME_BYTES as usize + 1];
992        assert!(matches!(
993            wal.append(Signal::Logs, &huge),
994            Err(Error::WalFrameTooLarge { .. })
995        ));
996    }
997
998    #[test]
999    fn the_sequence_resumes_past_everything_on_disk_after_a_restart() {
1000        let root = tmpdir("resume");
1001        let wal = Wal::open(&root, 6).unwrap();
1002        wal.append(Signal::Logs, b"a").unwrap();
1003        wal.append(Signal::Logs, b"b").unwrap();
1004        wal.sync().unwrap();
1005        drop(wal);
1006
1007        // A sequence that restarted at 0 would collide with the frames already
1008        // there, and the watermark comparison would then skip new data as if
1009        // it were already published.
1010        let wal = Wal::open(&root, 6).unwrap();
1011        assert_eq!(wal.next_seq(), 2);
1012        assert_eq!(wal.append(Signal::Logs, b"c").unwrap(), 2);
1013
1014        let (got, _) = collect(&root, 6, [0, 0, 0]);
1015        assert_eq!(got.len(), 3);
1016    }
1017
1018    /// Replay hands back the sequence the frame already has, and `append_then`
1019    /// hands back the one it just assigned. Both are the same requirement seen
1020    /// from the two ends: a replayed export has to reach its block under its
1021    /// original number, or the block claims a copy, leaves the original
1022    /// uncovered, and the next boot replays it again — for ever.
1023    #[test]
1024    fn replay_carries_the_original_sequence_and_append_reports_it_under_the_lock() {
1025        let root = tmpdir("seqs");
1026        let wal = Wal::open(&root, 3).unwrap();
1027        let mut seen = Vec::new();
1028        for body in [b"a", b"b", b"c"] {
1029            wal.append_then(Signal::Traces, body, |seq| seen.push(seq))
1030                .unwrap();
1031        }
1032        assert_eq!(seen, [0, 1, 2]);
1033
1034        // Not 0..n of whatever survived the watermark: the first frame is
1035        // already published, so the two that replay keep 1 and 2.
1036        let mut wm = [0u64; 3];
1037        wm[Signal::Traces.index()] = 1;
1038        let (got, seqs, stats) = collect_seqs(&root, 3, wm);
1039        assert_eq!(seqs, [1, 2]);
1040        assert_eq!(got.len(), 2);
1041        assert_eq!(stats.skipped, 1);
1042    }
1043
1044    #[test]
1045    fn another_nodes_segments_are_left_alone() {
1046        let root = tmpdir("twonodes");
1047        let a = Wal::open(&root, 0x11).unwrap();
1048        let b = Wal::open(&root, 0x22).unwrap();
1049        a.append(Signal::Logs, b"from-a").unwrap();
1050        b.append(Signal::Logs, b"from-b").unwrap();
1051        a.sync().unwrap();
1052        b.sync().unwrap();
1053
1054        // Two replicas sharing a volume (section 12) each replay only their own log.
1055        // Replaying the other's would double-write data it already acked.
1056        let (got, _) = collect(&root, 0x11, [0, 0, 0]);
1057        assert_eq!(got, vec![(Signal::Logs, b"from-a".to_vec())]);
1058    }
1059
1060    #[test]
1061    fn truncate_removes_covered_segments_and_never_the_open_one() {
1062        let root = tmpdir("truncate");
1063        let wal = Wal::open(&root, 7).unwrap();
1064        wal.append(Signal::Logs, b"old").unwrap();
1065        {
1066            // Force a roll without writing 64 MiB.
1067            let mut inner = wal.inner.lock().unwrap();
1068            inner.written = SEGMENT_BYTES;
1069        }
1070        wal.append(Signal::Logs, b"new").unwrap();
1071        assert_eq!(Wal::segments(&wal.dir, 7).unwrap().len(), 2);
1072
1073        // Watermark covers seq 0, the first segment's only frame.
1074        assert_eq!(wal.truncate(1).unwrap(), 1);
1075        let segments = Wal::segments(&wal.dir, 7).unwrap();
1076        assert_eq!(segments.len(), 1, "the open segment is never unlinked");
1077
1078        let (got, _) = collect(&root, 7, [0, 0, 0]);
1079        assert_eq!(got, vec![(Signal::Logs, b"new".to_vec())]);
1080    }
1081
1082    #[test]
1083    fn truncate_keeps_a_segment_whose_tail_is_still_uncovered() {
1084        let root = tmpdir("truncate-partial");
1085        let wal = Wal::open(&root, 8).unwrap();
1086        wal.append(Signal::Logs, b"covered").unwrap(); // seq 0
1087        wal.append(Signal::Logs, b"not-yet").unwrap(); // seq 1
1088        {
1089            let mut inner = wal.inner.lock().unwrap();
1090            inner.written = SEGMENT_BYTES;
1091        }
1092        wal.append(Signal::Logs, b"newest").unwrap(); // seq 2, new segment
1093
1094        // The last frame decides. Dropping the first segment at a watermark of
1095        // 1 would lose seq 1, which no block covers yet.
1096        assert_eq!(wal.truncate(1).unwrap(), 0);
1097        assert_eq!(wal.truncate(2).unwrap(), 1);
1098    }
1099
1100    #[test]
1101    fn a_wrong_frame_header_is_named_by_what_is_wrong_with_it() {
1102        // The header fields, one test, because the property is the set: a
1103        // reader that says "corrupt" for all of them is a reader nobody can
1104        // debug a real crash with. The body's two failures — a length above the
1105        // maximum, a checksum that does not match — have their own tests above,
1106        // since what matters about those is *when* they are checked.
1107        let root = tmpdir("corrupt-frames");
1108        let wal = Wal::open(&root, 0x11).unwrap();
1109        wal.append(Signal::Logs, b"body").unwrap();
1110        wal.sync().unwrap();
1111        let good = fs::read(root.join(".wal").join("00000011-00000000000000000000.wal")).unwrap();
1112        assert_eq!(good.len(), HEADER_LEN + 4 + CRC_LEN);
1113
1114        let broken = |f: &dyn Fn(&mut Vec<u8>)| -> Error {
1115            let mut bytes = good.clone();
1116            f(&mut bytes);
1117            let path = root.join("mangled.wal");
1118            fs::write(&path, &bytes).unwrap();
1119            let mut reader = FrameReader::open(&path).unwrap();
1120            let e = reader.next().unwrap().unwrap_err();
1121            // A reader that has yielded an error is finished: the bytes after a
1122            // frame it could not measure are not frames.
1123            assert!(reader.next().is_none());
1124            e
1125        };
1126        // `matches!` and not a `match` with a panicking arm: the arm that says
1127        // "that was not a corrupt frame at all" is a line no passing run
1128        // executes, and the assertion message carries the same information.
1129        let corrupt = |f: &dyn Fn(&mut Vec<u8>), want: &str| {
1130            let e = broken(f);
1131            assert!(
1132                matches!(&e, Error::WalCorrupt { why, .. } if *why == want),
1133                "expected {want:?}, got {e}"
1134            );
1135        };
1136
1137        corrupt(&|b| b[0] ^= 0xff, "bad frame magic");
1138        corrupt(&|b| b[6] = 0xfe, "unknown signal in frame header");
1139        corrupt(&|b| b.truncate(HEADER_LEN + 1), "truncated frame body");
1140
1141        // The version is the one that is not a `WalCorrupt`: a segment written
1142        // by a future Mira is intact, and saying "corrupt" about it would send
1143        // whoever downgraded looking for a disk fault.
1144        let e = broken(&|b| b[4..6].copy_from_slice(&(WAL_VERSION + 7).to_le_bytes()));
1145        assert!(
1146            matches!(&e, Error::WalVersion { found, expected, .. }
1147                if (*found, *expected) == (WAL_VERSION + 7, WAL_VERSION)),
1148            "expected a version error naming both sides, got {e}"
1149        );
1150    }
1151
1152    #[test]
1153    fn replaying_an_absent_directory_is_not_an_error() {
1154        let root = tmpdir("empty");
1155        let (got, stats) = collect(&root, 9, [0, 0, 0]);
1156        assert!(got.is_empty());
1157        assert_eq!(stats, Replayed::default());
1158    }
1159
1160    /// Bytes for a frame whose header promises more body than follows it —
1161    /// exactly what a crash between the header `write_all` and the body one
1162    /// leaves at the tail of a segment. Hand-built rather than produced by
1163    /// chopping a real segment, because a *first* frame has to be torn to reach
1164    /// the case where the segment's name is the sequence being resumed at.
1165    fn torn_frame_bytes(seq: u64) -> Vec<u8> {
1166        let mut bytes = Vec::new();
1167        bytes.extend_from_slice(&MAGIC.to_le_bytes());
1168        bytes.extend_from_slice(&WAL_VERSION.to_le_bytes());
1169        bytes.push(Signal::Logs as u8);
1170        bytes.push(0); // pad
1171        bytes.extend_from_slice(&seq.to_le_bytes());
1172        bytes.extend_from_slice(&64u32.to_le_bytes()); // promises 64 body bytes
1173        assert_eq!(bytes.len(), HEADER_LEN);
1174        bytes.extend_from_slice(b"only-a-few"); // ... and delivers ten
1175        bytes
1176    }
1177
1178    /// A crash mid-`write(2)` leaves half a frame at the tail of a segment, and
1179    /// the log has to *open* over it: refusing turns the ordinary crash this
1180    /// log exists to survive into a node that will not boot at all. What must
1181    /// not happen either is resuming onto the torn segment's own name —
1182    /// everything appended behind a tear is invisible to every later replay,
1183    /// which is the silent loss of acknowledged data the log exists to prevent.
1184    #[test]
1185    fn a_torn_tail_does_not_stop_the_log_opening_or_get_appended_behind() {
1186        // The tail of a segment that holds a whole frame before the tear.
1187        let root = tmpdir("open-torn-tail");
1188        let wal = Wal::open(&root, 0x4a).unwrap();
1189        wal.append(Signal::Logs, b"kept").unwrap();
1190        wal.append(Signal::Logs, b"in-flight").unwrap();
1191        wal.sync().unwrap();
1192        let path = {
1193            let inner = wal.inner.lock().unwrap();
1194            inner.path.clone()
1195        };
1196        drop(wal);
1197        let len = fs::metadata(&path).unwrap().len();
1198        OpenOptions::new()
1199            .write(true)
1200            .open(&path)
1201            .unwrap()
1202            .set_len(len - 4)
1203            .unwrap();
1204
1205        let wal = Wal::open(&root, 0x4a).expect("a torn tail is a boot condition, not an error");
1206        assert_eq!(wal.append(Signal::Logs, b"after").unwrap(), 1);
1207        wal.sync().unwrap();
1208        let (got, stats) = collect(&root, 0x4a, [0, 0, 0]);
1209        assert_eq!(
1210            got,
1211            vec![
1212                (Signal::Logs, b"kept".to_vec()),
1213                (Signal::Logs, b"after".to_vec())
1214            ],
1215            "the frame before the tear and the one after the restart both replay"
1216        );
1217        assert_eq!(stats.torn_segments, 1);
1218
1219        // The harder shape: the crash was during the very first frame of a
1220        // fresh segment, so the segment's name *is* the sequence a naive resume
1221        // would pick, and the new frame would be written behind the tear.
1222        let root = tmpdir("open-torn-head");
1223        let wal = Wal::open(&root, 0x4b).unwrap();
1224        wal.append(Signal::Logs, b"kept").unwrap(); // seq 0, segment ...000
1225        wal.sync().unwrap();
1226        drop(wal);
1227        let head = root
1228            .join(".wal")
1229            .join(format!("{:08x}-{:020}.wal", 0x4b, 1));
1230        fs::write(&head, torn_frame_bytes(1)).unwrap();
1231
1232        let wal = Wal::open(&root, 0x4b).unwrap();
1233        assert_eq!(
1234            wal.next_seq(),
1235            2,
1236            "the torn segment's own name is not reused"
1237        );
1238        wal.append(Signal::Logs, b"after").unwrap();
1239        wal.sync().unwrap();
1240        let (got, stats) = collect(&root, 0x4b, [0, 0, 0]);
1241        assert_eq!(
1242            got,
1243            vec![
1244                (Signal::Logs, b"kept".to_vec()),
1245                (Signal::Logs, b"after".to_vec())
1246            ],
1247            "a frame acked after the restart is still replayable"
1248        );
1249        assert_eq!(stats.torn_segments, 1, "the torn segment is still counted");
1250    }
1251
1252    /// A rolled segment is handed to the next `sync` instead of being forced by
1253    /// the appender that rolled it (see `Inner::retired` for the measured
1254    /// reason). If `sync` did not pick it up, a node that goes quiet after a
1255    /// roll would leave a whole 64 MiB segment exposed to a power cut for ever
1256    /// — the roll would have turned the timer off for the data it retired.
1257    #[test]
1258    fn the_next_sync_forces_a_segment_the_appender_rolled_away() {
1259        let root = tmpdir("retired");
1260        let wal = Wal::open(&root, 0x5a).unwrap();
1261        wal.append(Signal::Logs, b"in the outgoing segment")
1262            .unwrap();
1263        {
1264            // Rolled by hand: the alternative is 64 MiB of appends, and what is
1265            // under test is the hand-off, not the size trigger.
1266            let mut inner = wal.inner.lock().unwrap();
1267            wal.roll(&mut inner).unwrap();
1268            assert_eq!(inner.retired.len(), 1, "queued for the timer, not forced");
1269            assert!(!inner.dirty, "and the new segment has nothing in it");
1270        }
1271
1272        wal.sync().unwrap();
1273        {
1274            let inner = wal.inner.lock().unwrap();
1275            assert!(
1276                inner.retired.is_empty(),
1277                "taken, so a later tick does not pay for the same barrier again"
1278            );
1279        }
1280        // A quiet log stays quiet: with nothing dirty and nothing retired the
1281        // next tick must not issue a barrier at all, which is what makes the
1282        // 250 ms timer free on an idle node.
1283        wal.sync().unwrap();
1284
1285        let (got, _) = collect(&root, 0x5a, [0, 0, 0]);
1286        assert_eq!(
1287            got,
1288            vec![(Signal::Logs, b"in the outgoing segment".to_vec())],
1289            "rolling is not a truncation"
1290        );
1291    }
1292
1293    /// A segment created and never appended to is what a crash between `roll`
1294    /// and the first append leaves. It has no highest sequence, so a `truncate`
1295    /// that only deleted segments *below* the watermark would keep it for ever
1296    /// and leak one file per crash into the directory every boot scans.
1297    #[test]
1298    fn truncate_drops_the_empty_segment_a_crash_left_behind() {
1299        let root = tmpdir("empty-segment");
1300        let wal = Wal::open(&root, 0x6b).unwrap();
1301        wal.append(Signal::Logs, b"live").unwrap();
1302        let stale = root
1303            .join(".wal")
1304            .join(format!("{:08x}-{:020}.wal", 0x6b, 7));
1305        File::create(&stale).unwrap();
1306        assert_eq!(Wal::segments(&wal.dir, 0x6b).unwrap().len(), 2);
1307
1308        // Watermark 0: nothing at all is published, so the only reason this
1309        // file can go is that it holds no frames.
1310        assert_eq!(wal.truncate(0).unwrap(), 1);
1311        assert!(!stale.exists());
1312        let (got, _) = collect(&root, 0x6b, [0, 0, 0]);
1313        assert_eq!(
1314            got,
1315            vec![(Signal::Logs, b"live".to_vec())],
1316            "the open segment is untouched"
1317        );
1318    }
1319
1320    /// The WAL directory is a directory on somebody's volume: it collects
1321    /// half-renamed files, another replica's segments and whatever a backup
1322    /// tool drops. Each one mistaken for a segment is a spurious replay or a
1323    /// boot failure, so the filter is a correctness property and not tidiness
1324    /// — and the two ways `read_dir` fails have to stay distinguishable,
1325    /// because absent is a fresh volume and unreadable is a mount to shout
1326    /// about. Answering "no segments" for the second would skip the replay and
1327    /// silently drop everything the log was holding.
1328    #[test]
1329    fn segments_lists_only_this_nodes_well_formed_segments() {
1330        let root = tmpdir("listing");
1331        let wal = Wal::open(&root, 0x7c).unwrap();
1332        wal.append(Signal::Logs, b"real").unwrap();
1333        let dir = root.join(".wal");
1334        for junk in [
1335            "0000007c-00000000000000000009.log", // right shape, wrong suffix
1336            "0000007c-not-a-number.wal",         // suffix, but no sequence
1337            "0000007c-.wal",                     // empty sequence
1338            "readme.txt",                        // not ours in any way
1339            "0000007d-00000000000000000000.wal", // the other replica's
1340        ] {
1341            File::create(dir.join(junk)).unwrap();
1342        }
1343
1344        let listed = Wal::segments(&dir, 0x7c).unwrap();
1345        assert_eq!(listed.len(), 1, "only the real segment, got {listed:?}");
1346        assert_eq!(listed[0].1, 0);
1347
1348        // A directory that is not there yet is the first boot on a fresh
1349        // volume, and has to read as empty rather than as an error.
1350        assert!(
1351            Wal::segments(&root.join("never-created"), 0x7c)
1352                .unwrap()
1353                .is_empty()
1354        );
1355        // One that cannot be listed is a different thing and must say so.
1356        let notdir = root.join("a-file-not-a-dir");
1357        fs::write(&notdir, b"x").unwrap();
1358        assert!(matches!(
1359            Wal::segments(&notdir, 0x7c),
1360            Err(Error::Io { .. })
1361        ));
1362    }
1363
1364    /// A `read(2)` that fails is not the end of a segment. The two are one
1365    /// return value apart in `FrameReader` and a world apart in meaning: an
1366    /// EIO mistaken for a clean end would silently drop every frame behind it
1367    /// and let `truncate` delete the segment as if it had all been published.
1368    /// The error also has to name the file, because the operator's next move
1369    /// is to go and look at that one inode.
1370    #[test]
1371    fn a_failed_read_is_never_mistaken_for_the_end_of_a_segment() {
1372        let root = tmpdir("read-error");
1373        // Opening a directory read-only succeeds on Unix and the first `read`
1374        // on it fails with EISDIR: a descriptor whose reads really do fail,
1375        // driven through the real reader, with no fault injection to arrange.
1376        // ponytail: this reaches the header read only. The body and checksum
1377        // reads want a descriptor that succeeds for 20 bytes and then fails,
1378        // which needs a FUSE mount or an injected `Read` — worth it only if
1379        // those two lines ever diverge from this one.
1380        let notafile = root.join("a-directory");
1381        fs::create_dir_all(&notafile).unwrap();
1382        let mut reader =
1383            FrameReader::open(&notafile).expect("opening a directory is not itself the failure");
1384        let err = reader
1385            .next_frame()
1386            .expect_err("a failed read is an error, not an end of segment");
1387        match &err {
1388            Error::Io { path, source } => {
1389                assert_eq!(path, &notafile, "the error names the segment: {err}");
1390                assert!(
1391                    source.raw_os_error().is_some(),
1392                    "the errno is carried through rather than synthesised: {err}"
1393                );
1394            }
1395            other => panic!("a failing read is an io error, got {other}"),
1396        }
1397
1398        // And through the iterator, which is how `replay` and `truncate` see
1399        // it: an `Err` item, not the `None` that would end the segment.
1400        let mut reader = FrameReader::open(&notafile).unwrap();
1401        assert!(
1402            matches!(reader.next(), Some(Err(Error::Io { .. }))),
1403            "the failure is yielded, not swallowed into an end of segment"
1404        );
1405        assert!(reader.next().is_none(), "and the reader is spent after it");
1406
1407        // The clean end, for contrast, is the *only* thing that produces
1408        // `None`: a segment with no frames left in it.
1409        let empty = root.join("empty.wal");
1410        File::create(&empty).unwrap();
1411        assert!(
1412            FrameReader::open(&empty)
1413                .unwrap()
1414                .next_frame()
1415                .unwrap()
1416                .is_none(),
1417            "an end of file is `Ok(None)`, and only a tear is an `Err`"
1418        );
1419    }
1420
1421    #[test]
1422    fn signal_bytes_match_the_block_directory_names() {
1423        // These strings are the join between a WAL watermark and the block it
1424        // came from; a rename on one side only would silently put a watermark
1425        // on the wrong signal.
1426        assert_eq!(Signal::Logs.as_str(), "logs");
1427        assert_eq!(Signal::Traces.as_str(), "traces");
1428        assert_eq!(Signal::Metrics.as_str(), "metrics");
1429        for s in Signal::ALL {
1430            assert_eq!(Signal::from_u8(s as u8), Some(s));
1431        }
1432        assert_eq!(Signal::from_u8(3), None);
1433    }
1434
1435    /// The invariant [`Wal::append_then`] is built around, now that a signal has
1436    /// more than one flusher.
1437    ///
1438    /// With one flusher a block could take `max(seq) + 1` as its watermark,
1439    /// because a signal's frames reached their blocks in sequence order. With
1440    /// two, frame 5 can still be in shard 0's builder when shard 1 seals the
1441    /// block holding frame 6 — and `max + 1` would say 7, so the next boot
1442    /// would skip frame 5. That is silent loss, which is the one failure this
1443    /// log exists to prevent.
1444    #[test]
1445    fn a_shard_may_not_claim_a_watermark_over_a_frame_a_sibling_still_holds() {
1446        let root = tmpdir("watermark-for");
1447        let wal = Wal::open(&root, 7).unwrap();
1448        for b in [&b"l0"[..], b"l1", b"l2", b"l3"] {
1449            wal.append(Signal::Logs, b).unwrap();
1450        }
1451        // Shard 0 took 0 and 2, shard 1 took 1 and 3 — the stride `pipeline`
1452        // allocates with.
1453        let (even, odd) = (vec![0, 2], vec![1, 3]);
1454
1455        // Shard 1 seals first. Frame 0 is still in shard 0's builder, so the
1456        // highest honest watermark is 0: nothing is published.
1457        assert_eq!(wal.watermark_for(Signal::Logs, &odd), 0);
1458        wal.published(Signal::Logs, &odd);
1459        // And now shard 0's block covers everything left, so it may claim the
1460        // whole log — `next_seq`, not `max + 1`, which is the same number here
1461        // and would not be if a fifth frame were in flight.
1462        assert_eq!(wal.watermark_for(Signal::Logs, &even), 4);
1463
1464        // Signals do not see each other: a traces frame in flight cannot hold
1465        // back a logs watermark, which is what the per-signal array is for. The
1466        // logs watermark goes past it, and the traces one does not — a sequence
1467        // is global, and only the array makes skipping another signal's frame
1468        // safe.
1469        wal.append(Signal::Traces, b"t4").unwrap();
1470        assert_eq!(wal.watermark_for(Signal::Logs, &even), 5);
1471        assert_eq!(wal.watermark_for(Signal::Traces, &[]), 4);
1472    }
1473
1474    /// A block that failed to publish keeps its frames replayable.
1475    ///
1476    /// The bug this pins predates sharding: with `max(seq) + 1` the *next*
1477    /// block's watermark stepped over the failed one's frames, so a crash after
1478    /// a failed publish lost them. Retiring sequences only on success fixes
1479    /// both at once.
1480    #[test]
1481    fn a_block_that_never_landed_holds_the_watermark_where_it_was() {
1482        let root = tmpdir("watermark-fail");
1483        let wal = Wal::open(&root, 8).unwrap();
1484        for b in [&b"l0"[..], b"l1"] {
1485            wal.append(Signal::Logs, b).unwrap();
1486        }
1487        // The first block seals holding frame 0 and claims 1 — exclusive, so
1488        // that says "frame 0 is covered, frame 1 is not" — and then fails to
1489        // land, so nothing is retired.
1490        assert_eq!(wal.watermark_for(Signal::Logs, &[0]), 1);
1491        // The second one may not pretend the first succeeded.
1492        assert_eq!(
1493            wal.watermark_for(Signal::Logs, &[1]),
1494            0,
1495            "frame 0 is still unpublished, so no block may claim past it"
1496        );
1497        wal.published(Signal::Logs, &[1]);
1498        // And when the first block is retried, it covers the lot.
1499        assert_eq!(wal.watermark_for(Signal::Logs, &[0]), 2);
1500
1501        // Replay is the reason it matters: the frames are both still there.
1502        let (got, _) = collect(&root, 8, [0, 0, 0]);
1503        assert_eq!(got.len(), 2);
1504    }
1505
1506    /// Replay re-arms the pending set, or the frames a crash nearly lost are
1507    /// the ones the first seal after boot steps over.
1508    #[test]
1509    fn a_replayed_frame_counts_against_the_watermark_again() {
1510        let root = tmpdir("watermark-reframe");
1511        let wal = Wal::open(&root, 9).unwrap();
1512        for b in [&b"l0"[..], b"l1"] {
1513            wal.append(Signal::Logs, b).unwrap();
1514        }
1515        drop(wal);
1516
1517        let wal = Wal::open(&root, 9).unwrap();
1518        assert_eq!(
1519            wal.watermark_for(Signal::Logs, &[]),
1520            2,
1521            "a fresh log knows nothing is outstanding until replay says so"
1522        );
1523        let (_, seqs, _) = collect_seqs(&root, 9, [0, 0, 0]);
1524        for seq in &seqs {
1525            wal.reframed(Signal::Logs, *seq);
1526        }
1527        assert_eq!(seqs, vec![0, 1]);
1528        // Shard 1 gets frame 1 and seals it before shard 0 has flushed frame 0.
1529        assert_eq!(wal.watermark_for(Signal::Logs, &[1]), 0);
1530    }
1531}