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}