Skip to main content

mira_core/
block.rs

1//! Immutable block storage.
2//!
3//! A block is a *directory* holding one Arrow IPC file per table:
4//!
5//! ```text
6//! <root>/logs/p=<epoch_hour>/<min_ts:020>-<max_ts:020>-<node:08x>-<seq:012>-<wal_hi:020>/
7//!     logs.arrow  log_attrs.arrow  resources.arrow  resource_attrs.arrow  scope_attrs.arrow
8//! ```
9//!
10//! The filesystem *is* the manifest. Every reason a LSM engine needs a MANIFEST
11//! file is absent here: Mira publishes exactly one immutable object per commit
12//! via one directory rename, never mutates a published block, and never needs a
13//! multi-file atomic operation. The directory name carries the full pruning key,
14//! so booting is one `readdir` per partition with zero file opens, and there is
15//! no metadata state that can disagree with the data. That is what makes the
16//! process stateless in the sense that matters: kill it, restart it, point a
17//! second one at the same directory read-only — nothing to reconcile.
18//!
19//! Publish is write-tmp / fsync-files / fsync-tmpdir / rename-dir / fsync-parent
20//! / fsync-grandparent — the last because the partition directory itself is a
21//! new entry in `<root>/<signal>` on the first block of every hour.
22//! Directory rename is atomic on POSIX, so a block is either wholly visible or
23//! wholly absent; there is no torn state for recovery to clean up, and therefore
24//! no write-ahead log.
25//!
26//! Retention is `remove_dir_all` on the block directory. POSIX guarantees this is
27//! safe against in-flight readers: a mapping holds a reference to the inode that
28//! `close()` does not drop, so a query holding an `Arc<Mmap>` keeps reading
29//! correct data from an unlinked file until it drops the mapping. The `Arc` is
30//! the refcount; no lease protocol is needed.
31
32use std::fs::{self, File};
33use std::io::{self, BufWriter, Write};
34use std::path::{Path, PathBuf};
35use std::ptr::NonNull;
36use std::sync::Arc;
37
38use arrow_array::RecordBatch;
39use arrow_buffer::Buffer;
40use arrow_ipc::MetadataVersion;
41use arrow_ipc::reader::{FileDecoder, read_footer_length};
42use arrow_ipc::root_as_footer;
43use arrow_ipc::writer::{FileWriter, IpcWriteOptions};
44use memmap2::Mmap;
45
46use crate::error::{Error, IoContext, Result};
47use crate::signal::Sealed;
48
49/// Leading bytes of every Arrow IPC file. arrow-rs's own reader seeks straight
50/// to the trailer and never checks this, so a truncated-from-the-front file
51/// would decode as garbage; we check it ourselves.
52const MAGIC: &[u8; 6] = b"ARROW1";
53
54/// CRC32 of the record-batch body, stored in the footer's custom metadata along
55/// with the exact byte length it covers. arrow-ipc has no checksum of its own: a
56/// valid footer over a corrupt body decodes silently into wrong answers.
57///
58/// The length is stored rather than derived from the footer offset because
59/// `finish()` appends an end-of-stream marker *after* the last batch and before
60/// the footer, so "everything before the footer" and "everything the writer had
61/// emitted when we snapshotted" differ by those bytes.
62const CRC_KEY: &str = "mira.crc32";
63const CRC_LEN_KEY: &str = "mira.crc32.len";
64
65/// On-disk format version, stamped into every table's footer metadata beside
66/// the CRC — a place that already exists, so no second file and no second
67/// fsync. Read before the reader trusts anything else in the file.
68///
69/// What it is for: three of the five logs tables are read positionally
70/// (`attrs.rs`, `query.rs`: `column(3)` is the `str` value column), so inserting
71/// a field into a schema in `schema.rs` does not fail against blocks already on
72/// disk — it reinterprets them. Without a version there is nothing a reader can
73/// look at to tell the two layouts apart.
74///
75/// ponytail: this records the version and refuses the future — a block from a
76/// newer Mira is a named error, not a misparse — and that is all. It does not
77/// make an *older* block readable by a newer binary, because there is no
78/// migration to run and one version of one layout to run it on. The upgrade
79/// path, in order: move the positional readers onto `column_by_name` (which the
80/// root tables already use, which is why `LogRecord.event_name` could be added
81/// without rewriting a block); then a column insertion needs no bump at all.
82/// Until that lands, the rule is that any change to a published table's column
83/// list bumps [`FORMAT_VERSION`], and the compatibility branch for the older
84/// layout goes next to the check in [`open_table`].
85const FORMAT_KEY: &str = "mira.format";
86
87/// The format this binary writes, and the highest it will read.
88pub const FORMAT_VERSION: u32 = 1;
89
90/// The version blocks written before [`FORMAT_KEY`] existed are treated as.
91///
92/// They are byte-identical to version 1 — the key was added without changing
93/// anything else about the file — so absent means 1 rather than "refuse it".
94/// A format change that orphaned every block already on somebody's disk would
95/// be a worse bug than the one the version exists to catch.
96const LEGACY_VERSION: u32 = 1;
97
98/// The ZSTD level every compressed table is written at.
99///
100/// The same as arrow-ipc's default, and set explicitly anyway: the ratio in
101/// `docs/architecture.md` section 11 is a published number, and an upstream
102/// default that moved would move it without anything in this tree changing.
103///
104/// 3 and not higher, which was measured and rejected. Over 8 real blocks per
105/// signal, level 9 buys **2.4%** fewer bytes (8.41x against 8.21x) and takes
106/// **1.4x** as long to rewrite a block. Compaction is a background sweep sharing
107/// cores with ingest, and a sweep that falls behind leaves blocks uncompressed,
108/// so 2.4% does not buy the risk. Nearly all of the ratio comes from the
109/// dictionary column in [`crate::schema::ATTRS`], not from the level.
110const ZSTD_LEVEL: i32 = 3;
111
112/// Where the schema message starts: `ARROW1` padded up to [`ALIGNMENT`], which
113/// is exactly how `FileWriter` places it (`pad_to_alignment` over the same
114/// constant we hand it in [`write_table_with`]).
115const HEADER_LEN: usize = MAGIC.len().next_multiple_of(ALIGNMENT);
116
117/// The prefix of every encapsulated IPC message since the legacy format was
118/// retired. We write V5 with `write_legacy_ipc_format` off, so a message that
119/// does not start with it is not a message this wrote.
120const CONTINUATION: [u8; 4] = [0xff; 4];
121
122/// A block file whose own metadata does not describe it.
123///
124/// Every length and offset in [`open_table`] is read *out of the file*, which
125/// makes it attacker-controlled-equivalent — a disk bit flip being the
126/// realistic case. Unchecked, they index the mapping out of bounds, and
127/// `panic = "abort"` (workspace release profile) turns that into a process
128/// death rather than a caught error: every open block and in-flight export
129/// dies with it, the bad block is still on disk afterwards, and the restart
130/// hits the same byte. One bad byte becomes an unattended crashloop. As an
131/// error it is one table a query skips.
132///
133/// ponytail: `io::ErrorKind::InvalidData` inside `Error::Io` rather than an
134/// `Error::CorruptBlock` of its own, following the NUL-byte case in
135/// [`fs_type`]. Every caller today treats a block it cannot read the same way
136/// whatever the reason; give it a variant when one of them needs to match on
137/// it.
138fn corrupt(path: &Path, what: String) -> Error {
139    Error::Io {
140        path: path.to_path_buf(),
141        source: io::Error::new(io::ErrorKind::InvalidData, what),
142    }
143}
144
145/// Buffer alignment for published blocks.
146///
147/// The *correctness* floor is `align_of::<T>()` — 16 bytes for the widest thing
148/// we store. 64 is the cache-line / SIMD figure and costs a few padding bytes
149/// per buffer. Blocks are written at 64 and read with `require_alignment(true)`
150/// so that a regression in the write path fails loudly instead of making
151/// arrow-rs quietly memcpy the entire body out of the mapping.
152const ALIGNMENT: usize = 64;
153
154const NANOS_PER_HOUR: i64 = 3_600 * 1_000_000_000;
155
156/// A `Write` that hashes everything passing through it.
157///
158/// Sits between the IPC writer and the `BufWriter` so it sees the byte stream
159/// exactly as it will land on disk, and can be read mid-stream (via
160/// `FileWriter::get_ref`) to stamp the CRC into the footer before `finish()`.
161struct CrcWriter<W> {
162    inner: W,
163    hasher: crc32fast::Hasher,
164    written: u64,
165}
166
167impl<W: Write> CrcWriter<W> {
168    fn new(inner: W) -> Self {
169        Self {
170            inner,
171            hasher: crc32fast::Hasher::new(),
172            written: 0,
173        }
174    }
175
176    /// Bytes hashed so far, and their CRC.
177    fn checksum(&self) -> (u64, u32) {
178        (self.written, self.hasher.clone().finalize())
179    }
180}
181
182impl<W: Write> Write for CrcWriter<W> {
183    fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
184        let n = self.inner.write(buf)?;
185        self.hasher.update(&buf[..n]);
186        self.written += n as u64;
187        Ok(n)
188    }
189
190    fn flush(&mut self) -> io::Result<()> {
191        self.inner.flush()
192    }
193}
194
195/// Write one table as a self-contained, checksummed, 64-byte-aligned IPC file
196/// and fsync it. Uncompressed on purpose: IPC body compression forces the reader
197/// to decompress into fresh allocations, which is mutually exclusive with the
198/// zero-copy mmap read path.
199///
200/// Never point this at a path some reader may have mapped. It truncates, and a
201/// mapping over a truncated file is a SIGBUS on the next page touched, not an
202/// error anyone can catch. Replacing a published table means staging a new file
203/// and renaming, which is what [`compact`] does.
204pub fn write_table(path: &Path, batch: &RecordBatch) -> Result<()> {
205    write_table_with(path, std::slice::from_ref(batch), None)
206}
207
208/// The same file, ZSTD-compressed per buffer.
209///
210/// Only for the cold tier (section 11): a compressed block cannot be read out of its
211/// mapping, so this trades the zero-copy property for bytes. The retention
212/// worker applies it to blocks old enough that nothing is scanning them, which
213/// is where the trade is free.
214///
215/// The reader needs no flag — the IPC metadata records the codec per batch, so
216/// a directory can hold both tiers at once and does, mid-rewrite.
217pub fn write_table_zstd(path: &Path, batch: &RecordBatch) -> Result<()> {
218    write_table_with(
219        path,
220        std::slice::from_ref(batch),
221        Some(arrow_ipc::CompressionType::ZSTD),
222    )
223}
224
225/// The same again as LZ4_FRAME, so the `tier` example can price the pure-Rust
226/// alternative against the C one on real blocks. Nothing in the engine writes
227/// LZ4; see the decisions table in `docs/architecture.md`.
228pub fn write_table_lz4(path: &Path, batch: &RecordBatch) -> Result<()> {
229    write_table_with(
230        path,
231        std::slice::from_ref(batch),
232        Some(arrow_ipc::CompressionType::LZ4_FRAME),
233    )
234}
235
236fn write_table_with(
237    path: &Path,
238    batches: &[RecordBatch],
239    codec: Option<arrow_ipc::CompressionType>,
240) -> Result<()> {
241    let Some(first) = batches.first() else {
242        return Ok(());
243    };
244    let file = File::create(path).ctx(path)?;
245    let opts = IpcWriteOptions::try_new(ALIGNMENT, false, MetadataVersion::V5)?;
246    let opts = match codec {
247        // LZ4 rejects a configured level outright rather than ignoring one, so
248        // the level goes on only where it means something.
249        Some(arrow_ipc::CompressionType::ZSTD) => opts
250            .try_with_compression(codec)?
251            .try_with_compression_level(Some(ZSTD_LEVEL))?,
252        Some(c) => opts.try_with_compression(Some(c))?,
253        None => opts,
254    };
255    let mut w = FileWriter::try_new_with_options(
256        CrcWriter::new(BufWriter::new(file)),
257        &first.schema(),
258        opts,
259    )?;
260    for batch in batches {
261        w.write(batch)?;
262    }
263
264    // Everything written so far is the body; the footer is emitted by finish().
265    let (len, crc) = w.get_ref().checksum();
266    w.write_metadata(FORMAT_KEY, FORMAT_VERSION.to_string());
267    w.write_metadata(CRC_KEY, format!("{crc:08x}"));
268    w.write_metadata(CRC_LEN_KEY, len.to_string());
269    w.finish()?;
270
271    let mut buf = w.into_inner()?;
272    buf.flush().ctx(path)?;
273    let file = buf.inner.into_inner().map_err(|e| Error::Io {
274        path: path.to_path_buf(),
275        source: e.into_error(),
276    })?;
277    crate::sync_all(&file).ctx(path)?;
278    Ok(())
279}
280
281/// A published block, discovered by reading directory names.
282#[derive(Debug, Clone, PartialEq, Eq)]
283pub struct BlockRef {
284    pub dir: PathBuf,
285    pub min_ts: i64,
286    pub max_ts: i64,
287    /// Which replica wrote this block. See [`node_id`].
288    pub node: u32,
289    pub seq: u64,
290    /// One past the highest write-ahead-log sequence whose records are in this
291    /// block, or `0` for a block published before the log existed.
292    ///
293    /// This is the entire manifest. Principle 4 says there is no coordination
294    /// state and the block directory *is* the manifest, so the one fact WAL
295    /// replay needs — how far the log has been absorbed — is carried in the
296    /// name rather than in a file someone has to keep consistent with the
297    /// blocks. Recovery therefore stays the `readdir` section 4 promises it is.
298    pub wal_hi: u64,
299}
300
301impl BlockRef {
302    /// True if this block could contain a row in `[from, to]`. The whole point
303    /// of the naming scheme: pruning without opening a single file.
304    pub fn overlaps(&self, from: i64, to: i64) -> bool {
305        self.min_ts <= to && self.max_ts >= from
306    }
307}
308
309/// One candidate the read path may have to open: a published block directory,
310/// or the snapshot of a block that is still open (section 4).
311///
312/// Everything a scan needs before it opens anything — the range to prune on, the
313/// identity a cursor is built from, and where the tables are. The two cases
314/// differ in exactly two ways: a snapshot has no directory to read sidecars out
315/// of, and its tables are already in memory.
316pub(crate) struct Src<'a> {
317    pub node: u32,
318    pub seq: u64,
319    pub min_ts: i64,
320    pub max_ts: i64,
321    /// `None` for a snapshot. Skipping the sidecar probes is not a special
322    /// case: "no filter file" already means "scan me" for every block published
323    /// before that filter existed.
324    pub dir: Option<&'a Path>,
325    tables: Option<&'a [(&'static str, RecordBatch)]>,
326}
327
328impl<'a> Src<'a> {
329    pub fn disk(b: &'a BlockRef) -> Src<'a> {
330        Src {
331            node: b.node,
332            seq: b.seq,
333            min_ts: b.min_ts,
334            max_ts: b.max_ts,
335            dir: Some(b.dir.as_path()),
336            tables: None,
337        }
338    }
339
340    pub fn open(o: &'a crate::signal::Open) -> Src<'a> {
341        Src {
342            node: o.node,
343            seq: o.seq,
344            min_ts: o.sealed.min_ts,
345            max_ts: o.sealed.max_ts,
346            dir: None,
347            tables: Some(&o.sealed.tables),
348        }
349    }
350
351    pub fn overlaps(&self, from: i64, to: i64) -> bool {
352        self.min_ts <= to && self.max_ts >= from
353    }
354
355    /// One named table, or `None` if this source does not have it.
356    ///
357    /// A missing table on disk is not an error: retention unlinking an expired
358    /// block underneath a scan is normal. An open block's tables are already
359    /// Arrow, under the same names the published files would carry — including
360    /// the empty ones, which `publish` skips and which read identically to
361    /// absent everywhere downstream.
362    pub fn load(&self, name: &str) -> Result<Option<RecordBatch>> {
363        match (self.tables, self.dir) {
364            (Some(tables), _) => Ok(tables
365                .iter()
366                .find(|(n, _)| *n == name)
367                .map(|(_, b)| b.clone())),
368            // `write_table` emits exactly one record batch per file, so row
369            // numbers are unambiguous and there is never a second batch to
370            // stitch.
371            (None, Some(dir)) => Ok(open_table_opt(&dir.join(format!("{name}.arrow")))?
372                .and_then(|t| t.batches.first().cloned())),
373            (None, None) => Ok(None),
374        }
375    }
376}
377
378/// The published blocks under `root/signal`, plus any open-block snapshots that
379/// have not yet been published under the same `(node, seq)`.
380///
381/// The published copy wins a collision: it is at least as complete, and it is
382/// the one whose cursors readers already hold. `(node, seq)` is enough to
383/// recognise it because the flusher snapshots under the sequence it will publish
384/// under — see [`crate::signal::Open`].
385pub(crate) fn sources<'a>(
386    disk: &'a [BlockRef],
387    open: &'a [std::sync::Arc<crate::signal::Open>],
388) -> Vec<Src<'a>> {
389    disk.iter()
390        .map(Src::disk)
391        .chain(
392            open.iter()
393                .filter(|o| !disk.iter().any(|b| b.node == o.node && b.seq == o.seq))
394                .map(|o| Src::open(o)),
395        )
396        .collect()
397}
398
399/// A replica's writer identity, derived from its name with no coordination.
400///
401/// The block name has to be unique across every process that can ever write to
402/// this directory tree, and it has to be so without asking anyone. Hashing the
403/// replica name gets that for free: in Kubernetes the name is the pod name,
404/// which the scheduler already guarantees is unique, so uniqueness is inherited
405/// from a namespace that exists rather than invented by a protocol we would then
406/// have to operate.
407/// Truncated to 32 bits: two replicas colliding needs ~77k of them on one
408/// volume, and a collision degrades to the loud `ENOTEMPTY` above rather than to
409/// anything silent.
410pub fn node_id(name: &str) -> u32 {
411    crate::identity::hash64(name.as_bytes()) as u32
412}
413
414/// `{min_ts}-{max_ts}-{node}-{seq}-{wal_hi}`.
415///
416/// `node` is what makes two active replicas sharing a volume safe. Without it,
417/// two writers allocate the same `seq` and the second `rename` lands on a
418/// non-empty directory — `ENOTEMPTY`, and a node that can never publish again.
419///
420/// Splitting on `-` is only safe because [`crate::signal::Sealed`] clamps both
421/// timestamps non-negative; a negative one would format with a leading `-` and
422/// make the name unparseable, which is to say invisible to `scan`.
423fn dir_name(min_ts: i64, max_ts: i64, node: u32, seq: u64, wal_hi: u64) -> String {
424    format!("{min_ts:020}-{max_ts:020}-{node:08x}-{seq:012}-{wal_hi:020}")
425}
426
427/// Parses both the five-field name above and the four-field name that predates
428/// the write-ahead log.
429///
430/// The old form has to keep working: a block directory written by an earlier
431/// build is still a valid block, and there is no migration step to run because
432/// there is no metadata store to migrate. A missing `wal_hi` reads as `0`,
433/// which is correct — those blocks came from a Mira with no log, so no
434/// sequence is covered by them and replay must not skip anything on their
435/// account.
436fn parse_dir_name(name: &str) -> Option<(i64, i64, u32, u64, u64)> {
437    let mut parts = name.split('-');
438    let min = parts.next()?.parse().ok()?;
439    let max = parts.next()?.parse().ok()?;
440    let node = u32::from_str_radix(parts.next()?, 16).ok()?;
441    let seq = parts.next()?.parse().ok()?;
442    let wal_hi = match parts.next() {
443        Some(field) => field.parse().ok()?,
444        None => 0,
445    };
446    if parts.next().is_some() {
447        return None;
448    }
449    Some((min, max, node, seq, wal_hi))
450}
451
452fn fsync_dir(path: &Path) -> Result<()> {
453    crate::sync_all(&File::open(path).ctx(path)?).ctx(path)
454}
455
456/// Atomically publish a set of tables as one block under `<root>/<signal>/`.
457///
458/// Returns the published directory.
459///
460/// Acking an OTLP export requires that its records are recoverable, and until
461/// [`crate::wal`] existed this call was the only thing that made them so — so
462/// the caller had to wait for it. It no longer does, provided the export is in
463/// the log: `wal_hi` is what records that, and it must be whatever
464/// [`crate::wal::Wal::watermark_for`] answers for the sequences in `sealed` —
465/// an *exclusive* watermark over the signal as a whole, not one past this
466/// block's own highest sequence, because sibling shards are filling their own
467/// blocks from the same log and hold sequences below it that are not here. Pass
468/// `0` when there is no log.
469///
470/// Getting `wal_hi` too high is the dangerous direction. [`wal_watermarks`]
471/// takes the maximum over every block of the signal, so a watermark that claims
472/// sequences no block holds makes replay skip them — silent loss, with the
473/// client holding a 200. Too low only costs a re-ingest.
474pub fn publish(
475    root: &Path,
476    signal: &str,
477    node: u32,
478    seq: u64,
479    wal_hi: u64,
480    sealed: &Sealed,
481) -> Result<BlockRef> {
482    let (min_ts, max_ts) = (sealed.min_ts, sealed.max_ts);
483    // The staging name carries `node` for the same reason the final one does:
484    // two replicas on one volume must not stage into the same directory. It
485    // also carries the timestamp range, because `node` alone is not enough: two
486    // replicas started with the same `--node` — a misconfiguration, but a silent
487    // one — walk the same `seq` from 0 and collide. The observed cost was two
488    // lost publishes in 107, both retryable; the unobserved one is worse, since
489    // B's `remove_dir_all` can empty a directory A is still writing tables into
490    // and the winner then publishes a block assembled from two sealed sets.
491    // Adding the range the final name already carries makes the path unique per
492    // block content, which is exactly the granularity the collision needs.
493    let staging = root.join(".tmp");
494    let tmp = staging.join(format!(
495        "{signal}-{node:08x}-{seq:012}-{min_ts:020}-{max_ts:020}"
496    ));
497    fs::create_dir_all(&staging).ctx(&staging)?;
498    // `create_dir`, not `create_dir_all`: the latter succeeds on a directory
499    // that already exists, which is how a leftover from a killed publish gets
500    // silently merged into this one. Colliding here is a retryable error, and
501    // `sweep_staging` clears the leftover at the next boot.
502    fs::create_dir(&tmp).ctx(&tmp)?;
503
504    let signal_dir = root.join(signal);
505    let partition = signal_dir.join(format!("p={}", min_ts.div_euclid(NANOS_PER_HOUR)));
506    let dir = partition.join(dir_name(min_ts, max_ts, node, seq, wal_hi));
507    let staged = stage(&tmp, sealed).and_then(|()| {
508        fs::create_dir_all(&partition).ctx(&partition)?;
509        fs::rename(&tmp, &dir).ctx(&dir)?;
510        fsync_dir(&partition)?;
511        // And the directory naming the partition. fsyncing `partition` persists
512        // the entries inside it, not the entry for it in its own parent — so on
513        // the first block of a new hour the block is durable and the directory
514        // holding it is unflushed metadata, which loses an acked block on XFS
515        // and btrfs (ext4's ordered journal happens to cover it). One extra
516        // fsync per 32 MB block, against the per-table fsyncs already paid.
517        fsync_dir(&signal_dir)
518    });
519
520    if let Err(e) = staged {
521        unwind_staging(&tmp);
522        return Err(e);
523    }
524
525    Ok(BlockRef {
526        dir,
527        min_ts,
528        max_ts,
529        node,
530        seq,
531        wal_hi,
532    })
533}
534
535/// Unwind the staging directory of a publish that did not complete.
536///
537/// Every step of [`publish`] `?`s, and the staging name is unique per block
538/// content, so nothing reuses it: without this the tables already written are
539/// stranded under `.tmp`, where `expire` — which only ever scans
540/// `<root>/<signal>` — will never see them. That matters because the flusher
541/// retries every couple of seconds, so the failure this path exists for (a full
542/// disk, a read-only mount) leaks on the order of a thousand directories an hour
543/// per signal, and the space is still gone once the operator has freed the disk.
544/// `sweep_staging` keeps its boot-time role: it is for the kill -9 that never
545/// reaches this line.
546///
547/// Returns nothing, deliberately. The publish failure is the one the caller has
548/// to act on, and a cleanup that could not run is a leaked directory the next
549/// boot's sweep collects anyway — turning it into the returned error would
550/// replace the reason the publish failed with the reason the tidy-up did.
551fn unwind_staging(tmp: &Path) {
552    if let Err(rm) = fs::remove_dir_all(tmp) {
553        // NotFound is the normal shape of "the rename succeeded and a later
554        // fsync did not": there is nothing left at `tmp` to remove, and the
555        // block is published.
556        if rm.kind() != io::ErrorKind::NotFound {
557            tracing::warn!(
558                path = %tmp.display(),
559                error = %rm,
560                "cannot remove the staging directory of a failed publish",
561            );
562        }
563    }
564}
565
566/// Write one sealed block's files into an already-created staging directory.
567///
568/// Split out of [`publish`] only so that the failure of any step in it has one
569/// place to be cleaned up from.
570fn stage(tmp: &Path, sealed: &Sealed) -> Result<()> {
571    // An empty table is not written. Arrow IPC framing for a zero-row table is
572    // ~1 KB for three columns and ~2.5 KB for nine (measured), which is nothing
573    // against a full 32 MB block and most of a block sealed by the age timer on
574    // a quiet node. A traces block has nine tables and typically five of them
575    // have any rows: the four event and link tables — span_events,
576    // span_links and the attribute table of each — stay empty unless a service
577    // emits events or links, which most do not. Measured on the smoke corpus,
578    // every traces block writes exactly those five and skips 8 KB of framing
579    // against 18 KB of tables; a metrics block writes eight of thirteen.
580    //
581    // The reader treats a missing file as an empty table, which it has to do
582    // anyway: it is also how a block written by an older version that did not
583    // have the table reads back.
584    for (name, batch) in &sealed.tables {
585        if batch.num_rows() > 0 {
586            write_table(&tmp.join(format!("{name}.arrow")), batch)?;
587        }
588    }
589    // Sidecars are written with the same durability as the tables: a block that
590    // lands with a stale or missing index is one the reader would either skip
591    // wrongly or scan slowly, and only the first of those is a correctness bug —
592    // but both are avoidable for one fsync each of files `bloom` sizes by row
593    // count — 1 KB apiece on the blocks a quiet node writes, 65 KB at the top
594    // end, against tens of MB of tables.
595    for (name, bytes) in &sealed.sidecars {
596        let path = tmp.join(name);
597        let mut f = File::create(&path).ctx(&path)?;
598        f.write_all(bytes).ctx(&path)?;
599        crate::sync_all(&f).ctx(&path)?;
600    }
601    fsync_dir(tmp)
602}
603
604/// Remove staging directories this node left behind for this signal.
605///
606/// Since the staging name carries the block's timestamp range it is never
607/// reused, so a publish killed between staging and `rename` leaks a directory
608/// that nothing else will ever clear. Sweeping is filtered by signal *and* node
609/// rather than emptying `.tmp` wholesale, because another replica on the same
610/// volume may have a publish in flight — deleting under it is exactly the
611/// corruption the unique staging name exists to prevent. Called once per signal
612/// at boot, before that signal's flusher can publish anything.
613pub fn sweep_staging(root: &Path, signal: &str, node: u32) -> Result<usize> {
614    let tmp = root.join(".tmp");
615    let prefix = format!("{signal}-{node:08x}-");
616    let entries = match fs::read_dir(&tmp) {
617        Ok(d) => d,
618        Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(0),
619        Err(e) => {
620            return Err(Error::Io {
621                path: tmp,
622                source: e,
623            });
624        }
625    };
626    let mut removed = 0;
627    for entry in entries {
628        let path = entry.ctx(&tmp)?.path();
629        if path
630            .file_name()
631            .and_then(|n| n.to_str())
632            .is_some_and(|n| n.starts_with(&prefix))
633        {
634            fs::remove_dir_all(&path).ctx(&path)?;
635            removed += 1;
636        }
637    }
638    Ok(removed)
639}
640
641/// How far the write-ahead log has been absorbed into published blocks, per
642/// signal, in the order [`crate::wal::Signal::index`] uses.
643///
644/// This is the whole of WAL recovery's input, and it is three `readdir`s — the
645/// same ones the read path does at boot anyway. No manifest file, so nothing
646/// that can disagree with the blocks it describes.
647///
648/// The maximum over the blocks, not the last one published: `scan` sorts by
649/// timestamp, and a block covering an older hour can be published after a
650/// newer one when a late export arrives. Taking the last would then walk the
651/// watermark backwards and replay data that is already stored.
652///
653/// A signal with no blocks gets `0` — replay everything the log holds for it,
654/// which is right, because nothing has absorbed any of it.
655pub fn wal_watermarks(root: &Path) -> Result<crate::wal::Watermarks> {
656    let mut out = [0u64; 3];
657    for signal in crate::wal::Signal::ALL {
658        out[signal.index()] = scan(root, signal.as_str())?
659            .iter()
660            .map(|b| b.wal_hi)
661            .max()
662            .unwrap_or(0);
663    }
664    Ok(out)
665}
666
667/// Rebuild the catalog from the filesystem. This is the entire boot sequence for
668/// the read path: no manifest to replay, and the write-ahead log's recovery
669/// point rides in the block names rather than in a file of its own.
670pub fn scan(root: &Path, signal: &str) -> Result<Vec<BlockRef>> {
671    let base = root.join(signal);
672    let mut out = Vec::new();
673    let partitions = match fs::read_dir(&base) {
674        Ok(d) => d,
675        Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(out),
676        Err(e) => {
677            return Err(Error::Io {
678                path: base,
679                source: e,
680            });
681        }
682    };
683    for partition in partitions {
684        let partition = partition.ctx(&base)?.path();
685        if !partition.is_dir() {
686            continue;
687        }
688        for entry in fs::read_dir(&partition).ctx(&partition)? {
689            let dir = entry.ctx(&partition)?.path();
690            let Some((min_ts, max_ts, node, seq, wal_hi)) = dir
691                .file_name()
692                .and_then(|n| n.to_str())
693                .and_then(parse_dir_name)
694            else {
695                continue;
696            };
697            out.push(BlockRef {
698                dir,
699                min_ts,
700                max_ts,
701                node,
702                seq,
703                wal_hi,
704            });
705        }
706    }
707    out.sort_by_key(|b| (b.min_ts, b.seq));
708    Ok(out)
709}
710
711/// Drop every block whose newest row is older than `cutoff_ns`.
712///
713/// TTL is a directory unlink, not a compaction: there is no read-modify-write of
714/// live data, so retention costs no IO bandwidth and cannot interfere with
715/// ingest.
716pub fn expire(root: &Path, signal: &str, cutoff_ns: i64) -> Result<usize> {
717    let mut dropped = 0;
718    for block in scan(root, signal)? {
719        if block.max_ts < cutoff_ns {
720            match fs::remove_dir_all(&block.dir) {
721                Ok(()) => dropped += 1,
722                // Another replica sharing this volume expired it first. Racing
723                // to delete the same immutable block is not a conflict, and
724                // aborting here would leave the rest of the sweep undone.
725                Err(e) if e.kind() == io::ErrorKind::NotFound => {}
726                // Anything else — a directory this process cannot traverse, a
727                // file the kernel refuses to unlink — is about *this* block and
728                // says nothing about the next one. Returning here stopped the
729                // sweep for the whole signal, so one undeletable block meant
730                // retention silently stopped reclaiming space for every other
731                // block beside it, which is the failure retention exists to
732                // prevent. Log it and keep going; the count returned is the
733                // blocks actually dropped, so a sweep that reclaimed nothing
734                // still reports nothing.
735                Err(e) => tracing::warn!(
736                    block = %block.dir.display(),
737                    error = %e,
738                    "cannot expire block; skipping it",
739                ),
740            }
741        }
742    }
743    Ok(dropped)
744}
745
746/// Refuse to start on a filesystem the read path cannot survive.
747///
748/// Every block is read through `mmap`. On a network filesystem a server that
749/// goes away, or a file that changes length underneath a mapping, is delivered
750/// as `SIGBUS` — a signal, not an `io::Error`. There is nothing to catch and no
751/// way to unwind; the process dies mid-query. The atomicity this design rests on
752/// is also weaker there: NFS `rename` is atomic on the server but a client may
753/// still serve a cached negative lookup, and `fsync` semantics vary by mount
754/// option. Both are reasons to say no at startup rather than at 3am.
755///
756/// Called once, on the data directory, before anything is published or mapped.
757pub fn check_filesystem(path: &Path) -> Result<()> {
758    check_fs_type(path, fs_type(path)?)
759}
760
761/// The decision [`check_filesystem`] makes, split from the mount it makes it
762/// about for the same reason [`check_format`] takes a string rather than a
763/// file: the rule is the part that can be wrong, and a test cannot conjure an
764/// NFS mount to state it over.
765fn check_fs_type(path: &Path, fs: Option<String>) -> Result<()> {
766    let Some(fs) = fs else {
767        return Ok(());
768    };
769    // FUSE is the ambiguous one and it has to stay a warning: the magic number
770    // is identical for `gcsfuse` and `s3fs`, which are exactly as fatal as NFS,
771    // and for a perfectly local userspace filesystem, which is fine. Refusing
772    // would strand the second case; staying silent would strand the first.
773    if fs == "fuse" {
774        tracing::warn!(
775            path = %path.display(),
776            "data directory is on a FUSE filesystem. If it is network-backed \
777             (gcsfuse, s3fs, rclone), mmap will raise SIGBUS and kill the \
778             process; if it is local, ignore this."
779        );
780        return Ok(());
781    }
782    Err(Error::NetworkFilesystem {
783        path: path.to_path_buf(),
784        fs,
785    })
786}
787
788/// Refuse to start on a data directory that cannot be written to.
789///
790/// `create_dir_all` returns `Ok` for a directory that already exists whatever
791/// its mode, so a read-only mount, a wrong-uid volume or a typo pointing at
792/// someone else's path gets all the way to a listening socket. The first
793/// evidence is then a flush error per export, minutes later, under load, which
794/// reads as a Mira fault rather than as a mount that was never writable — the
795/// same reasoning as [`check_filesystem`], and the same one line in `main`.
796///
797/// The probe carries the pid because replicas may share a directory (section 10) and
798/// two of them starting together must not race each other's cleanup.
799pub fn check_writable(path: &Path) -> Result<()> {
800    let probe = path.join(format!(".mira-write-probe-{}", std::process::id()));
801    // The error names the directory, not the probe: the probe is an
802    // implementation detail and nobody should go looking for that filename.
803    let fail = |source| Error::NotWritable {
804        path: path.to_path_buf(),
805        source,
806    };
807    fs::write(&probe, []).map_err(fail)?;
808    fs::remove_file(&probe).map_err(fail)
809}
810
811/// One filled `statfs`, shared by the two things that ask the mount a question.
812fn statfs(path: &Path) -> Result<libc::statfs> {
813    let c = std::ffi::CString::new(path.as_os_str().as_encoded_bytes()).map_err(|_| Error::Io {
814        path: path.to_path_buf(),
815        source: io::Error::new(io::ErrorKind::InvalidInput, "path contains a NUL byte"),
816    })?;
817    // SAFETY: `statfs` is POD — integers and fixed byte arrays — so all-zero is
818    // a valid value to hold until the call below fills it. Only fields `statfs`
819    // itself writes are read afterwards, and only on the success path.
820    let mut buf: libc::statfs = unsafe { std::mem::zeroed() };
821    // SAFETY: `c` is a `CString` — NUL-terminated by construction, rejected
822    // above if the path had an interior NUL — and it is still live at the call.
823    // `&mut buf` is a correctly typed, writable `statfs` the kernel fills.
824    if unsafe { libc::statfs(c.as_ptr(), &mut buf) } != 0 {
825        return Err(Error::Io {
826            path: path.to_path_buf(),
827            source: io::Error::last_os_error(),
828        });
829    }
830    Ok(buf)
831}
832
833/// How much of the filesystem holding `path` is still free, as a fraction of
834/// its total size.
835///
836/// `f_bavail`, not `f_bfree`: the difference is the root reserve (5% on a
837/// default ext4), which is space the process cannot write into and therefore
838/// not space Mira has. Both terms of the ratio are in blocks of `f_bsize`, so
839/// the block size cancels and is not in the arithmetic.
840pub fn free_fraction(path: &Path) -> Result<f64> {
841    let buf = statfs(path)?;
842    if buf.f_blocks == 0 {
843        // A mount that reports no blocks at all — some pseudo-filesystems do —
844        // is not a mount that can fill up, and calling it 0% free would stop
845        // ingest on a node with nothing wrong with it.
846        return Ok(1.0);
847    }
848    Ok(buf.f_bavail as f64 / buf.f_blocks as f64)
849}
850
851/// The mount's filesystem type, if it is one of the ones that matter. `None`
852/// means "nothing to say about it", which is every local filesystem.
853fn fs_type(path: &Path) -> Result<Option<String>> {
854    // statfs(2) needs the path to exist; the caller creates the data directory
855    // before this runs, but a bare `Ok(None)` if it does not is friendlier than
856    // an ENOENT that says nothing about filesystems.
857    if !path.exists() {
858        return Ok(None);
859    }
860    let buf = statfs(path)?;
861
862    // macOS reports the type by name, which is both readable and complete.
863    #[cfg(target_os = "macos")]
864    {
865        let name: Vec<u8> = buf
866            .f_fstypename
867            .iter()
868            .take_while(|&&c| c != 0)
869            .map(|&c| c as u8)
870            .collect();
871        let name = String::from_utf8_lossy(&name).into_owned();
872        Ok(match name.as_str() {
873            "nfs" | "smbfs" | "cifs" | "webdav" | "afpfs" | "ftp" => Some(name),
874            n if n.contains("fuse") => Some("fuse".into()),
875            _ => None,
876        })
877    }
878
879    // Linux reports a magic number. Listed rather than ranged because the set of
880    // filesystems that break `mmap` is small, specific and does not grow often;
881    // anything unrecognised is treated as local, which is the right default for
882    // a check whose false positive is "Mira will not start".
883    #[cfg(not(target_os = "macos"))]
884    {
885        // Masked to 32 bits: `f_type` is `__fsword_t`, which is i64 on x86_64
886        // glibc but i32 on some musl and 32-bit targets, where a magic with the
887        // high bit set (CIFS, SMB2) arrives sign-extended.
888        let ty = (buf.f_type as u64) & 0xffff_ffff;
889        Ok(match ty {
890            0x6969 => Some("NFS".into()),
891            0x517b => Some("SMB".into()),
892            0xff53_4d42 => Some("CIFS".into()),
893            0xfe53_4d42 => Some("SMB2".into()),
894            0x0102_1997 => Some("9P".into()),
895            0x5346_414f => Some("AFS".into()),
896            0x00c3_6400 => Some("CephFS".into()),
897            0x0116_1970 => Some("GFS2".into()),
898            0x7461_636f => Some("OCFS2".into()),
899            0x0bd0_0bd0 => Some("Lustre".into()),
900            0x6573_5546 => Some("fuse".into()),
901            _ => None,
902        })
903    }
904}
905
906/// The cold-tier marker. Its presence means every table in the block is already
907/// ZSTD-encoded, so a sweep can skip the directory without opening a file.
908const COLD_MARKER: &str = "cold";
909
910/// A block goes cold once it has aged out of the hour it was partitioned into.
911///
912/// Reusing the partition width means the tier boundary is derived rather than
913/// configured: the same instant that stops new rows landing next to this block
914/// is the one that stops queries with a default window from reaching it.
915pub const COLD_AFTER_NS: i64 = NANOS_PER_HOUR;
916
917/// ponytail: a flat cap per sweep, so the first pass over an existing volume
918/// drains at a few hundred MB a minute instead of saturating the disk for an
919/// hour. Make it adaptive when a real deployment says the backlog matters.
920const MAX_COMPACT_PER_SWEEP: usize = 8;
921
922/// Rewrite aged blocks ZSTD-compressed, in place.
923///
924/// Measured by `cargo run --release -p miradb-core --example tier` over section
925/// 11's corpus: 0.113 of the plain size for logs, 0.126 for traces, at 634
926/// MiB/s on one core. Warm, reading a compacted block back costs 1.11× the
927/// plain read — the inflate is real and it is small. Cold, which is the only
928/// state a block old enough to be compacted is in, 8.4× fewer pages to fault
929/// and 8.4× fewer bytes to CRC more than pays for it. So the tier costs the
930/// read path the zero-copy property and a tenth of a warm read, and only for
931/// data nothing is scanning any more.
932///
933/// Crash safety is the trick `publish` already uses: write beside the target,
934/// then rename. A crash leaves a directory with some tables compressed and some
935/// not, which reads correctly — the codec is per-batch IPC metadata, not a
936/// property of the directory — and the absent marker makes the next sweep
937/// finish the job.
938pub fn compact(root: &Path, signal: &str, node: u32, cutoff_ns: i64) -> Result<usize> {
939    let (mut done, mut tried) = (0, 0);
940    for block in scan(root, signal)? {
941        // The budget counts attempts, not successes. A block that fails is a
942        // block whose tables were read and CRC'd before it failed, so charging
943        // only the successes would let a directory full of unreadable blocks
944        // re-read every one of them on every sweep, for ever.
945        if tried == MAX_COMPACT_PER_SWEEP {
946            break;
947        }
948        if block.max_ts >= cutoff_ns || block.dir.join(COLD_MARKER).exists() {
949            continue;
950        }
951        tried += 1;
952        match compact_block(&block.dir, node) {
953            Ok(()) => done += 1,
954            // Expired out from under the sweep, by this node's own retention or
955            // another replica's. Nothing to compact is not a failure.
956            Err(Error::Io { source, .. }) if source.kind() == io::ErrorKind::NotFound => {}
957            // One block that cannot be rewritten — a corrupt table, a bad
958            // checksum, a permission the sweep does not have — used to end the
959            // sweep, which meant compaction for the whole signal stopped
960            // permanently at the oldest broken block and every block behind it
961            // stayed uncompressed. Skipping keeps the block exactly as
962            // expirable as it was: `expire` walks the same `scan` and unlinks
963            // the directory without opening a file, so the block that cannot be
964            // compacted is still the block retention takes.
965            Err(e) => tracing::warn!(
966                block = %block.dir.display(),
967                error = %e,
968                "cannot compact block; skipping it",
969            ),
970        }
971    }
972    Ok(done)
973}
974
975fn compact_block(dir: &Path, node: u32) -> Result<()> {
976    // Collected before rewriting: entries created during an open `read_dir` may
977    // or may not be returned, and one of these renames lands on a name the
978    // iterator has not reached yet.
979    let mut tables = Vec::new();
980    for entry in fs::read_dir(dir).ctx(dir)? {
981        let path = entry.ctx(dir)?.path();
982        if path.extension().is_some_and(|e| e == "arrow") {
983            tables.push(path);
984        }
985    }
986
987    for path in tables {
988        let table = open_table(&path)?;
989        // The staging name carries the node for the same reason `publish`'s
990        // does: two replicas sharing this volume both see this block go cold,
991        // and one truncating the other's half-written file would put garbage
992        // under the rename.
993        let tmp = path.with_extension(format!("{node:08x}.tmp"));
994        write_table_with(&tmp, &table.batches, Some(arrow_ipc::CompressionType::ZSTD))?;
995        // Rename rather than rewrite in place. A reader that already mapped the
996        // old inode keeps reading it — POSIX holds an unlinked file open under
997        // its mappings — which is the same guarantee `expire` depends on and
998        // what keeps `open_table`'s immutability claim true.
999        fs::rename(&tmp, &path).ctx(&path)?;
1000    }
1001
1002    // Marker last, so a crash mid-rewrite is retried rather than declared done.
1003    crate::sync_all(&File::create(dir.join(COLD_MARKER)).ctx(dir)?).ctx(dir)?;
1004    fsync_dir(dir)
1005}
1006
1007/// A table read straight out of its mapping.
1008///
1009/// The `RecordBatch` buffers point into the mmap; the mapping is kept alive by
1010/// the `Arc` that every `Buffer` holds, so `MappedTable` can be dropped while
1011/// batches derived from it are still in use.
1012pub struct MappedTable {
1013    pub batches: Vec<RecordBatch>,
1014    /// Address range of the mapping, so the zero-copy property can be asserted
1015    /// rather than assumed.
1016    mapping: std::ops::Range<usize>,
1017}
1018
1019impl MappedTable {
1020    /// `(buffers_pointing_into_the_mapping, buffers_total)`.
1021    ///
1022    /// Walks every buffer of every column including child data, so a partially
1023    /// copied nested array shows up. `require_alignment(true)` should already
1024    /// make a copy impossible; this is the assertion that says so out loud.
1025    ///
1026    /// A cold block (see [`compact`]) reports 0/n on purpose: decompression has
1027    /// to allocate. Only the hot tier is held to `inside == total`.
1028    pub fn zero_copy_ratio(&self) -> (usize, usize) {
1029        let (mut inside, mut total) = (0, 0);
1030        for batch in &self.batches {
1031            for col in batch.columns() {
1032                let mut stack = vec![col.to_data()];
1033                while let Some(d) = stack.pop() {
1034                    for buf in d.buffers() {
1035                        total += 1;
1036                        if self.mapping.contains(&(buf.as_ptr() as usize)) {
1037                            inside += 1;
1038                        }
1039                    }
1040                    stack.extend(d.child_data().iter().cloned());
1041                }
1042            }
1043        }
1044        (inside, total)
1045    }
1046}
1047
1048/// [`open_table`], but a missing file means an empty table rather than an error.
1049///
1050/// This is the normal way to read any table that can legitimately have no rows,
1051/// which is most of them: `publish` skips writing a zero-row table, so a traces
1052/// block from a service that emits no span links simply has no
1053/// `span_links.arrow`. It is also how a block written before a table existed
1054/// reads back, which is the same case a version from now.
1055pub fn open_table_opt(path: &Path) -> Result<Option<MappedTable>> {
1056    match open_table(path) {
1057        Err(Error::Io { source, .. }) if source.kind() == std::io::ErrorKind::NotFound => Ok(None),
1058        other => other.map(Some),
1059    }
1060}
1061
1062/// Decide whether this binary is allowed to read a block declaring `declared`
1063/// as its [`FORMAT_KEY`].
1064///
1065/// Refusing the future is the whole job: a newer block read as the current
1066/// format would not fail, it would answer wrongly, because the readers that go
1067/// through a table by column position cannot tell a shifted column from the one
1068/// they wanted.
1069fn check_format(path: &Path, declared: Option<&str>) -> Result<()> {
1070    let version = match declared {
1071        None => LEGACY_VERSION,
1072        Some(v) => v
1073            .parse::<u32>()
1074            .map_err(|_| corrupt(path, format!("{FORMAT_KEY} is `{v}`, not a version")))?,
1075    };
1076    if version > FORMAT_VERSION {
1077        return Err(Error::Io {
1078            path: path.to_path_buf(),
1079            source: io::Error::new(
1080                io::ErrorKind::Unsupported,
1081                format!(
1082                    "block format version {version} was written by a newer Mira; \
1083                     this binary reads up to version {FORMAT_VERSION}"
1084                ),
1085            ),
1086        });
1087    }
1088    Ok(())
1089}
1090
1091/// One encapsulated IPC message, described entirely by the *checksummed* bytes
1092/// it starts at.
1093///
1094/// Nothing in here comes from the footer, and that is the point. The footer's
1095/// `dictionaries` and `recordBatches` vectors are the file's table of contents,
1096/// they sit outside the CRC, and a flatbuffer vector's length is a single `u32`
1097/// — so clearing `recordBatches` is *one bit flip* that makes a block read back
1098/// `Ok` with no rows in it. Not a panic and not an error: a whole table of
1099/// telemetry silently gone, reported as success, which is the worst of the three
1100/// outcomes and the only one no caller can react to.
1101///
1102/// The body does not need that table of contents. Every message declares its own
1103/// metadata length in the 8-byte encapsulation prefix and its own body length in
1104/// its flatbuffer header, both inside `[0, body_len)`, so the messages chain: the
1105/// next one starts exactly [`Framed::stride`] bytes after this one. Walking that
1106/// chain costs one extra flatbuffer verify per message — a few hundred bytes each,
1107/// against a CRC of the whole body — and takes the footer out of the read path.
1108struct Framed {
1109    /// What arrow-rs is told about the message. Only `metaDataLength` is read
1110    /// back out of it (`read_record_batch` slices `data` by it); the offset is
1111    /// relative to `data`, which starts at the message, so it is zero.
1112    block: arrow_ipc::Block,
1113    /// Exactly this message: metadata, padding and body, and nothing after it.
1114    data: Buffer,
1115    header: arrow_ipc::MessageHeader,
1116    version: MetadataVersion,
1117}
1118
1119impl Framed {
1120    /// Distance to the next message. `metaDataLength` is padded to [`ALIGNMENT`]
1121    /// by the writer and is at least 8, so this always advances.
1122    fn stride(&self) -> usize {
1123        self.block.metaDataLength() as usize + self.block.bodyLength() as usize
1124    }
1125}
1126
1127/// Frame the message at `offset`, bounds-checking every number it declares
1128/// against the region the CRC covers.
1129///
1130/// The checks are not belt-and-braces. `Buffer::slice_with_length` panics on a
1131/// range it does not like, `panic = "abort"` makes that a process death, and the
1132/// numbers being checked were read off a disk — see [`corrupt`].
1133fn message_at(path: &Path, buffer: &Buffer, offset: usize, body_len: usize) -> Result<Framed> {
1134    let bad = |why: String| corrupt(path, format!("message at offset {offset}: {why}"));
1135    // Written as a subtraction from `body_len` rather than an addition to
1136    // `offset`, so that an offset near `usize::MAX` is rejected instead of
1137    // wrapping into the range it is being tested against.
1138    if offset > body_len || body_len - offset < 8 {
1139        return Err(bad(format!(
1140            "outside the {body_len} bytes the checksum covers"
1141        )));
1142    }
1143    let head = &buffer[offset..offset + 8];
1144    if head[..4] != CONTINUATION {
1145        return Err(bad("no message framing here".into()));
1146    }
1147    // `write_encoded_data` declares the padded metadata length *after* the
1148    // 8-byte prefix, so the header the decoder skips is exactly 8 more.
1149    let declared = i32::from_le_bytes(head[4..].try_into().expect("4 bytes"));
1150    let Some(meta) = declared.checked_add(8).filter(|m| *m >= 8) else {
1151        return Err(bad(format!("{declared} is not a metadata length")));
1152    };
1153    let meta = meta as usize;
1154    if body_len - offset < meta {
1155        return Err(bad(format!(
1156            "{meta} bytes of metadata run past the {body_len} bytes the checksum covers"
1157        )));
1158    }
1159    // The verifier here is what makes the two accessors below safe to call.
1160    let message = arrow_ipc::root_as_message(&buffer[offset + 8..offset + meta])
1161        .map_err(|e| bad(format!("not a readable IPC message: {e}")))?;
1162    let body = message.bodyLength();
1163    let room = (body_len - offset - meta) as i64;
1164    if body < 0 || body > room {
1165        return Err(bad(format!(
1166            "a {body}-byte body runs past the {body_len} bytes the checksum covers"
1167        )));
1168    }
1169    Ok(Framed {
1170        block: arrow_ipc::Block::new(0, meta as i32, body),
1171        data: buffer.slice_with_length(offset, meta + body as usize),
1172        header: message.header_type(),
1173        version: message.version(),
1174    })
1175}
1176
1177/// Open one table of a block with no buffer copies.
1178///
1179/// This is a blocking call that can take a hard page fault. It must never run on
1180/// a tokio worker: a cold fault stalls the whole OS thread with no yield point
1181/// and no signal to the runtime. Callers go through `spawn_blocking` or a
1182/// dedicated reader pool.
1183pub fn open_table(path: &Path) -> Result<MappedTable> {
1184    let file = File::open(path).ctx(path)?;
1185    // SAFETY: the obligation is that nothing modifies or truncates this file
1186    // while the mapping lives — a truncation is a SIGBUS on the next page
1187    // touched, which no in-process check can catch. What discharges it is
1188    // Mira's own discipline, not the kernel: a block becomes visible by one
1189    // directory rename and is never written again ([`publish`]); [`compact`]
1190    // replaces a table by renaming a new file over the name, which unlinks the
1191    // old inode rather than truncating it; [`expire`] is `remove_dir_all`,
1192    // also unlink. POSIX keeps an unlinked inode alive under its mappings, so
1193    // every path Mira has lands on the safe side.
1194    //
1195    // That argument covers this process and every replica sharing the volume,
1196    // because they all run this code. It does not cover a third party editing
1197    // a block file in place, and nothing here can — the data directory is
1198    // Mira's, and that is a deployment property, not a checkable one.
1199    let mmap = unsafe { Mmap::map(&file) }.ctx(path)?;
1200    // Every open CRCs the whole body, so every page is touched. Faulting them in
1201    // one at a time caps a cold scan at fault latency; asking for the file up
1202    // front lets the kernel read ahead. A hint, so a failure is not an error.
1203    let _ = mmap.advise(memmap2::Advice::WillNeed);
1204
1205    if mmap.len() < MAGIC.len() + 10 || &mmap[..MAGIC.len()] != MAGIC {
1206        return Err(Error::BadMagic {
1207            path: path.to_path_buf(),
1208        });
1209    }
1210
1211    let len = mmap.len();
1212    let base = mmap.as_ptr() as usize;
1213    let ptr = NonNull::new(mmap.as_ptr().cast_mut()).expect("mmap is never null");
1214    // SAFETY: `ptr` and `len` are `mmap`'s own `as_ptr`/`len`, so they describe
1215    // exactly the mapped region and nothing beyond it, page-aligned. Moving the
1216    // `Mmap` into the `Arc` moves an (address, length) pair, not the mapping,
1217    // so `ptr` is still the same live region afterwards — and the `Arc` is the
1218    // allocation owner, so `munmap` runs only after the last `Buffer` sliced
1219    // from it is dropped. `cast_mut` is to fit the signature; `Buffer` is
1220    // read-only and never writes through it, which matters because the mapping
1221    // is `PROT_READ`.
1222    let buffer = unsafe { Buffer::from_custom_allocation(ptr, len, Arc::new(mmap)) };
1223
1224    let trailer = len - 10;
1225    let footer_len = read_footer_length(buffer[trailer..].try_into().expect("10 bytes"))?;
1226    // `read_footer_length` only rejects a negative length, so a garbled one is
1227    // still a number this would subtract past zero and then slice with. See
1228    // [`corrupt`] for why that must not be a panic.
1229    if footer_len > trailer {
1230        return Err(corrupt(
1231            path,
1232            format!("footer says it is {footer_len} bytes, in a {len}-byte file"),
1233        ));
1234    }
1235    let footer_start = trailer - footer_len;
1236    let footer = root_as_footer(&buffer[footer_start..trailer])
1237        .map_err(|e| arrow_schema::ArrowError::ParseError(e.to_string()))?;
1238
1239    let find = |key: &str| -> Option<&str> {
1240        footer
1241            .custom_metadata()
1242            .into_iter()
1243            .flatten()
1244            .find(|kv| kv.key() == Some(key))
1245            .and_then(|kv| kv.value())
1246    };
1247    let meta = |key: &'static str| -> Result<&str> {
1248        find(key).ok_or(Error::MissingMetadata {
1249            path: path.to_path_buf(),
1250            key,
1251        })
1252    };
1253
1254    // First, before the CRC and before a single byte of the body is read: the
1255    // version is what says the rest of this file means what this binary thinks
1256    // it means, so nothing else here is worth checking until it has passed.
1257    check_format(path, find(FORMAT_KEY))?;
1258
1259    let expected = u32::from_str_radix(meta(CRC_KEY)?, 16).map_err(|_| Error::MissingMetadata {
1260        path: path.to_path_buf(),
1261        key: CRC_KEY,
1262    })?;
1263    let body_len: usize = meta(CRC_LEN_KEY)?
1264        .parse()
1265        .ok()
1266        .filter(|&n: &usize| n <= footer_start)
1267        .ok_or(Error::MissingMetadata {
1268            path: path.to_path_buf(),
1269            key: CRC_LEN_KEY,
1270        })?;
1271    let actual = crc32fast::hash(&buffer[..body_len]);
1272    if actual != expected {
1273        return Err(Error::BadChecksum {
1274            path: path.to_path_buf(),
1275            expected,
1276            actual,
1277        });
1278    }
1279
1280    // Everything from here on comes out of the *body*, not out of the footer.
1281    //
1282    // Extending the CRC over the footer was the obvious fix and it is not
1283    // possible without leaving the format: the checksum lives in the footer's
1284    // own custom metadata, and a checksum cannot cover the bytes it is stored
1285    // in. The two places outside the footer are a trailer appended after the
1286    // final `ARROW1` magic — which makes the file unreadable to every other
1287    // Arrow implementation, including arrow-rs's own `FileReader`, since they
1288    // all seek to `len - 10` — and a sidecar file per table, which doubles the
1289    // file count and the fsyncs and reintroduces the two-file atomicity problem
1290    // this design does not otherwise have.
1291    //
1292    // The other direction is free: an Arrow IPC file's body is *self-describing*
1293    // and holds a second copy of everything the footer says. The schema is
1294    // written as the first message as well as into the footer; the messages
1295    // chain, so the table of contents is derivable; and all of that is inside
1296    // `[0, body_len)`, which the CRC has just verified. So rather than covering
1297    // the footer, stop reading it. What is left of it is its custom metadata,
1298    // and that is self-checking — a corrupt CRC or length fails the CRC, and a
1299    // corrupt version is refused by [`check_format`].
1300    //
1301    // The schema is the half that mattered for memory safety: with validation
1302    // skipped below, a footer schema naming a wider type than the body holds
1303    // builds an array over a buffer too short for it, which is undefined
1304    // behaviour rather than an `Error::BadChecksum`.
1305    let first = message_at(path, &buffer, HEADER_LEN, body_len)?;
1306    if first.header != arrow_ipc::MessageHeader::Schema {
1307        return Err(corrupt(
1308            path,
1309            format!(
1310                "the body starts with {:?}, not a schema message",
1311                first.header.variant_name().unwrap_or("an unknown message")
1312            ),
1313        ));
1314    }
1315    let schema = Arc::new(
1316        arrow_ipc::convert::try_schema_from_ipc_buffer(&first.data).map_err(|e| {
1317            corrupt(
1318                path,
1319                format!("no readable schema at the head of the body: {e}"),
1320            )
1321        })?,
1322    );
1323
1324    // Skipping validation is what keeps this a mmap read: with it on, every
1325    // Utf8 column runs `std::str::from_utf8` over the whole values buffer — a
1326    // sequential scan that faults in every page of string data, which is
1327    // precisely what demand paging was supposed to avoid.
1328    //
1329    // SAFETY: only `with_skip_validation` is unsafe here. It turns off offset
1330    // bounds, buffer length and UTF-8 checks, so the arrays built below are
1331    // trusted rather than verified, and an out-of-range offset would read
1332    // arbitrary memory. What backs the trust is the CRC checked above: it
1333    // covers `[0, body_len)`, which is every byte the IPC writer emitted before
1334    // `finish()` — schema, dictionary and record-batch messages and their
1335    // bodies (see `write_table_with`, which snapshots the CRC at exactly that
1336    // point). Those bytes are therefore provably the ones arrow-rs's own writer
1337    // produced from arrays it had already validated, and by the paragraph above
1338    // they are the only bytes the decoder is shown.
1339    let mut decoder = unsafe {
1340        FileDecoder::new(schema, first.version)
1341            .with_require_alignment(true)
1342            .with_skip_validation(true)
1343    };
1344
1345    // Write order is decode order: `FileWriter` emits a dictionary before the
1346    // batch that refers to it, so one pass down the chain feeds the decoder in
1347    // the order it needs. `body_len` is the writer's position at the CRC
1348    // snapshot, which is the end of the last message — the end-of-stream marker
1349    // and the footer are emitted by `finish()`, after it — so the walk stops
1350    // exactly where the messages do.
1351    let mut batches = Vec::new();
1352    let mut offset = HEADER_LEN + first.stride();
1353    while offset < body_len {
1354        let msg = message_at(path, &buffer, offset, body_len)?;
1355        offset += msg.stride();
1356        let fail = |source| Error::Undecodable {
1357            path: path.to_path_buf(),
1358            source,
1359        };
1360        match msg.header {
1361            arrow_ipc::MessageHeader::DictionaryBatch => {
1362                decoder
1363                    .read_dictionary(&msg.block, &msg.data)
1364                    .map_err(fail)?;
1365            }
1366            arrow_ipc::MessageHeader::RecordBatch => {
1367                if let Some(rb) = decoder
1368                    .read_record_batch(&msg.block, &msg.data)
1369                    .map_err(fail)?
1370                {
1371                    batches.push(rb);
1372                }
1373            }
1374            other => {
1375                return Err(corrupt(
1376                    path,
1377                    format!(
1378                        "{} at offset {offset} is not a dictionary or a record batch",
1379                        other.variant_name().unwrap_or("an unknown message")
1380                    ),
1381                ));
1382            }
1383        }
1384    }
1385
1386    Ok(MappedTable {
1387        batches,
1388        mapping: base..base + len,
1389    })
1390}
1391
1392#[cfg(test)]
1393mod tests {
1394    use super::*;
1395    use arrow_array::{StringArray, UInt32Array};
1396    use arrow_schema::{DataType, Field, Schema};
1397
1398    fn dir(tag: &str) -> PathBuf {
1399        let d = std::env::temp_dir().join(format!("mira-blk-{tag}-{}", std::process::id()));
1400        let _ = fs::remove_dir_all(&d);
1401        fs::create_dir_all(&d).unwrap();
1402        d
1403    }
1404
1405    /// Two columns and a string, so that a footer schema naming a wider type
1406    /// than the body holds has something to be wrong about.
1407    fn batch() -> RecordBatch {
1408        let schema = Arc::new(Schema::new(vec![
1409            Field::new("id", DataType::UInt32, false),
1410            Field::new("body", DataType::Utf8, true),
1411        ]));
1412        RecordBatch::try_new(
1413            schema,
1414            vec![
1415                Arc::new(UInt32Array::from(vec![1, 2, 3])),
1416                Arc::new(StringArray::from(vec![Some("a"), None, Some("ccc")])),
1417            ],
1418        )
1419        .unwrap()
1420    }
1421
1422    /// The same two columns, wide and repetitive enough that ZSTD beats the
1423    /// plain bytes. `compress_to_vec` keeps the uncompressed copy whenever the
1424    /// frame comes out larger, so [`batch`] compressed has no frame in it at
1425    /// all and nothing for the cold-tier test to corrupt.
1426    fn compressible() -> RecordBatch {
1427        let n = 4_096u32;
1428        RecordBatch::try_new(
1429            batch().schema(),
1430            vec![
1431                Arc::new(UInt32Array::from_iter_values(0..n)),
1432                Arc::new(StringArray::from_iter_values(
1433                    (0..n).map(|_| "the same line of telemetry, over and over"),
1434                )),
1435            ],
1436        )
1437        .unwrap()
1438    }
1439
1440    fn sealed(min_ts: i64, max_ts: i64) -> Sealed {
1441        Sealed {
1442            tables: vec![("logs", batch())],
1443            sidecars: vec![],
1444            min_ts,
1445            max_ts,
1446            num_rows: 3,
1447        }
1448    }
1449
1450    /// A message header tag past `ENUM_MAX_MESSAGE_HEADER`, so that
1451    /// `variant_name()` has no name for it either — the reader has to say
1452    /// something useful about a message type that does not exist, not just
1453    /// about one it did not want.
1454    const UNKNOWN_MESSAGE: u8 = 42;
1455
1456    /// Where the footer starts, by the same three fields the reader uses.
1457    fn footer_start(bytes: &[u8]) -> usize {
1458        let n = bytes.len();
1459        let footer_len = i32::from_le_bytes(bytes[n - 10..n - 6].try_into().unwrap()) as usize;
1460        n - 10 - footer_len
1461    }
1462
1463    /// The two numbers in the footer that describe the checksummed region:
1464    /// where the CRC's own eight hex digits live in the file, and how many
1465    /// bytes they cover.
1466    fn crc_field(bytes: &[u8]) -> (usize, usize) {
1467        let start = footer_start(bytes);
1468        let footer = root_as_footer(&bytes[start..bytes.len() - 10]).unwrap();
1469        let md = footer.custom_metadata().unwrap();
1470        let find = |key: &str| {
1471            md.iter()
1472                .find(|kv| kv.key() == Some(key))
1473                .and_then(|kv| kv.value())
1474                .unwrap()
1475        };
1476        let hex = find(CRC_KEY);
1477        assert_eq!(hex.len(), 8, "the CRC is written as eight hex digits");
1478        (
1479            hex.as_ptr() as usize - bytes.as_ptr() as usize,
1480            find(CRC_LEN_KEY).parse().unwrap(),
1481        )
1482    }
1483
1484    /// Make the checksum agree with a body that has been changed underneath it.
1485    ///
1486    /// Repairing the CRC is the point of these tests rather than a way around
1487    /// them. Everything [`open_table`] checks *after* the checksum — the first
1488    /// message being a schema, the ones after it being dictionaries or batches,
1489    /// every length staying inside the covered region — exists for bytes the
1490    /// checksum cannot speak for: crc32 is 32 bits and collides, and the arrays
1491    /// below it are built with `with_skip_validation(true)`, so the first thing
1492    /// to notice a structurally wrong body would otherwise be a read off the
1493    /// end of the mapping. A mutation the checksum accepts is the only way to
1494    /// reach them, and it is the shape of the corruption they are for.
1495    ///
1496    /// The length is left alone deliberately: it is a decimal string of
1497    /// variable width, so anything that changes it changes the footer's size
1498    /// too. Every mutation here stays inside `[0, body_len)`.
1499    fn repair_crc(bytes: &mut [u8]) {
1500        let (at, body_len) = crc_field(bytes);
1501        let crc = crc32fast::hash(&bytes[..body_len]);
1502        bytes[at..at + 8].copy_from_slice(format!("{crc:08x}").as_bytes());
1503    }
1504
1505    /// Where the `header_type` union tag of the message at `offset` sits.
1506    ///
1507    /// Found by probing rather than by decoding a vtable: the layout inside the
1508    /// message is arrow-rs's business and a search that *verifies its own
1509    /// result* cannot land on the wrong byte. Same technique as
1510    /// [`a_footer_schema_naming_a_wider_type_is_not_the_one_the_read_uses`].
1511    fn header_tag(bytes: &[u8], offset: usize) -> usize {
1512        let declared =
1513            i32::from_le_bytes(bytes[offset + 4..offset + 8].try_into().unwrap()) as usize;
1514        let meta = offset + 8..offset + 8 + declared;
1515        for i in meta.clone() {
1516            let mut probe = bytes.to_vec();
1517            probe[i] = UNKNOWN_MESSAGE;
1518            let framed = arrow_ipc::root_as_message(&probe[meta.clone()]);
1519            if framed.is_ok_and(|m| m.header_type().0 == UNKNOWN_MESSAGE) {
1520                return i;
1521            }
1522        }
1523        panic!("the message at {offset} has no header tag to corrupt");
1524    }
1525
1526    /// The offset of the second message, which for every file this writes is
1527    /// the record batch: the schema is first and its stride leads here.
1528    fn second_message(path: &Path, bytes: &[u8]) -> usize {
1529        let covered = crc_field(bytes).1;
1530        let buffer = Buffer::from_vec(bytes.to_vec());
1531        HEADER_LEN
1532            + message_at(path, &buffer, HEADER_LEN, covered)
1533                .unwrap()
1534                .stride()
1535    }
1536
1537    /// The error every corruption has to produce: named, and not a panic.
1538    fn invalid_data(e: &Error, what: &str) {
1539        let Error::Io { source, .. } = e else {
1540            panic!("{what}: {e}")
1541        };
1542        assert_eq!(source.kind(), io::ErrorKind::InvalidData, "{what}: {e}");
1543    }
1544
1545    /// A footer length field that does not describe this file used to be a
1546    /// subtraction past zero and then a slice with the wrap-around — a panic,
1547    /// which `panic = "abort"` makes a process death and a crashloop, since the
1548    /// block is still there on the next start.
1549    #[test]
1550    fn a_corrupt_footer_length_is_an_error_not_a_panic() {
1551        let d = dir("footerlen");
1552        let path = d.join("logs.arrow");
1553        write_table(&path, &batch()).unwrap();
1554
1555        let good = fs::read(&path).unwrap();
1556        for len in [i32::MAX, good.len() as i32, good.len() as i32 - 9] {
1557            let mut bytes = good.clone();
1558            let n = bytes.len();
1559            bytes[n - 10..n - 6].copy_from_slice(&len.to_le_bytes());
1560            fs::write(&path, &bytes).unwrap();
1561            let Err(e) = open_table(&path) else {
1562                panic!("a footer length of {len} in a {n}-byte file read as a block")
1563            };
1564            assert!(
1565                matches!(&e, Error::Io { source, .. }
1566                    if source.kind() == io::ErrorKind::InvalidData),
1567                "{e}"
1568            );
1569        }
1570        let _ = fs::remove_dir_all(&d);
1571    }
1572
1573    /// The property the CRC cannot give directly, because it is stamped before
1574    /// the footer exists: every bit of the uncovered tail either fails the read
1575    /// or changes nothing about what the read returns. In particular the schema
1576    /// is the one the writer wrote — it comes from the covered copy at the head
1577    /// of the body — so no flip here can widen a column and build an array over
1578    /// a buffer too short for it.
1579    ///
1580    /// Exhaustive rather than sampled: the footer of this file is a few hundred
1581    /// bytes and the whole sweep is a fraction of a second, and the interesting
1582    /// bits (a length's high bit, an offset's sign bit) are exactly the ones a
1583    /// sample misses.
1584    #[test]
1585    fn no_bit_in_the_unchecked_footer_can_change_what_a_read_returns() {
1586        let d = dir("footerbits");
1587        let path = d.join("logs.arrow");
1588        let want = batch();
1589        write_table(&path, &want).unwrap();
1590
1591        let good = fs::read(&path).unwrap();
1592        let (start, n) = (footer_start(&good), good.len());
1593        assert!(start < n - 10, "no footer to corrupt");
1594        let mut flipped = 0;
1595        for i in start..n {
1596            for bit in 0..8u8 {
1597                let mut bytes = good.clone();
1598                bytes[i] ^= 1 << bit;
1599                fs::write(&path, &bytes).unwrap();
1600                if let Ok(t) = open_table(&path) {
1601                    assert_eq!(
1602                        t.batches,
1603                        vec![want.clone()],
1604                        "byte {i} bit {bit} decoded to other data"
1605                    );
1606                } else {
1607                    flipped += 1;
1608                }
1609            }
1610        }
1611        // Not every flip has to be caught — a bit in a padding byte or in the
1612        // unused half of a flatbuffer field is genuinely harmless — but a run
1613        // where nothing at all was rejected would mean the checks above never
1614        // ran.
1615        assert!(flipped > 0, "no corrupt footer was rejected");
1616        let _ = fs::remove_dir_all(&d);
1617    }
1618
1619    /// The corruption a CRC over `[0, body_len)` could never have caught, driven
1620    /// directly rather than waited for: a footer that parses cleanly and names a
1621    /// *wider* type than the body holds.
1622    ///
1623    /// `Utf8` and `LargeUtf8` are both empty flatbuffer tables, so a footer
1624    /// naming one and a footer naming the other differ in exactly one byte — the
1625    /// union tag — which is why this is reachable by a single bit flip and why it
1626    /// can be constructed by patching one. What it asks for is a buffer of 4-byte
1627    /// offsets read as 8-byte offsets: with the schema taken from the footer and
1628    /// `with_skip_validation(true)` below, the last offset is read off the end of
1629    /// the mapping. Undefined behaviour, not `Error::BadChecksum`.
1630    ///
1631    /// The assertion is the strong one — the read returns the *right* data, not
1632    /// merely an error — because the schema now comes from the checksummed copy
1633    /// at the head of the body and the footer's copy is read by nobody.
1634    #[test]
1635    fn a_footer_schema_naming_a_wider_type_is_not_the_one_the_read_uses() {
1636        let d = dir("widen");
1637        let path = d.join("logs.arrow");
1638        let want = batch();
1639        write_table(&path, &want).unwrap();
1640
1641        let good = fs::read(&path).unwrap();
1642        let (start, end) = (footer_start(&good), good.len() - 10);
1643        // Raw flatbuffer accessors, not `fb_to_schema`: that one is a `todo!()`
1644        // on a type tag it does not know, so the probe for "did this patch land
1645        // on the tag" would itself panic on every patch that landed elsewhere.
1646        // Which is its own argument for keeping the footer schema out of the
1647        // read path — but here it is only in the way.
1648        let body_tag = |bytes: &[u8]| -> Option<u8> {
1649            let fields = root_as_footer(&bytes[start..end])
1650                .ok()?
1651                .schema()?
1652                .fields()?;
1653            let body = (fields.len() == 2).then(|| fields.get(1))?;
1654            (body.name() == Some("body")).then(|| body.type_type().0)
1655        };
1656        assert_eq!(body_tag(&good), Some(arrow_ipc::Type::Utf8.0));
1657
1658        let mut widened = 0;
1659        for i in start..end {
1660            let mut bytes = good.clone();
1661            bytes[i] = arrow_ipc::Type::LargeUtf8.0;
1662            if body_tag(&bytes) != Some(arrow_ipc::Type::LargeUtf8.0) {
1663                continue;
1664            }
1665            widened += 1;
1666            fs::write(&path, &bytes).unwrap();
1667            let t = open_table(&path).expect("a widened footer schema is not read at all");
1668            assert_eq!(t.batches, vec![want.clone()], "byte {i} widened the read");
1669        }
1670        assert!(widened > 0, "the footer schema could not be widened");
1671        let _ = fs::remove_dir_all(&d);
1672    }
1673
1674    /// The chain [`open_table`] walks instead of reading the footer, and the
1675    /// bounds every link in it is held to. Driven directly, because a body that
1676    /// declares a length running off the end of itself is not something a bit
1677    /// flip reaches often and it is the case that reads arbitrary memory.
1678    #[test]
1679    fn a_message_that_does_not_fit_the_checked_body_is_refused() {
1680        let d = dir("extent");
1681        let path = d.join("logs.arrow");
1682        write_table(&path, &batch()).unwrap();
1683        let bytes = fs::read(&path).unwrap();
1684        // What the CRC covers: everything up to the end-of-stream marker that
1685        // `finish()` writes just before the footer.
1686        let covered = footer_start(&bytes) - 8;
1687        let buffer = Buffer::from_vec(bytes);
1688
1689        // The schema message, which every Arrow file has at the same place, and
1690        // then the record batch its stride leads to.
1691        let schema = message_at(&path, &buffer, HEADER_LEN, covered).unwrap();
1692        assert_eq!(schema.header, arrow_ipc::MessageHeader::Schema);
1693        assert_eq!(schema.block.metaDataLength() % ALIGNMENT as i32, 0);
1694        assert_eq!(schema.block.bodyLength(), 0);
1695
1696        let at = HEADER_LEN + schema.stride();
1697        let rb = message_at(&path, &buffer, at, covered).unwrap();
1698        assert_eq!(rb.header, arrow_ipc::MessageHeader::RecordBatch);
1699        assert_eq!(rb.data.len(), rb.stride());
1700        // The property the walk rests on: the chain lands exactly on the end of
1701        // the checksummed region. If it did not, the loop would either stop
1702        // short of a batch or read one out of the uncovered tail.
1703        assert_eq!(
1704            at + rb.stride(),
1705            covered,
1706            "the chain does not end at the CRC"
1707        );
1708
1709        let bad = |offset: usize, body_len: usize| {
1710            let Err(e) = message_at(&path, &buffer, offset, body_len) else {
1711                panic!("offset {offset} framed a message inside {body_len} covered bytes")
1712            };
1713            assert!(
1714                matches!(&e, Error::Io { source, .. }
1715                    if source.kind() == io::ErrorKind::InvalidData),
1716                "{e}"
1717            );
1718        };
1719        bad(covered, covered); // at the end
1720        bad(usize::MAX, covered); // and far past it, without wrapping
1721        bad(HEADER_LEN + 1, covered); // no framing there
1722        bad(HEADER_LEN, HEADER_LEN + 8); // metadata past the checksum
1723        bad(at, at + rb.block.metaDataLength() as usize); // body past the checksum
1724        let _ = fs::remove_dir_all(&d);
1725    }
1726
1727    /// A version this binary does not understand has to be an error with a name
1728    /// on it. The alternative is the silent one: a block whose columns moved,
1729    /// read positionally, answers a query with the wrong column.
1730    #[test]
1731    fn a_newer_format_version_is_refused_and_an_older_one_is_not() {
1732        let path = Path::new("logs.arrow");
1733        // Written before the key existed. Byte-identical to version 1, so
1734        // refusing it would orphan every block on disk at upgrade time.
1735        check_format(path, None).unwrap();
1736        check_format(path, Some("1")).unwrap();
1737
1738        let e = check_format(path, Some("2")).unwrap_err();
1739        assert!(
1740            matches!(&e, Error::Io { source, .. }
1741                if source.kind() == io::ErrorKind::Unsupported),
1742            "{e}"
1743        );
1744        let e = check_format(path, Some("banana")).unwrap_err();
1745        assert!(
1746            matches!(&e, Error::Io { source, .. }
1747                if source.kind() == io::ErrorKind::InvalidData),
1748            "{e}"
1749        );
1750    }
1751
1752    /// A version is only worth stamping if somebody bumps it, and nothing in
1753    /// `schema.rs` — where the change that needs the bump gets made — says so.
1754    /// The readers that make it matter are somewhere else again: `attrs.rs`,
1755    /// `query.rs` and `series.rs` reach into the attribute tables by column
1756    /// *position* (`column(3)` is the `str` value), so inserting a field into
1757    /// [`crate::schema::ATTRS`] does not fail against the blocks already on
1758    /// disk — it reinterprets them, and a query answers with a different
1759    /// column's data and no error anywhere.
1760    ///
1761    /// So the rule is a test rather than a sentence in a doc comment three
1762    /// files away. Only `ATTRS` is pinned: the root tables are read through
1763    /// `column_by_name`, which is exactly why `LogRecord.event_name` could be
1764    /// added without rewriting a block, and pinning those here would be a
1765    /// tripwire on the wrong wire.
1766    #[test]
1767    fn the_positionally_read_columns_are_pinned_to_the_format_version() {
1768        let names: Vec<&str> = crate::schema::ATTRS
1769            .fields()
1770            .iter()
1771            .map(|f| f.name().as_str())
1772            .collect();
1773        assert_eq!(
1774            names,
1775            [
1776                "parent_id",
1777                "key",
1778                "type",
1779                "str",
1780                "int",
1781                "double",
1782                "bool",
1783                "bytes",
1784                "ser"
1785            ],
1786            "the attribute table's column order changed, and three readers take \
1787             those columns by index — so every block already written now decodes \
1788             with the wrong ones. Bump FORMAT_VERSION (currently {FORMAT_VERSION}), \
1789             put the compatibility branch for the old layout next to `check_format`, \
1790             and update this list."
1791        );
1792    }
1793
1794    /// And the version actually reaches the disk, next to the CRC. Every table
1795    /// is written by [`write_table_with`], so one of them is all of them.
1796    #[test]
1797    fn a_written_table_carries_its_format_version() {
1798        let d = dir("version");
1799        let path = d.join("logs.arrow");
1800        write_table(&path, &batch()).unwrap();
1801
1802        let bytes = fs::read(&path).unwrap();
1803        let footer = root_as_footer(&bytes[footer_start(&bytes)..bytes.len() - 10]).unwrap();
1804        let stamped = footer
1805            .custom_metadata()
1806            .unwrap()
1807            .iter()
1808            .find(|kv| kv.key() == Some(FORMAT_KEY))
1809            .and_then(|kv| kv.value().map(str::to_string));
1810        assert_eq!(
1811            stamped.as_deref(),
1812            Some(FORMAT_VERSION.to_string().as_str())
1813        );
1814        assert!(open_table(&path).is_ok());
1815        let _ = fs::remove_dir_all(&d);
1816    }
1817
1818    /// A publish that fails after staging must take its staging directory with
1819    /// it. `expire` only ever scans `<root>/<signal>`, so anything left under
1820    /// `.tmp` is invisible to retention until the next boot sweep — and the
1821    /// flusher retries this every couple of seconds, which is what turns one
1822    /// full disk into a thousand leaked directories an hour.
1823    #[test]
1824    fn a_failed_publish_leaves_no_staging_directory() {
1825        let root = dir("leak");
1826        // A file where the signal directory goes, so `create_dir_all` on the
1827        // partition fails with the staging directory already written.
1828        fs::write(root.join("logs"), b"not a directory").unwrap();
1829
1830        assert!(publish(&root, "logs", node_id("a"), 0, 0, &sealed(1_000, 2_000)).is_err());
1831
1832        let staged: Vec<_> = fs::read_dir(root.join(".tmp"))
1833            .unwrap()
1834            .map(|e| e.unwrap().path())
1835            .collect();
1836        assert!(staged.is_empty(), "leaked {staged:?}");
1837        let _ = fs::remove_dir_all(&root);
1838    }
1839
1840    /// A block directory written before the write-ahead log existed has four
1841    /// fields, not five, and it is still a block. There is no manifest to
1842    /// migrate and no version to bump, so the only place backward compatibility
1843    /// can live is here — and reading a legacy name as `wal_hi = 0` is not just
1844    /// lenient, it is the correct answer: nothing published by a Mira without a
1845    /// log covers any log sequence.
1846    #[test]
1847    fn a_block_name_from_before_the_log_parses_with_no_watermark() {
1848        let legacy = format!("{:020}-{:020}-{:08x}-{:012}", 10, 20, 0xabu32, 7u64);
1849        assert_eq!(parse_dir_name(&legacy), Some((10, 20, 0xab, 7, 0)));
1850        assert_eq!(
1851            parse_dir_name(&dir_name(10, 20, 0xab, 7, 99)),
1852            Some((10, 20, 0xab, 7, 99))
1853        );
1854        // A sixth field is not a name from the future to be read hopefully. The
1855        // format is positional, so guessing at one more would mean guessing at
1856        // what it means.
1857        assert_eq!(parse_dir_name(&format!("{legacy}-1-2")), None);
1858    }
1859
1860    /// The watermark is the maximum over a signal's blocks, never the last one
1861    /// listed. `scan` sorts by `(min_ts, seq)`, so a block covering an older
1862    /// hour can be published after a newer one — a backlog replay does exactly
1863    /// that — and taking the last would hand replay a watermark below what is
1864    /// already on disk.
1865    #[test]
1866    fn the_watermark_is_the_highest_per_signal_not_the_newest() {
1867        let root = dir("watermark");
1868        let node = node_id("a");
1869        assert_eq!(wal_watermarks(&root).unwrap(), [0, 0, 0]);
1870
1871        // Published second, timestamped first: `scan` puts this one at the
1872        // front, and its watermark is the low one.
1873        publish(&root, "logs", node, 0, 40, &sealed(5_000, 6_000)).unwrap();
1874        publish(&root, "logs", node, 1, 9, &sealed(1_000, 2_000)).unwrap();
1875        publish(&root, "traces", node, 0, 3, &sealed(1_000, 2_000)).unwrap();
1876
1877        // Indexed by `wal::Signal`: logs, traces, metrics.
1878        assert_eq!(wal_watermarks(&root).unwrap(), [40, 3, 0]);
1879        let _ = fs::remove_dir_all(&root);
1880    }
1881
1882    /// One block that cannot be read used to end the sweep for the whole
1883    /// signal: every block behind the broken one stayed uncompressed for ever,
1884    /// and the same early return in `expire` stopped reclaiming space.
1885    #[test]
1886    fn one_unreadable_block_does_not_stop_the_sweep() {
1887        let root = dir("badblock");
1888        let node = node_id("a");
1889        let bad = publish(&root, "logs", node, 0, 0, &sealed(1_000, 2_000))
1890            .unwrap()
1891            .dir;
1892        let good = publish(&root, "logs", node, 1, 0, &sealed(3_000, 4_000))
1893            .unwrap()
1894            .dir;
1895        // Not an Arrow file at all, which is what a table truncated by a full
1896        // disk looks like from here.
1897        fs::write(bad.join("logs.arrow"), b"junk").unwrap();
1898
1899        assert_eq!(compact(&root, "logs", node, i64::MAX).unwrap(), 1);
1900        assert!(good.join(COLD_MARKER).exists());
1901        assert!(!bad.join(COLD_MARKER).exists(), "declared cold unread");
1902        // The invariant the skip must not break: a block that cannot be
1903        // compacted is still a block retention can take, because expiry never
1904        // opens a file.
1905        assert_eq!(expire(&root, "logs", i64::MAX).unwrap(), 2);
1906        assert!(scan(&root, "logs").unwrap().is_empty());
1907        let _ = fs::remove_dir_all(&root);
1908    }
1909
1910    /// The same for a block that cannot be *deleted*: the rest of the signal
1911    /// still has to be swept, since a stuck retention is how a disk fills.
1912    #[test]
1913    fn one_undeletable_block_does_not_stop_retention() {
1914        use std::os::unix::fs::PermissionsExt;
1915
1916        let root = dir("stuck");
1917        let node = node_id("a");
1918        // Two partitions, an hour apart, so one can be locked without locking
1919        // the other.
1920        let stuck = publish(&root, "logs", node, 0, 0, &sealed(1_000, 2_000))
1921            .unwrap()
1922            .dir;
1923        let free = publish(
1924            &root,
1925            "logs",
1926            node,
1927            1,
1928            0,
1929            &sealed(2 * NANOS_PER_HOUR, 2 * NANOS_PER_HOUR + 1),
1930        )
1931        .unwrap()
1932        .dir;
1933
1934        let locked = stuck.parent().unwrap().to_path_buf();
1935        fs::set_permissions(&locked, fs::Permissions::from_mode(0o555)).unwrap();
1936        // Root ignores the mode bits, so ask the filesystem rather than assume.
1937        if fs::write(locked.join("canary"), []).is_err() {
1938            assert_eq!(expire(&root, "logs", i64::MAX).unwrap(), 1);
1939            assert!(stuck.is_dir(), "the locked block is still there");
1940            assert!(!free.exists(), "the block beside it was still dropped");
1941        }
1942        fs::set_permissions(&locked, fs::Permissions::from_mode(0o755)).unwrap();
1943        let _ = fs::remove_dir_all(&root);
1944    }
1945
1946    /// Free space, for the ingest side to back off on. Only the shape can be
1947    /// asserted here — the number belongs to whatever volume the test runs on.
1948    #[test]
1949    fn free_fraction_is_a_fraction_of_a_real_mount() {
1950        let d = dir("free");
1951        let f = free_fraction(&d).unwrap();
1952        assert!(f > 0.0 && f <= 1.0, "{f} is not a fraction");
1953        // The temp dir and a file on it are the same mount.
1954        let path = d.join("logs.arrow");
1955        write_table(&path, &batch()).unwrap();
1956        assert!((free_fraction(&path).unwrap() - f).abs() < 0.01);
1957        // A path that is not there is an error, not a zero: "no space" and "no
1958        // such directory" are different operational answers.
1959        assert!(free_fraction(&d.join("nope")).is_err());
1960        let _ = fs::remove_dir_all(&d);
1961    }
1962
1963    /// A path is bytes, not a string, and `statfs` takes a C string. An
1964    /// interior NUL would otherwise be truncated by the conversion and the
1965    /// answer would be about a *different* directory — the one whose name is
1966    /// the prefix — which is worse than a refusal because nothing about it
1967    /// looks wrong.
1968    #[test]
1969    fn a_path_with_a_nul_byte_is_refused_rather_than_truncated() {
1970        use std::os::unix::ffi::OsStrExt;
1971
1972        let d = dir("nul");
1973        let mut raw = d.as_os_str().as_encoded_bytes().to_vec();
1974        raw.extend_from_slice(b"\0suffix");
1975        let nul = PathBuf::from(std::ffi::OsStr::from_bytes(&raw));
1976
1977        // The prefix is a real directory this would happily answer about.
1978        assert!(free_fraction(&d).is_ok());
1979        let Err(e) = free_fraction(&nul) else {
1980            panic!("a path with a NUL byte was measured")
1981        };
1982        assert!(
1983            matches!(&e, Error::Io { source, .. }
1984                if source.kind() == io::ErrorKind::InvalidInput),
1985            "{e}"
1986        );
1987        let _ = fs::remove_dir_all(&d);
1988    }
1989
1990    /// A pseudo-filesystem reports no blocks at all. Dividing by that is a NaN
1991    /// the ingest side would read as "not above the threshold" — or a 0% free
1992    /// that stops ingest on a node with nothing wrong with it — so a mount that
1993    /// cannot fill up answers "empty".
1994    ///
1995    /// Conditional on finding one, like
1996    /// [`one_undeletable_block_does_not_stop_retention`] is conditional on not
1997    /// being root: `/proc` is Linux's, `/System/Volumes/Data/home` is the
1998    /// autofs trigger macOS mounts by default, and a host with neither has
1999    /// nothing to assert this over.
2000    #[test]
2001    fn a_mount_that_reports_no_blocks_is_empty_not_full() {
2002        let pseudo = ["/proc", "/sys", "/System/Volumes/Data/home"]
2003            .into_iter()
2004            .map(Path::new)
2005            .find(|p| statfs(p).is_ok_and(|b| b.f_blocks == 0));
2006        if let Some(p) = pseudo {
2007            assert_eq!(free_fraction(p).unwrap(), 1.0, "{}", p.display());
2008        }
2009    }
2010
2011    /// The rule [`check_filesystem`] applies, stated over the three answers
2012    /// [`fs_type`] can give. FUSE has to stay a warning — the magic number is
2013    /// the same for `gcsfuse`, which is as fatal as NFS, and for a local
2014    /// userspace filesystem, which is fine — and everything else it names has
2015    /// to be fatal, because `SIGBUS` under a mapping is not an error any Rust
2016    /// can catch.
2017    #[test]
2018    fn a_network_filesystem_refuses_to_start_and_fuse_only_warns() {
2019        let path = Path::new("/data");
2020        check_fs_type(path, None).unwrap();
2021        check_fs_type(path, Some("fuse".into())).unwrap();
2022
2023        let e = check_fs_type(path, Some("NFS".into())).unwrap_err();
2024        assert!(
2025            matches!(&e, Error::NetworkFilesystem { fs, .. } if fs == "NFS"),
2026            "{e}"
2027        );
2028        // The operator has to be told which mount type, or the message is a
2029        // refusal with no next step in it.
2030        assert!(e.to_string().contains("NFS"), "{e}");
2031    }
2032
2033    /// A cleanup that cannot run is not the failure the caller has to act on.
2034    /// Returning it would replace "the disk is full" with "could not remove a
2035    /// temporary directory", and the second is the one nobody can fix.
2036    #[test]
2037    fn a_staging_directory_that_cannot_be_removed_is_logged_not_returned() {
2038        use std::os::unix::fs::PermissionsExt;
2039
2040        let root = dir("unwind");
2041        let tmp = root.join("logs-0000002a-000000000000-0-0");
2042        fs::create_dir(&tmp).unwrap();
2043        fs::write(tmp.join("logs.arrow"), b"a staged table").unwrap();
2044
2045        fs::set_permissions(&root, fs::Permissions::from_mode(0o555)).unwrap();
2046        // Root ignores the mode bits, so ask the filesystem rather than assume.
2047        if fs::create_dir(root.join("canary")).is_err() {
2048            unwind_staging(&tmp);
2049            assert!(tmp.is_dir(), "the undeletable staging directory went away");
2050        }
2051        fs::set_permissions(&root, fs::Permissions::from_mode(0o755)).unwrap();
2052
2053        unwind_staging(&tmp);
2054        assert!(!tmp.exists(), "the deletable one did not");
2055        // And the ordinary shape of "the rename succeeded and a later fsync did
2056        // not": nothing left to remove, nothing to say about it.
2057        unwind_staging(&tmp);
2058        let _ = fs::remove_dir_all(&root);
2059    }
2060
2061    /// `publish` skips a zero-row table, and this is the same rule one level
2062    /// down. Writing the file anyway would put a header-and-footer-only Arrow
2063    /// file in the block for every table a signal does not use — which the
2064    /// reader would then open, map and CRC on every scan.
2065    #[test]
2066    fn a_table_with_no_batches_writes_no_file() {
2067        let d = dir("nobatch");
2068        let path = d.join("logs.arrow");
2069        write_table_with(&path, &[], None).unwrap();
2070        assert!(!path.exists(), "an empty table left a file behind");
2071        // Which is the same thing the reader sees for a table that was never
2072        // written, and it is not an error.
2073        assert!(open_table_opt(&path).unwrap().is_none());
2074        let _ = fs::remove_dir_all(&d);
2075    }
2076
2077    /// Neither constructor can build this — [`Src::disk`] always has a
2078    /// directory and [`Src::open`] always has tables — so the arm is here for
2079    /// the day a third one does. It answers "no rows", which is the answer
2080    /// every other missing table gets, rather than unwrapping something that is
2081    /// not there.
2082    #[test]
2083    fn a_source_with_neither_a_directory_nor_tables_reads_as_empty() {
2084        let src = Src {
2085            node: 0,
2086            seq: 0,
2087            min_ts: 0,
2088            max_ts: 1,
2089            dir: None,
2090            tables: None,
2091        };
2092        assert!(src.load("logs").unwrap().is_none());
2093        assert!(src.overlaps(0, 1));
2094    }
2095
2096    /// The sweep is capped so the first pass over an existing volume does not
2097    /// saturate the disk, and the cap counts *attempts*: a directory full of
2098    /// unreadable blocks must not re-read every one of them on every sweep for
2099    /// ever. What the cap must not do is lose blocks — the ones it did not
2100    /// reach are still there for the next pass.
2101    #[test]
2102    fn a_sweep_compacts_at_most_its_budget_and_the_next_one_finishes() {
2103        let root = dir("budget");
2104        let node = node_id("a");
2105        let blocks = MAX_COMPACT_PER_SWEEP + 1;
2106        for seq in 0..blocks as u64 {
2107            publish(&root, "logs", node, seq, 0, &sealed(1_000, 2_000)).unwrap();
2108        }
2109
2110        assert_eq!(
2111            compact(&root, "logs", node, i64::MAX).unwrap(),
2112            MAX_COMPACT_PER_SWEEP
2113        );
2114        let cold = |root: &Path| {
2115            scan(root, "logs")
2116                .unwrap()
2117                .iter()
2118                .filter(|b| b.dir.join(COLD_MARKER).exists())
2119                .count()
2120        };
2121        assert_eq!(cold(&root), MAX_COMPACT_PER_SWEEP);
2122
2123        // The next sweep skips the eight already marked and takes the ninth.
2124        assert_eq!(compact(&root, "logs", node, i64::MAX).unwrap(), 1);
2125        assert_eq!(cold(&root), blocks);
2126        // And a third has nothing left to do, which is what the marker is for.
2127        assert_eq!(compact(&root, "logs", node, i64::MAX).unwrap(), 0);
2128        let _ = fs::remove_dir_all(&root);
2129    }
2130
2131    /// Retention on this node or another replica can unlink a block between the
2132    /// `scan` that listed it and the rewrite that would have compacted it.
2133    /// Racing to delete an immutable block is not a conflict, so it must not be
2134    /// logged as a failure — and the sweep has to carry on to the blocks
2135    /// behind it.
2136    ///
2137    /// A block directory that is a dangling symlink stands in for the race: it
2138    /// is what `scan` sees for a name whose directory is no longer there, which
2139    /// is precisely the state the race leaves behind.
2140    #[test]
2141    fn a_block_that_vanished_mid_sweep_is_not_a_failure() {
2142        let root = dir("vanished");
2143        let node = node_id("a");
2144        let good = publish(&root, "logs", node, 0, 0, &sealed(1_000, 2_000))
2145            .unwrap()
2146            .dir;
2147        let gone = good
2148            .parent()
2149            .unwrap()
2150            .join(dir_name(1_000, 2_000, node, 99, 0));
2151        std::os::unix::fs::symlink(root.join("no-such-block"), &gone).unwrap();
2152        assert_eq!(scan(&root, "logs").unwrap().len(), 2);
2153        // Pinned to the exact error the sweep swallows. Anything else would
2154        // take the warning arm instead, which is the same visible outcome and
2155        // the wrong one: a routine race logged as a block that cannot be
2156        // compacted is a false alarm on every retention pass.
2157        assert!(
2158            matches!(compact_block(&gone, node), Err(Error::Io { source, .. })
2159                if source.kind() == io::ErrorKind::NotFound),
2160        );
2161
2162        assert_eq!(compact(&root, "logs", node, i64::MAX).unwrap(), 1);
2163        assert!(good.join(COLD_MARKER).exists(), "the block beside it");
2164        assert!(!gone.join(COLD_MARKER).exists(), "declared cold unread");
2165        let _ = fs::remove_dir_all(&root);
2166    }
2167
2168    /// The 8-byte encapsulation prefix carries a *signed* metadata length read
2169    /// straight off the disk. Negative, or large enough that adding the prefix
2170    /// back overflows, has to be a named error before it reaches
2171    /// `Buffer::slice_with_length` — which panics on a range it does not like,
2172    /// and `panic = "abort"` makes that the death of every open block in the
2173    /// process.
2174    #[test]
2175    fn a_metadata_length_that_is_not_a_length_is_refused() {
2176        let d = dir("metalen");
2177        let path = d.join("logs.arrow");
2178        write_table(&path, &batch()).unwrap();
2179        let good = fs::read(&path).unwrap();
2180        let covered = crc_field(&good).1;
2181
2182        for declared in [-1i32, -8, i32::MIN, i32::MAX, i32::MAX - 7] {
2183            let mut bytes = good.clone();
2184            bytes[HEADER_LEN + 4..HEADER_LEN + 8].copy_from_slice(&declared.to_le_bytes());
2185            let buffer = Buffer::from_vec(bytes);
2186            let Err(e) = message_at(&path, &buffer, HEADER_LEN, covered) else {
2187                panic!("a metadata length of {declared} framed a message")
2188            };
2189            invalid_data(&e, &format!("metadata length {declared}"));
2190            // The diagnostic has to carry the number, or it says nothing about
2191            // which of the file's several lengths is the broken one.
2192            assert!(e.to_string().contains(&declared.to_string()), "{e}");
2193        }
2194        let _ = fs::remove_dir_all(&d);
2195    }
2196
2197    /// The reader takes the schema from the head of the *body*, so the first
2198    /// thing it has to be sure of is that the body starts with a schema at all.
2199    /// Anything else there would be handed to `try_schema_from_ipc_buffer` and
2200    /// then to a decoder built with validation off, which is the combination
2201    /// that reads off the end of the mapping rather than erroring.
2202    #[test]
2203    fn a_body_that_does_not_start_with_a_schema_is_refused() {
2204        let d = dir("noschema");
2205        let path = d.join("logs.arrow");
2206        write_table(&path, &batch()).unwrap();
2207
2208        let mut bytes = fs::read(&path).unwrap();
2209        let tag = header_tag(&bytes, HEADER_LEN);
2210        bytes[tag] = UNKNOWN_MESSAGE;
2211        repair_crc(&mut bytes);
2212        fs::write(&path, &bytes).unwrap();
2213
2214        let Err(e) = open_table(&path) else {
2215            panic!("a body with no schema at the head of it read as a block")
2216        };
2217        invalid_data(&e, "a first message that is not a schema");
2218        assert!(e.to_string().contains("not a schema message"), "{e}");
2219        let _ = fs::remove_dir_all(&d);
2220    }
2221
2222    /// And every message after it is a dictionary or a record batch. A third
2223    /// kind is not something to skip: the chain is walked by stride, so a
2224    /// message the reader does not understand is a message whose length it is
2225    /// trusting without having understood what it describes.
2226    #[test]
2227    fn a_message_after_the_schema_that_is_not_a_batch_is_refused() {
2228        let d = dir("notabatch");
2229        let path = d.join("logs.arrow");
2230        write_table(&path, &batch()).unwrap();
2231
2232        let mut bytes = fs::read(&path).unwrap();
2233        let at = second_message(&path, &bytes);
2234        let tag = header_tag(&bytes, at);
2235        bytes[tag] = UNKNOWN_MESSAGE;
2236        repair_crc(&mut bytes);
2237        fs::write(&path, &bytes).unwrap();
2238
2239        let Err(e) = open_table(&path) else {
2240            panic!("a message that is neither a dictionary nor a batch decoded")
2241        };
2242        invalid_data(&e, "a message that is not a batch");
2243        assert!(
2244            e.to_string()
2245                .contains("is not a dictionary or a record batch"),
2246            "{e}"
2247        );
2248        let _ = fs::remove_dir_all(&d);
2249    }
2250
2251    /// The cold tier's own corruption. A compressed block is the one case where
2252    /// the body is not read out of the mapping, so it is also the one case
2253    /// where a byte the checksum happens to accept lands in a decompressor
2254    /// rather than in an array — and a ZSTD frame that will not decompress has
2255    /// to be an error naming the failure, not a short buffer an array is then
2256    /// built over.
2257    #[test]
2258    fn a_zstd_frame_that_will_not_decompress_is_an_error() {
2259        let d = dir("zstd");
2260        let path = d.join("logs.arrow");
2261        let want = compressible();
2262        write_table_zstd(&path, &want).unwrap();
2263        assert_eq!(open_table(&path).unwrap().batches, vec![want]);
2264
2265        let mut bytes = fs::read(&path).unwrap();
2266        let covered = crc_field(&bytes).1;
2267        // The start of a ZSTD frame, which is what `compress_to_vec` writes
2268        // after the 8-byte uncompressed length — unless the frame came out
2269        // bigger than the plain bytes, which is why the batch above is one
2270        // that compresses.
2271        let frame = bytes[..covered]
2272            .windows(4)
2273            .position(|w| w == [0x28, 0xb5, 0x2f, 0xfd])
2274            .expect("no compressed frame in a compressed table");
2275        bytes[frame..frame + 4].copy_from_slice(&[0xff; 4]);
2276        repair_crc(&mut bytes);
2277        fs::write(&path, &bytes).unwrap();
2278
2279        let Err(e) = open_table(&path) else {
2280            panic!("a block with a broken ZSTD frame decoded")
2281        };
2282        // One variant carries every way arrow-rs can refuse a body, so the
2283        // headline has to be true of all of them and the wrapped source is
2284        // what says which one this is. Pinned as the substring rather than
2285        // the whole sentence so a zstd wording change is not a red build.
2286        assert!(matches!(&e, Error::Undecodable { .. }), "{e}");
2287        assert!(e.to_string().contains("frame"), "{e}");
2288        assert!(!e.to_string().contains("align"), "{e}");
2289        let _ = fs::remove_dir_all(&d);
2290    }
2291}