Skip to main content

mira/
pipeline.rs

1//! The ingest path.
2//!
3//! One bounded `tokio::sync::mpsc` channel per shard feeding one flusher task.
4//!
5//! The brief called for a lock-free ring buffer. That is the right structure
6//! when items are ~150ns order structs arriving millions per second; here an
7//! item is a whole export request costing 10^5–10^6 ns to decode and encode, and
8//! the realistic arrival rate is 10^2–10^4 per second. At that ratio the queue
9//! is never the bottleneck, and a bounded async channel buys the thing a
10//! lock-free queue cannot: `send().await` applies real backpressure that
11//! propagates out as HTTP/2 flow control to the exporter, instead of either
12//! spinning or dropping. If a queue ever shows up in a profile, this is one type
13//! to change.
14//!
15//! Flush is `spawn_blocking`: it fsyncs.
16//!
17//! Where the acknowledgement happens is [`Config::wal`]'s decision, and it is
18//! the only one in this file. Without a log the export is acknowledged after
19//! the block directory rename is durable, because OTLP's retryable status set
20//! covers exports in flight at a crash — acking earlier is the one window where
21//! data is lost with the client believing it was stored. That costs a whole
22//! `max_block_age` at the tail, which is section 11's 2.6 s p99. With a log the frame
23//! *is* the durable record, the publish is a background reorganisation of data
24//! that is already safe, and the ack costs a `write(2)`. Everything else here —
25//! the queue, the carry, the failure contract — is identical either way.
26
27use std::path::{Path, PathBuf};
28use std::sync::atomic::{AtomicU64, Ordering::Relaxed};
29use std::sync::{Arc, Mutex};
30use std::time::Duration;
31
32use mira_core::SignalBuilder;
33use mira_core::block;
34use mira_core::signal::Open;
35use mira_core::wal::{self, Wal};
36use tokio::sync::{mpsc, oneshot};
37use tokio::time::{Instant, sleep_until};
38
39/// Engine configuration.
40///
41/// Note what is *not* reachable from `config.rs`: `target_block_bytes` and
42/// `max_block_age` are derived constants, not settings. They are the two numbers
43/// an operator would most expect to tune and the two the engine is best placed
44/// to own, so principle 2c applies and there is no YAML key for either.
45pub struct Config {
46    pub data_dir: PathBuf,
47    /// Writer identity, from `mira_core::block::node_id`. Makes block names
48    /// unique across replicas with no coordination.
49    pub node: u32,
50    /// Seal a block once it reaches roughly this many bytes.
51    pub target_block_bytes: usize,
52    /// Seal a block after this long regardless of size, so acknowledgement
53    /// latency is bounded by time and not by the caller's traffic.
54    pub max_block_age: Duration,
55    pub retention: Duration,
56    /// How many exports may wait for one signal's flusher. See
57    /// `crate::config::Config::queue`, which is where the reasoning is.
58    ///
59    /// Split across [`shards`](Self::shards), so the number an operator sets is
60    /// still the number of decoded exports this signal can be holding.
61    pub queue: usize,
62    /// How many flusher tasks one signal runs, each with its own channel, open
63    /// block and block sequence.
64    ///
65    /// One per core, not one per resource hash — section 4's "A note on
66    /// sharding" is explicit about which of those is the right unit, and the
67    /// reason is that resource cardinality in real fleets is bimodal, so a hash
68    /// gives a permanently hot shard and a small-file explosion in the tail.
69    /// Files per flush interval should be a function of core count, known at
70    /// startup, not of the customer's topology.
71    ///
72    /// One, not `available_parallelism`, by default: `main` resolves the real
73    /// number from `ingest.shards` and the core count, and everything that
74    /// builds a `Config` by hand — every unit test, every e2e node — wants the
75    /// deterministic one block per seal that a single shard gives.
76    pub shards: usize,
77    /// The write-ahead log, shared by all three signals, or `None` to
78    /// acknowledge on the block publish as Mira always has.
79    ///
80    /// One `Option` rather than a separate durability setting, because the two
81    /// are the same decision: with a log, an export is recoverable the moment
82    /// it is framed and there is nothing left for the acknowledgement to wait
83    /// for; without one, the publish is the only thing that makes it
84    /// recoverable. A flag that let those disagree would only be able to
85    /// express wrong answers.
86    ///
87    /// Off by default. Turning it on would trade read-your-writes away — see
88    /// `mira_core::wal`'s module docs — were it not for
89    /// `mira_core::query::search_open`, which scans the flusher's open builder
90    /// alongside the sealed blocks and buys it back.
91    pub wal: Option<Arc<Wal>>,
92}
93
94impl Default for Config {
95    fn default() -> Self {
96        Self {
97            data_dir: PathBuf::from("./data"),
98            node: block::node_id("mira"),
99            target_block_bytes: 32 << 20,
100            max_block_age: Duration::from_secs(2),
101            retention: Duration::from_secs(7 * 24 * 3600),
102            queue: 128,
103            shards: 1,
104            wal: None,
105        }
106    }
107}
108
109// `pub(crate)` only so `receiver`'s tests can put a queue into the two states
110// `submit` refuses from — full and closed — without a flusher behind it.
111pub(crate) struct Job<R> {
112    req: R,
113    ack: oneshot::Sender<Result<(), Rejected>>,
114    /// The log sequence this export was framed at, if there is a log. The
115    /// flusher collects them per block and asks `Wal::watermark_for` what
116    /// `wal_hi` that set permits — not the maximum over the block, which would
117    /// step over an older frame a sibling shard is still holding.
118    wal_seq: Option<u64>,
119}
120
121/// The write handle for one signal. `R` is that signal's OTLP export request.
122pub struct Ingest<R> {
123    // `pub(crate)` so a test can build one around a queue it controls; see
124    // [`Job`]. Nothing outside this module constructs one in anger — `spawn`
125    // is the only supported way to get a handle.
126    //
127    // One sender per flusher shard, and never empty. `Arc<[_]>` rather than
128    // `Vec` because every receiver holds a clone of this handle and the shard
129    // count is fixed at startup.
130    pub(crate) tx: Arc<[mpsc::Sender<Job<R>>]>,
131    /// Where the next export that cannot go to shard 0 starts looking. Shared
132    /// across clones, because the point of it is to spread waiters over shards
133    /// rather than over handles.
134    pub(crate) turn: Arc<AtomicU64>,
135    pub(crate) rejects: &'static Rejects,
136    pub(crate) wal: Option<Arc<Wal>>,
137    pub(crate) signal: wal::Signal,
138}
139
140// Derived `Clone` would demand `R: Clone`, which no export request is. Only the
141// `Sender`s are cloned, and that is unconditional.
142impl<R> Clone for Ingest<R> {
143    fn clone(&self) -> Self {
144        Self {
145            tx: self.tx.clone(),
146            turn: self.turn.clone(),
147            rejects: self.rejects,
148            wal: self.wal.clone(),
149            signal: self.signal,
150        }
151    }
152}
153
154/// Why an export could not be admitted. None of these is a partial success:
155/// OTLP forbids the client from retrying a partial success, so reporting
156/// overload that way permanently destroys the data and blames the sender.
157///
158/// The split between the last two is the whole of the failure contract. OTLP's
159/// retryable set is closed — gRPC `UNAVAILABLE` and friends, HTTP 429/502/503/504
160/// — and an exporter handed anything outside it drops the batch on the floor. So
161/// "we could not write it, try again" and "this export can never be written"
162/// cannot share a variant, however similar they look from inside the flusher.
163pub enum Rejected {
164    /// Queue full. Transient; retry.
165    Busy,
166    /// The engine is shutting down.
167    Closed,
168    /// The block this export was in did not become durable — a full disk, an
169    /// EIO, a flush task that panicked. Nothing about the export caused it and
170    /// the next one may well land, so it is answered like [`Rejected::Busy`].
171    Unavailable(String),
172    /// This export can never be stored: it does not fit an empty block. A retry
173    /// produces the same answer, so the client must be told not to send one.
174    Failed(String),
175}
176
177/// Wall-clock seconds. Only ever used for rate limits and for ages an operator
178/// reads; block timestamps come from the data, never from this clock.
179fn now_secs() -> u64 {
180    std::time::SystemTime::now()
181        .duration_since(std::time::UNIX_EPOCH)
182        .unwrap_or_default()
183        .as_secs()
184}
185
186/// True at most once per wall-clock second per gate, for whichever caller gets
187/// there first.
188///
189/// A node in trouble is in trouble thousands of times a second and the log line
190/// is worth exactly one of them; the rest is in the counter beside it. `swap`,
191/// not load-then-store, so of the many threads arriving in the same second
192/// exactly one sees the old value.
193fn once_a_second(gate: &AtomicU64) -> bool {
194    let now = now_secs();
195    gate.swap(now, Relaxed) != now
196}
197
198/// How long a signal has to be unable to store anything before this node calls
199/// itself unready.
200///
201/// It has to outlast the automatic recovery, or readiness flaps through every
202/// incident it is supposed to report. The two recoveries are a retention sweep,
203/// which runs every 60s and is what frees a full volume ([`reclaim`]), and the
204/// next flush, which is at most `max_block_age` behind it. Two sweeps gives that
205/// path two chances before the endpoint is pulled — and at a 2s block age it is
206/// already ~60 consecutive failed publishes, which is nobody's transient.
207pub const UNREADY_AFTER: Duration = Duration::from_secs(120);
208
209/// How long an export waits for room in the queue before it is shed.
210///
211/// Under the OTLP exporter timeout, which is 10s in every SDK that follows the
212/// spec's default, so a waiter is answered by this node rather than abandoned
213/// by its client — an abandoned request is the one case where the work is paid
214/// twice and nobody is told. Over `max_block_age`, which is 2s, so a queue that
215/// is full only because a flush is in flight drains within the wait instead of
216/// shedding around it. Five seconds sits in the middle of that range.
217///
218/// This is the tail bound, not a target: at the operating point nothing waits
219/// at all. It matters when a sender is faster than the disk, and there the
220/// choice is between a slow ack and a 503 that costs the sender a retry and
221/// this node the decode it already did.
222const ADMIT_WAIT: Duration = Duration::from_secs(5);
223
224/// What each signal has refused, published and been stuck on, since start.
225///
226/// A handful of counters and one warn a second, not a metrics subsystem. An
227/// exporter being NACKed already logs Mira's own reason on its side; what only
228/// the server can say is the *rate* and how long it has been going on, which is
229/// what an operator reads out of `/health`, `/readyz` and `/api/v1/stats` (see
230/// `main`) when deciding whether to grow the disk or the node.
231///
232/// Static because those three endpoints need all three signals at once and
233/// nothing else ever reads them: threading a handle per signal through two
234/// routers to reach one probe would be more plumbing than the numbers are worth.
235pub struct Rejects {
236    /// The signal these count for, so the endpoints can name them.
237    pub signal: &'static str,
238    /// Exports refused before the queue, because it was full.
239    pub shed: AtomicU64,
240    /// Exports accepted and then NACKed, because the write did not land.
241    pub failed: AtomicU64,
242    /// Exports refused permanently, whose records are gone: the client is told
243    /// not to retry, so this is the only counter that measures lost data.
244    pub refused: AtomicU64,
245    /// Blocks and rows that reached the disk, and the bytes they took there.
246    pub published: AtomicU64,
247    pub rows: AtomicU64,
248    pub bytes: AtomicU64,
249    /// Unix second the oldest currently open block of this signal took its
250    /// first row, or 0 if nothing is open. An age that keeps growing past
251    /// `max_block_age` is a flusher that is not flushing.
252    ///
253    /// Derived from [`shard_open_since`](Self::shard_open_since) and not
254    /// written directly by a
255    /// flusher: with more than one shard per signal, a shard that has just
256    /// sealed would otherwise clear a sibling's clock and the stuck flusher
257    /// this number exists to expose would read as healthy. The oldest of them,
258    /// because the question it answers is "is anything stuck".
259    pub open_since: AtomicU64,
260    /// Unix second of the first publish failure in the current run of them, or 0
261    /// if the last publish worked. See [`UNREADY_AFTER`]. Derived like
262    /// `open_since`, and for the same reason.
263    pub stalled_since: AtomicU64,
264    /// What each flusher shard actually writes; the two above are the oldest of
265    /// each.
266    ///
267    /// Fixed arrays rather than ones sized at `spawn`: these live in a `static`
268    /// that outlives every flusher and is re-entered by the next test in the
269    /// process, so a `OnceLock` sized by whoever spawned first would be the
270    /// wrong length for whoever spawns second. [`MAX_SHARDS`] pairs is 256
271    /// bytes a signal.
272    shard_open_since: [AtomicU64; MAX_SHARDS],
273    shard_stalled_since: [AtomicU64; MAX_SHARDS],
274    /// Rate-limit gates, one per line that can fire per export.
275    warned: AtomicU64,
276    refuse_warned: AtomicU64,
277}
278
279impl Rejects {
280    const fn new(signal: &'static str) -> Self {
281        Self {
282            signal,
283            shed: AtomicU64::new(0),
284            failed: AtomicU64::new(0),
285            refused: AtomicU64::new(0),
286            published: AtomicU64::new(0),
287            rows: AtomicU64::new(0),
288            bytes: AtomicU64::new(0),
289            open_since: AtomicU64::new(0),
290            stalled_since: AtomicU64::new(0),
291            shard_open_since: [const { AtomicU64::new(0) }; MAX_SHARDS],
292            shard_stalled_since: [const { AtomicU64::new(0) }; MAX_SHARDS],
293            warned: AtomicU64::new(0),
294            refuse_warned: AtomicU64::new(0),
295        }
296    }
297
298    /// The oldest non-zero timestamp any shard is reporting, or 0 if none is.
299    ///
300    /// Recomputed on every write rather than on every read because the readers
301    /// are `/healthz`, `/metrics` and the TUI — three calls a second between
302    /// them against a hot loop — and [`MAX_SHARDS`] relaxed loads is cheaper
303    /// than the branch that would decide when to skip it.
304    fn oldest(slots: &[AtomicU64; MAX_SHARDS]) -> u64 {
305        slots
306            .iter()
307            .map(|t| t.load(Relaxed))
308            .filter(|&t| t != 0)
309            .min()
310            .unwrap_or(0)
311    }
312
313    /// Report when `shard`'s open block took its first row, or 0 for "nothing
314    /// open".
315    fn set_open_since(&self, shard: usize, at: u64) {
316        self.shard_open_since[shard].store(at, Relaxed);
317        self.open_since
318            .store(Self::oldest(&self.shard_open_since), Relaxed);
319    }
320
321    /// Start the clock on a run of failures in `shard`, or leave it where it is.
322    ///
323    /// Not a `store`: readiness is about how *long* this has been going on, so
324    /// the timestamp that matters is the first failure of the run, not the
325    /// latest. One flusher owns each shard slot, so the compare-exchange cannot
326    /// lose a race — it is here to keep the first value.
327    fn mark_stalled(&self, shard: usize) {
328        let _ = self.shard_stalled_since[shard].compare_exchange(
329            0,
330            now_secs().max(1),
331            Relaxed,
332            Relaxed,
333        );
334        self.stalled_since
335            .store(Self::oldest(&self.shard_stalled_since), Relaxed);
336    }
337
338    /// Zero every open-block clock, shard slots included.
339    ///
340    /// Only the in-process end-to-end harness calls this — see
341    /// `e2e::forget_open_blocks` for why it has to. Clearing the aggregate
342    /// alone would not do it: the next shard to open a block recomputes the
343    /// aggregate from the slots, and a dead flusher's slot would come back.
344    #[cfg(test)]
345    pub fn forget_open(&self) {
346        for t in &self.shard_open_since {
347            t.store(0, Relaxed);
348        }
349        self.open_since.store(0, Relaxed);
350    }
351
352    /// `shard` stored a block, so its run of failures is over. The signal is
353    /// only unstalled once every shard's is.
354    fn clear_stalled(&self, shard: usize) {
355        self.shard_stalled_since[shard].store(0, Relaxed);
356        self.stalled_since
357            .store(Self::oldest(&self.shard_stalled_since), Relaxed);
358    }
359
360    fn record_shed(&self) {
361        self.shed.fetch_add(1, Relaxed);
362        if once_a_second(&self.warned) {
363            tracing::warn!(
364                signal = self.signal,
365                "ingest queue full; shedding exports (senders are told to retry)"
366            );
367        }
368    }
369}
370
371/// Parallel to [`SIGNALS`].
372pub static REJECTS: [Rejects; SIGNALS.len()] = [
373    Rejects::new(SIGNALS[0]),
374    Rejects::new(SIGNALS[1]),
375    Rejects::new(SIGNALS[2]),
376];
377
378fn rejects_for(signal: &str) -> &'static Rejects {
379    REJECTS
380        .iter()
381        .find(|r| r.signal == signal)
382        .expect("every signal that has a builder has a counter slot")
383}
384
385/// Seconds `r` has been unable to store an export, if that is long enough to be
386/// worth acting on. Split out from [`stalled`] so the threshold is testable
387/// without writing to a process-wide static that three live flushers also own.
388fn stall_of(r: &Rejects, now: u64) -> Option<u64> {
389    match r.stalled_since.load(Relaxed) {
390        0 => None,
391        since => {
392            let secs = now.saturating_sub(since);
393            (secs >= UNREADY_AFTER.as_secs()).then_some(secs)
394        }
395    }
396}
397
398/// The first signal this node has been unable to store for longer than
399/// [`UNREADY_AFTER`], and for how many seconds. `None` means every signal is
400/// either healthy or has only just started failing.
401pub fn stalled() -> Option<(&'static str, u64)> {
402    let now = now_secs();
403    REJECTS
404        .iter()
405        .find_map(|r| stall_of(r, now).map(|secs| (r.signal, secs)))
406}
407
408impl<R> Ingest<R> {
409    /// A slot in the first shard that has one, or `None` if every shard is full
410    /// or gone.
411    ///
412    /// First fit from shard 0 rather than round-robin, and that is the whole
413    /// sharding policy. Round-robin spreads a trickle of exports over every
414    /// shard, and since each shard owns its own open block, a node doing two
415    /// exports a second would publish `shards` nearly-empty blocks every
416    /// `max_block_age` instead of one — the small-file explosion section 4
417    /// rejects hash sharding for, arrived at from the other direction. First
418    /// fit keeps a node that is not saturating one flusher behaving exactly as
419    /// it did with one, and starts using the second shard at the moment the
420    /// first one's queue stops draining, which is the moment the consumer's
421    /// service time became the curve.
422    ///
423    /// Ordering across shards is not preserved and does not need to be: two
424    /// exports are two OTLP requests, the spec orders neither against the
425    /// other, and block timestamps come from the data. Ordering *within* a
426    /// shard still is, which is what the carry rule in [`flusher`] needs.
427    fn reserve(&self) -> Option<mpsc::Permit<'_, Job<R>>> {
428        self.tx.iter().find_map(|tx| tx.try_reserve().ok())
429    }
430}
431
432impl<R: prost::Message> Ingest<R> {
433    /// Enqueue and wait for durability.
434    ///
435    /// What "durable" means here is the one thing [`Config::wal`] decides.
436    /// Without a log this returns once the block containing the request has
437    /// been fsynced and renamed into place, which is correct and costs a whole
438    /// `max_block_age` at the tail. With one it returns once the request is a
439    /// frame in the log's page cache, which is section 11's 2.6 s p99 turned into
440    /// microseconds and is why the log exists.
441    pub async fn submit(&self, req: R) -> Result<(), Rejected> {
442        let (ack, wait) = oneshot::channel();
443        // Wait for room, and only shed once the wait has run out. The first
444        // revision shed the moment the queue was full, on the reasoning that a
445        // fast NACK beats an unbounded latency tail. The tail argument is right
446        // and [`ADMIT_WAIT`] bounds it; the "fast" was not. Tonic and axum both
447        // decode the request before the handler is called, so by the time this
448        // runs the expensive part of the export is already paid, and shedding
449        // throws it away for a client that will send the same bytes again.
450        // Measured at 96 connections that cost more than the queue ever saved:
451        // 93% of exports shed, four cores busy, and a third of the throughput
452        // two connections get on one core. Parking instead is bounded by the
453        // connection count — every waiter is a request already in memory — where
454        // a deeper queue is bounded by nothing.
455        //
456        // Before the log append, not after: an export shed here never happened,
457        // whereas one framed and then shed would be replayed into a node whose
458        // client has already retried it elsewhere.
459        let permit = match self.reserve() {
460            Some(p) => p,
461            // Every shard is full. Park on one of them rather than on all of
462            // them: `reserve` is not cancel-safe enough to race K of them and
463            // drop the losers' permits, and at this point the choice of shard
464            // does not matter — they are all behind their flusher. The turn
465            // counter spreads the parked waiters so they wake as each drains
466            // rather than all behind the same one.
467            None => {
468                let i = self.turn.fetch_add(1, Relaxed) as usize % self.tx.len();
469                match tokio::time::timeout(ADMIT_WAIT, self.tx[i].reserve()).await {
470                    Ok(Ok(p)) => p,
471                    Ok(Err(_)) => return Err(Rejected::Closed),
472                    Err(_) => {
473                        self.rejects.record_shed();
474                        return Err(Rejected::Busy);
475                    }
476                }
477            }
478        };
479        if let Some(wal) = &self.wal {
480            // Re-encoded, not the bytes off the wire: tonic decodes before the
481            // handler sees the request, and a KYAML body was never protobuf at
482            // all. Measured at 864 MiB/s against the 244 MiB/s decode already in
483            // the path — see `mira_core::wal`'s module docs for why owning a
484            // tonic `Codec` to avoid it is the worse trade.
485            let body = req.encode_to_vec();
486            // The enqueue rides inside the append so the queue cannot reorder
487            // what the log numbered — see `Wal::append_then`.
488            return match wal.append_then(self.signal, &body, move |seq| {
489                permit.send(Job {
490                    req,
491                    ack,
492                    wal_seq: Some(seq),
493                });
494            }) {
495                Ok(_) => Ok(()),
496                Err(e) => {
497                    self.rejects.failed.fetch_add(1, Relaxed);
498                    // Only one log error is the sender's to fix, and retrying an
499                    // export too large to frame just burns the link.
500                    Err(match e {
501                        mira_core::Error::WalFrameTooLarge { .. } => {
502                            Rejected::Failed(e.to_string())
503                        }
504                        _ => Rejected::Unavailable(e.to_string()),
505                    })
506                }
507            };
508        }
509        permit.send(Job {
510            req,
511            ack,
512            wal_seq: None,
513        });
514        match wait.await {
515            Ok(Ok(())) => Ok(()),
516            // One counter for both refusals after acceptance: the difference
517            // between them is the status code, and the number an operator wants
518            // is "how much did not get stored".
519            Ok(Err(r)) => {
520                self.rejects.failed.fetch_add(1, Relaxed);
521                Err(r)
522            }
523            Err(_) => Err(Rejected::Closed),
524        }
525    }
526}
527
528impl<R: prost::Message + Default> Ingest<R> {
529    /// Push one frame recovered from the log back into this signal's flusher,
530    /// under the sequence it already has.
531    ///
532    /// Not [`submit`](Self::submit): the frame is in the log already, so
533    /// re-appending it would number it above every watermark and the block
534    /// storing it would claim the copy instead of the original — which replays
535    /// again on the next boot, and the one after that. Nothing waits for the
536    /// ack either; the client that sent this got its answer before the crash,
537    /// or gave up long ago.
538    ///
539    /// Blocking, and deliberately: this is called from a `spawn_blocking` hop
540    /// at boot, and the bounded channel is the only thing keeping a multi-
541    /// gigabyte log from being decoded into memory faster than it can be
542    /// sealed.
543    /// The two failures are worth telling apart: [`Rejected::Failed`] is one
544    /// frame that will never decode, which is a line in the log and the next
545    /// frame; [`Rejected::Closed`] is the flusher being gone, which means the
546    /// rest of the replay would go nowhere.
547    pub fn replay(&self, body: &[u8], seq: u64) -> Result<(), Rejected> {
548        // Back among the unpublished before it is decoded, let alone queued, for
549        // the same reason `submit` numbers and enqueues under one lock: a shard
550        // that sealed in between would compute a watermark that steps over this
551        // frame.
552        if let Some(w) = &self.wal {
553            w.reframed(self.signal, seq);
554        }
555        let req = match R::decode(body) {
556            Ok(r) => r,
557            Err(e) => {
558                // Retired on the spot, and that is a decision rather than a
559                // leak. This frame will not decode on this boot and will not
560                // decode on any other, so leaving it unpublished would hold the
561                // signal's watermark at its sequence for the life of the volume
562                // — and every frame published after it would be replayed again
563                // on every boot, duplicating stored data to keep re-reading one
564                // that never becomes readable. `main::replay` says out loud that
565                // those exports are gone; this is the line that makes it true.
566                if let Some(w) = &self.wal {
567                    w.published(self.signal, &[seq]);
568                }
569                return Err(Rejected::Failed(e.to_string()));
570            }
571        };
572        let job = Job {
573            req,
574            ack: oneshot::channel().0,
575            wal_seq: Some(seq),
576        };
577        // First fit like `submit`, falling back to blocking on shard 0. Replay
578        // is the one caller that must not shed, so the bounded channel is the
579        // pacing: see the note above about decoding a multi-gigabyte log.
580        match self.reserve() {
581            Some(p) => {
582                p.send(job);
583                Ok(())
584            }
585            None => self.tx[0].blocking_send(job).map_err(|_| Rejected::Closed),
586        }
587    }
588}
589
590/// Every signal that has an on-disk directory. Retention sweeps all of them;
591/// [`block::scan`] treats a missing one as empty, so listing a signal before its
592/// encoder exists is harmless.
593pub const SIGNALS: [&str; 3] = ["logs", "traces", "metrics"];
594
595/// The most flusher shards one signal will run.
596///
597/// A ceiling, not a target: it sizes the per-shard health clocks in a `static`
598/// and it bounds how many blocks a signal can publish per flush interval. Above
599/// this the flushers are no longer the bottleneck — the decode in front of them
600/// is — and every extra shard is another open block's worth of resident memory
601/// against the footprint axis. Sixteen is also `mira_core::query`'s scan fanout,
602/// and a machine wide enough to want more of one wants more of both.
603pub const MAX_SHARDS: usize = 16;
604
605/// How many flusher shards to run, given the configured value and what the
606/// machine reports.
607///
608/// Zero means "ask the machine", which is the default and the only value
609/// `config.rs` documents as auto. The count is a function of core count and
610/// nothing else — not of the resource cardinality, not of the connection count
611/// — which is section 4's rule for what a shard may be keyed on.
612///
613/// Halved, because a shard is a *consumer*: the producers are the decode
614/// and the runtime's own work, and giving every core a flusher leaves nothing
615/// to feed them. Section 11 measured 1.56M records/s on 2.18 cores with one
616/// flusher per signal, so the transcode is around a third of the total and
617/// three consumers per two producers would be an idle two-thirds.
618pub fn shard_count(configured: usize, cores: usize) -> usize {
619    match configured {
620        0 => (cores / 2).clamp(1, MAX_SHARDS),
621        n => n.min(MAX_SHARDS),
622    }
623}
624
625/// The handle over one signal's flusher shards: what a single `JoinHandle`
626/// meant when there was one of them, kept true now that there are several.
627///
628/// Awaiting it resolves when every shard has stopped; `abort` — test-only, see
629/// below — stops them all where they stand. A [`JoinSet`](tokio::task::JoinSet) and not
630/// a task that awaits a `Vec` of handles, because that shape gets the second
631/// half wrong: aborting such a task drops only its own future, so the shards
632/// keep running, and a shard that outlives the node it belonged to watches its
633/// senders drop, reads that as a graceful close, and seals a block — under a
634/// sequence, and into a staging directory, that the successor node is already
635/// using.
636pub struct Flushers(tokio::task::JoinSet<()>);
637
638/// Dropping the handle detaches, as dropping a `JoinHandle` does — a `JoinSet`
639/// on its own would abort instead. Read-only test harnesses build a node and
640/// keep only the parts they query; turning that into "and stop ingesting" would
641/// be a trap, and every caller that means to stop has one of the two ways to
642/// say so above.
643impl Drop for Flushers {
644    fn drop(&mut self) {
645        self.0.detach_all();
646    }
647}
648
649impl Flushers {
650    /// Stop every shard where it stands. Nothing is sealed; what survives is
651    /// whatever the log already holds — which is the claim every test that
652    /// calls this is making.
653    ///
654    /// Test-only, and that is not an oversight: the binary sets
655    /// `panic = "abort"`, so in production a flusher that stops without being
656    /// asked takes the process with it and there is nobody left to abort.
657    #[cfg(test)]
658    pub fn abort(&mut self) {
659        self.0.abort_all();
660    }
661
662    /// Three shards that will never stop, for the drain path that has to give
663    /// up on them.
664    #[cfg(test)]
665    pub fn wedged() -> Self {
666        let mut set = tokio::task::JoinSet::new();
667        set.spawn(std::future::pending());
668        Self(set)
669    }
670}
671
672impl std::future::Future for Flushers {
673    type Output = Result<(), tokio::task::JoinError>;
674
675    /// Ready once the set has drained. Unlike a `JoinHandle` this is safe to
676    /// poll again afterwards — an emptied set answers `Ready` forever — which
677    /// is what lets `main` await a handle `first_stopped` may already have run
678    /// to completion.
679    fn poll(
680        mut self: std::pin::Pin<&mut Self>,
681        cx: &mut std::task::Context<'_>,
682    ) -> std::task::Poll<Self::Output> {
683        use std::task::Poll;
684        loop {
685            match self.0.poll_join_next(cx) {
686                Poll::Ready(Some(Ok(()))) => {}
687                // Surfaced rather than swallowed: `cargo test` does not build
688                // with `panic = "abort"`, so a flusher that panics under test
689                // is a `JoinError` here and nothing else anywhere.
690                Poll::Ready(Some(Err(e))) => return Poll::Ready(Err(e)),
691                Poll::Ready(None) => return Poll::Ready(Ok(())),
692                Poll::Pending => return Poll::Pending,
693            }
694        }
695    }
696}
697
698/// Start one signal's ingest pipeline. Returns the handle its receivers push
699/// into. Each signal gets its own channels, flusher tasks and block sequences,
700/// so a slow flush on one cannot stall another.
701///
702/// [`Flushers`] is the shutdown contract: drop every [`Ingest`] clone and every
703/// shard seals whatever is open, acks everyone waiting on it and returns; the
704/// handle resolves once they all have. A caller that exits without awaiting it
705/// turns a graceful stop into a reset for those waiters.
706pub fn spawn<B: SignalBuilder>(cfg: &Arc<Config>) -> (Ingest<B::Request>, OpenSlot, Flushers) {
707    let shards = cfg.shards.clamp(1, MAX_SHARDS);
708    // Once for the signal, before any shard can publish. Inside the flusher it
709    // would be one sweep per shard, and `sweep_staging` filters by signal and
710    // node — not by sequence — so shard 3 booting a moment late would delete the
711    // staging directory shard 0 was already writing tables into.
712    //
713    // Not fatal if it fails: a leaked directory under `.tmp` costs disk and
714    // nothing else, and refusing to ingest over it would turn a janitorial
715    // problem into an outage.
716    match block::sweep_staging(&cfg.data_dir, B::SIGNAL, cfg.node) {
717        Ok(0) => {}
718        Ok(n) => tracing::info!(signal = B::SIGNAL, count = n, "swept stale staging dirs"),
719        Err(e) => tracing::warn!(signal = B::SIGNAL, error = %e, "cannot sweep staging dirs"),
720    }
721    // Resume the sequence past whatever is already on disk so block directory
722    // names stay unique across restarts. This is the entirety of crash recovery.
723    //
724    // `max`, not `last`: `scan` sorts by `(min_ts, seq)`, so the last element is
725    // the latest-timestamped block, which is not the highest sequence number
726    // whenever a restart follows a backlog replay. Reusing a sequence makes the
727    // next `rename` land on an existing directory and the node never publishes
728    // again.
729    //
730    // Scanned here and handed to every shard rather than scanned by each of
731    // them, and that is load-bearing: a shard that read the directory after a
732    // sibling had already published would resume one higher and its stride
733    // would land on the sibling's next sequence. One base, distinct offsets.
734    let resume = match block::scan(&cfg.data_dir, B::SIGNAL) {
735        Ok(blocks) => Some(blocks.iter().map(|b| b.seq).max().map_or(0, |s| s + 1)),
736        Err(e) => {
737            tracing::error!(signal = B::SIGNAL, error = %e, "cannot scan data directory");
738            None
739        }
740    };
741    let rejects = rejects_for(B::SIGNAL);
742    // The configured depth is the signal's, not each shard's: it is a bound on
743    // how many decoded exports this node can be holding, and that does not get
744    // larger because there are more consumers.
745    let depth = cfg.queue.div_ceil(shards).max(1);
746    let mut txs = Vec::with_capacity(shards);
747    let mut slots = Vec::with_capacity(shards);
748    let mut tasks = tokio::task::JoinSet::new();
749    for shard in 0..shards {
750        let (tx, rx) = mpsc::channel(depth);
751        // Eight concurrent askers, because a ninth gets a snapshot at most a
752        // millisecond older and waiting in line for one is worth less than that.
753        let (ask, asks) = mpsc::channel(8);
754        let slot = Shard {
755            cur: Arc::default(),
756            ask,
757        };
758        txs.push(tx);
759        // No flusher if the data directory could not be read, which drops the
760        // receiver and leaves the sender closed: `submit` answers `Closed` and
761        // the handle resolves at once. The same thing the flusher's own early
762        // return did, decided once instead of `shards` times.
763        if let Some(resume) = resume {
764            tasks.spawn(flusher::<B>(
765                rx,
766                asks,
767                cfg.clone(),
768                slot.clone(),
769                shard,
770                shards,
771                resume,
772            ));
773        }
774        slots.push(slot);
775    }
776    let ingest = Ingest {
777        tx: txs.into(),
778        turn: Arc::default(),
779        rejects,
780        wal: cfg.wal.clone(),
781        signal: wal::Signal::named(B::SIGNAL).expect("every signal has a log discriminant"),
782    };
783    // One handle over all of them, so `main` still holds three. Awaiting every
784    // shard rather than the first to finish is the same contract it was: under
785    // `panic = "abort"` a flusher that dies takes the process with it, and the
786    // one way a shard returns early — an unreadable data directory at startup —
787    // is a condition every shard of the signal meets at once.
788    (
789        ingest,
790        OpenSlot {
791            shards: slots.into(),
792        },
793        Flushers(tasks),
794    )
795}
796
797/// Where the read path asks one flusher shard for a readable copy of its open
798/// block (section 4), and where the last copy it produced is cached.
799///
800/// The snapshot is taken on demand, never on a timer: an idle node with nobody
801/// querying it copies nothing. A `Mutex` around the cached `Arc` rather than an
802/// `ArcSwap` — the critical section is one pointer clone and a crate for that
803/// would be a crate for nothing.
804#[derive(Clone)]
805struct Shard {
806    cur: Arc<Mutex<Option<Arc<Open>>>>,
807    /// Handing the flusher somewhere to put an answer. Not generic in the
808    /// signal's request type, which is the whole reason the read path can hold
809    /// three of these in one array.
810    ask: mpsc::Sender<oneshot::Sender<Option<Arc<Open>>>>,
811}
812
813/// Every shard of one signal's open blocks, asked together.
814///
815/// The interesting half is [`OpenSlot::fresh`], and what makes it *fresh*
816/// rather than merely recent is the order the queues already enforce. `submit`
817/// acknowledges an export only after the job is in some shard's channel, so
818/// every acknowledged export is queued before a request issued after it — and
819/// if each shard answers only once its own channel is empty, the answers
820/// together necessarily contain them all. That is read-your-writes, for the
821/// price of FIFOs Mira was already paying, with no shared counter and no clock.
822///
823/// Sharding does not weaken it, because the argument never depended on there
824/// being one queue: an acknowledged export is in exactly one shard's channel
825/// until that shard appends it. It does mean the read path has to ask all of
826/// them, which is what `fresh` does.
827#[derive(Clone, Default)]
828pub struct OpenSlot {
829    /// Empty for a slot nobody serves: `fresh` returns nothing, forever. That
830    /// is exactly what a unit test that only wants an `Api` wants.
831    shards: Arc<[Shard]>,
832}
833
834impl OpenSlot {
835    /// Everything acknowledged before this call, as one readable block per
836    /// shard that has anything open.
837    ///
838    /// Falls back to a shard's last snapshot when its flusher cannot be
839    /// reached: the request queue is full, or the task is gone. Both are
840    /// overload or shutdown, and a query that waits its turn behind an
841    /// overloaded ingest path is a worse answer than one that is a few
842    /// milliseconds stale.
843    ///
844    /// Every ask goes out before any answer is awaited, so the shards work
845    /// concurrently; awaiting them in turn would put a whole flusher's backlog
846    /// between one shard's answer and the next one's question.
847    pub async fn fresh(&self) -> Vec<Arc<Open>> {
848        let mut out = Vec::with_capacity(self.shards.len());
849        let mut waiting = Vec::with_capacity(self.shards.len());
850        for s in self.shards.iter() {
851            let (tx, rx) = oneshot::channel();
852            match s.ask.try_send(tx) {
853                Ok(()) => waiting.push((s, rx)),
854                Err(_) => out.extend(s.get()),
855            }
856        }
857        for (s, rx) in waiting {
858            out.extend(rx.await.unwrap_or_else(|_| s.get()));
859        }
860        out
861    }
862}
863
864impl Shard {
865    /// The last snapshot taken, without asking for a new one.
866    fn get(&self) -> Option<Arc<Open>> {
867        self.lock().clone()
868    }
869
870    fn put(&self, v: Option<Arc<Open>>) {
871        *self.lock() = v;
872    }
873
874    /// A panic in this critical section is not possible — it clones or drops an
875    /// `Arc` and nothing else — so poisoning carries no information and
876    /// unwrapping it would only turn an impossible bug into an outage.
877    fn lock(&self) -> std::sync::MutexGuard<'_, Option<Arc<Open>>> {
878        self.cur.lock().unwrap_or_else(|e| e.into_inner())
879    }
880}
881
882/// The three signals' open blocks, in [`SIGNALS`] order.
883pub type OpenSlots = [OpenSlot; SIGNALS.len()];
884
885/// One sweep for all signals, not one per signal: retention is IO against the
886/// directory tree, and three tasks waking on the same minute boundary to unlink
887/// from the same volume is contention for nothing.
888pub fn spawn_retention(cfg: Arc<Config>) {
889    tokio::spawn(retention(cfg.clone()));
890    if cfg.wal.is_some() {
891        tokio::spawn(wal_maintenance(cfg));
892    }
893}
894
895/// How long an acknowledged export can sit in the page cache before it is on
896/// the device.
897///
898/// This is the entire power-loss exposure window, and it is a constant for the
899/// same reason `max_block_age` is: the operator cannot price the trade without
900/// knowing what an fsync costs on their volume, and the engine measures that
901/// every time it does one. On this machine `F_FULLFSYNC` is ~4 ms (section 10), so a
902/// quarter-second period spends under 2% of one thread and bounds the loss at
903/// a quarter second of ingest. Shorter buys very little — the exposure is
904/// already smaller than a Collector's own batch timeout, so the exporter is
905/// holding more unsent data than this window holds unsynced.
906pub const WAL_SYNC_PERIOD: Duration = Duration::from_millis(250);
907
908/// Sync the log to the device, and drop the segments every signal has published
909/// past.
910///
911/// Truncation is not on the sync period. It costs three `readdir`s of the block
912/// tree — [`block::wal_watermarks`] is the whole manifest, re-derived — and what
913/// it can reclaim is whole segments, which only become removable once every
914/// signal has published past them. At the rate a demo or a quiet service
915/// produces, that is minutes apart and four sweeps a second would be hundreds of
916/// scans finding nothing; at section 11's measured 190.6 MiB/s a 64 MiB segment
917/// fills in a third of a second, and a minute of them is ~180 files that one `readdir`
918/// retires as cheaply as it retires one. The period is set by how much disk a
919/// minute of unreclaimed log is worth, which is the same answer at both ends.
920async fn wal_maintenance(cfg: Arc<Config>) {
921    let Some(wal) = cfg.wal.clone() else { return };
922    let mut tick = tokio::time::interval(WAL_SYNC_PERIOD);
923    tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
924    let mut ticks: u64 = 0;
925    loop {
926        tick.tick().await;
927        ticks += 1;
928        // `u64::is_multiple_of` reads better but is stable since 1.87, and the
929        // workspace MSRV is 1.85.
930        wal_sweep(wal.clone(), cfg.data_dir.clone(), ticks % 240 == 0).await;
931    }
932}
933
934/// One pass of the above. Separate from the loop so a test can drive both kinds
935/// of tick without waiting out the sixty seconds of real time between them.
936async fn wal_sweep(wal: Arc<Wal>, dir: PathBuf, truncating: bool) {
937    // The whole sweep is one blocking hop: `sync` is `F_FULLFSYNC` and truncate
938    // is `readdir` plus `unlink`, and neither belongs on a runtime thread that
939    // has three flushers' worth of acks to hand out.
940    let done = tokio::task::spawn_blocking(move || {
941        wal.sync()?;
942        if !truncating {
943            return Ok(0);
944        }
945        // The minimum across signals, not each signal's own: one segment holds
946        // frames for all three, so it can only go once the last of them has
947        // claimed everything in it.
948        let covered = block::wal_watermarks(&dir)?.into_iter().min().unwrap_or(0);
949        wal.truncate(covered)
950    })
951    .await;
952    match done {
953        Ok(Ok(0)) => {}
954        Ok(Ok(n)) => tracing::info!(segments = n, "write-ahead log segments removed"),
955        // Warn and keep going. A log that cannot sync is still absorbing appends
956        // and still replayable after anything short of power loss, so refusing
957        // to ingest over it would trade a narrowed durability guarantee for a
958        // certain outage.
959        Ok(Err(e)) => tracing::warn!(error = %e, "write-ahead log maintenance failed"),
960        Err(e) => tracing::warn!(error = %e, "write-ahead log maintenance panicked"),
961    }
962}
963
964async fn flusher<B: SignalBuilder>(
965    mut rx: mpsc::Receiver<Job<B::Request>>,
966    mut asks: mpsc::Receiver<oneshot::Sender<Option<Arc<Open>>>>,
967    cfg: Arc<Config>,
968    open_slot: Shard,
969    shard: usize,
970    shards: usize,
971    resume: u64,
972) {
973    let rejects = rejects_for(B::SIGNAL);
974    // Shard k takes `resume + k`, `resume + k + shards`, and so on, so the
975    // shards of a signal partition the sequence space with no allocator and no
976    // agreement — a sequence only has to be unique, and the residues mod
977    // `shards` are distinct. It stays unique across a restart that runs a
978    // different number of shards, because the next `resume` is above every
979    // stride. That is the whole cost of a per-shard block sequence: the block
980    // directory is the manifest and a sequence is a filename, so nothing else
981    // has to know.
982    let mut seq = resume.saturating_add(shard as u64);
983    let signal = wal::Signal::named(B::SIGNAL).expect("every signal has a log discriminant");
984
985    let mut builder = B::default();
986    let mut waiters: Vec<oneshot::Sender<Result<(), Rejected>>> = Vec::new();
987    let mut batch = Vec::with_capacity(64);
988    // Jobs that did not fit the open block. They go into the next one, so a full
989    // dictionary costs a slightly small block and never costs a caller its data.
990    let mut carry: Vec<Job<B::Request>> = Vec::new();
991    let mut deadline = Instant::now() + cfg.max_block_age;
992    let mut open = true;
993    // The log sequences this shard has finished with since the last publish,
994    // ascending, or empty when there is no log. "Finished with" and not
995    // "stored": an empty export and a permanently refused one both leave
996    // nothing to recover, so replaying them forever would only keep the log
997    // from truncating. A carried job is deliberately absent — it has not landed
998    // anywhere yet, and claiming it here is how the watermark would lie.
999    //
1000    // A list rather than the running maximum it used to be, because with more
1001    // than one shard per signal the maximum is no longer the watermark: see
1002    // `Wal::watermark_for`, which needs to know exactly which sequences this
1003    // block is retiring in order to answer what the *others* still hold.
1004    let mut wal_seqs: Vec<u64> = Vec::new();
1005    // Readers waiting to be told what is in the block, and the builder size the
1006    // last answer was taken at. Answered only with an empty `rx` — see
1007    // [`OpenSlot::fresh`] — so they survive as many loop turns as the backlog
1008    // takes.
1009    let mut asked: Vec<oneshot::Sender<Option<Arc<Open>>>> = Vec::new();
1010    let mut snapped: Option<usize> = None;
1011
1012    // Carry outlives the channel: a request deferred by the last block still has
1013    // to land somewhere before the task exits.
1014    while open || !carry.is_empty() {
1015        // Before the wait, not after the work: a turn that seals parks again
1016        // immediately, and a reader answered only on the next arrival would
1017        // wait for someone else's export.
1018        answer::<B>(
1019            &builder,
1020            &mut asked,
1021            &mut snapped,
1022            !carry.is_empty() || !rx.is_empty(),
1023            &open_slot,
1024            cfg.node,
1025            seq,
1026        );
1027        let mut aged = false;
1028        // Skip the wait while there is carry: those jobs are already accepted and
1029        // unacknowledged, so holding them behind an idle receiver would add a
1030        // whole block age to their latency.
1031        if carry.is_empty() {
1032            tokio::select! {
1033                n = rx.recv_many(&mut batch, 64) => {
1034                    if n == 0 {
1035                        open = false;
1036                    }
1037                }
1038                // A reader wanting the open block. Closing this channel is not a
1039                // shutdown signal — the `Ingest` handles are — so a `None` here
1040                // only means nobody will ever ask again.
1041                who = asks.recv() => {
1042                    if let Some(who) = who {
1043                        asked.push(who);
1044                    }
1045                }
1046                _ = sleep_until(deadline) => aged = true,
1047            }
1048        }
1049        // Drained in the same turn as the jobs, so a reader that arrives with a
1050        // backlog behind it is answered once, after the backlog.
1051        while let Ok(who) = asks.try_recv() {
1052            asked.push(who);
1053        }
1054
1055        let mut jobs = std::mem::take(&mut carry);
1056        jobs.append(&mut batch);
1057        let mut dict_full = false;
1058        for job in jobs {
1059            // On an empty block the headroom hint is deliberately not consulted.
1060            //
1061            // The hint assumes every attribute in the request introduces a new
1062            // dictionary key, because counting the distinct ones would mean
1063            // hashing the whole request on the hot path to answer a question
1064            // that is almost always "yes, plenty of room". That estimate is the
1065            // right one when it decides *whether to seal first* — being wrong
1066            // costs a slightly small block. It is the wrong one when the block
1067            // is already empty, because then it is not choosing between two
1068            // blocks, it is rejecting the export outright: a single batch of
1069            // ~13k records at five attributes each exceeds 65536 attribute rows
1070            // and used to be NACKed permanently, retry included, for data whose
1071            // real key cardinality is a few dozen.
1072            //
1073            // Sealing cannot help a block with nothing in it, so the only honest
1074            // test left is the append itself.
1075            let empty = builder.is_empty();
1076            // Once one job has been deferred, every job after it must be too, or
1077            // the block would acknowledge exports out of arrival order.
1078            if !empty && (dict_full || !builder.has_headroom_for(&job.req)) {
1079                dict_full = true;
1080                carry.push(job);
1081                continue;
1082            }
1083            // The first job of a block starts its age clock, so acknowledgement
1084            // latency is bounded from the moment data arrived rather than from
1085            // the last flush.
1086            if waiters.is_empty() {
1087                deadline = Instant::now() + cfg.max_block_age;
1088            }
1089            if let Some(seq) = job.wal_seq {
1090                wal_seqs.push(seq);
1091            }
1092            match builder.append_request(&job.req) {
1093                // An export carrying no records is legal — the Collector emits
1094                // one whenever a batch empties out — and there is nothing in it
1095                // to make durable. Parking its caller behind a block that will
1096                // never be sealed, because nothing was added to seal, strands
1097                // that caller for as long as it is willing to wait.
1098                Ok(0) => {
1099                    let _ = job.ack.send(Ok(()));
1100                }
1101                Ok(_) => {
1102                    // The reported age tracks unacknowledged rows, not the
1103                    // deadline: an export carrying no records resets the timer
1104                    // above without leaving anything open, and an "open block"
1105                    // that is never sealed because there is nothing in it is
1106                    // the exact false alarm this number would be read as.
1107                    if waiters.is_empty() {
1108                        rejects.set_open_since(shard, now_secs());
1109                    }
1110                    waiters.push(job.ack);
1111                }
1112                Err(e) => {
1113                    // The append can fail part-way through, having already
1114                    // written some of the request's rows. The client will retry
1115                    // the whole export, so publishing those rows would
1116                    // guarantee duplicates. Discarding the builder is only safe
1117                    // — and only necessary — when this job started on an empty
1118                    // block, which is exactly the case that skipped the hint
1119                    // above; anything else passed a conservative check and
1120                    // cannot overflow.
1121                    if empty {
1122                        let _ = builder.finish();
1123                    }
1124                    // The only permanent refusal in the pipeline: this request
1125                    // did not fit a block with nothing in it, so no retry of it
1126                    // ever will.
1127                    //
1128                    // Loud, because it is the one refusal that destroys data.
1129                    // Everything else in this file is answered `Unavailable`
1130                    // and comes back on the next attempt; this one tells the
1131                    // exporter not to try, and the exporter obeys. Rate-limited
1132                    // like the shed warning — a sender in this state is in it
1133                    // for every export it has — and carrying the running total,
1134                    // because one line an incident later is not a quantity.
1135                    let refused = rejects.refused.fetch_add(1, Relaxed) + 1;
1136                    if once_a_second(&rejects.refuse_warned) {
1137                        tracing::error!(
1138                            signal = B::SIGNAL,
1139                            error = %e,
1140                            refused,
1141                            "export permanently refused; its records are gone. The sender \
1142                             is told not to retry, so nothing will bring them back — the \
1143                             request does not fit an empty block, which means splitting it \
1144                             at the sender is the only fix"
1145                        );
1146                    }
1147                    let _ = job.ack.send(Err(Rejected::Failed(e.to_string())));
1148                }
1149            }
1150        }
1151
1152        let full = dict_full || builder.approx_bytes() >= cfg.target_block_bytes;
1153        if builder.is_empty() || !(full || (aged && !waiters.is_empty()) || !open) {
1154            // Push the idle timer out so a stale deadline does not spin the loop.
1155            if waiters.is_empty() {
1156                deadline = Instant::now() + cfg.max_block_age;
1157                rejects.set_open_since(shard, 0);
1158            }
1159            continue;
1160        }
1161
1162        rejects.set_open_since(shard, 0);
1163        let sealed = match builder.finish() {
1164            Ok(s) => s,
1165            Err(e) => {
1166                let msg = e.to_string();
1167                rejects.mark_stalled(shard);
1168                for w in waiters.drain(..) {
1169                    // Whose export broke the encoder is not knowable from here,
1170                    // so nobody is blamed permanently: everyone is told to send
1171                    // it again.
1172                    let _ = w.send(Err(Rejected::Unavailable(msg.clone())));
1173                }
1174                // `finish` leaves a fresh builder behind even when it fails, so
1175                // there is nothing to repair here — see `SignalBuilder::finish`.
1176                //
1177                // The watermark goes with it. Nothing claimed these sequences,
1178                // so they stay unpublished, keep every sibling shard's
1179                // watermark behind them, and come back on the next boot — which
1180                // is the only reason the callers above could be told to retry
1181                // without that being a lie about where their data went.
1182                wal_seqs.clear();
1183                // Those rows are gone; a snapshot still advertising them would
1184                // be the read path promising data no restart can produce.
1185                open_slot.put(None);
1186                tracing::error!(signal = B::SIGNAL, error = %msg, "block discarded");
1187                continue;
1188            }
1189        };
1190
1191        let dir = cfg.data_dir.clone();
1192        let node = cfg.node;
1193        let this_seq = seq;
1194        seq += shards as u64;
1195        let block_seqs = std::mem::take(&mut wal_seqs);
1196        // Asked before the publish, because the answer is part of the directory
1197        // name, and answered against what every shard of this signal is still
1198        // holding rather than against this block alone. The sequences are not
1199        // retired until the rename lands.
1200        let block_wal_hi = match &cfg.wal {
1201            Some(w) => w.watermark_for(signal, &block_seqs),
1202            None => 0,
1203        };
1204        let rows = sealed.num_rows;
1205        let result = tokio::task::spawn_blocking(move || {
1206            // The size is measured in the same blocking hop as the write, off
1207            // the runtime: it is a handful of `stat`s against pages the publish
1208            // just touched, and it is the only exact answer to "how much disk
1209            // did this node write" that does not mean walking the whole tree.
1210            block::publish(&dir, B::SIGNAL, node, this_seq, block_wal_hi, &sealed)
1211                .map(|b| (dir_bytes(&b.dir), b.dir))
1212        })
1213        .await;
1214
1215        // Held across the publish rather than dropped at `finish`, so the rows
1216        // stay visible while the rename is in flight; the read path drops the
1217        // snapshot itself the instant a block with the same `(node, seq)`
1218        // appears on disk, so the overlap shows nothing twice.
1219        open_slot.put(None);
1220        snapped = None;
1221
1222        let outcome = match result {
1223            Ok(Ok((bytes, path))) => {
1224                if let Some(w) = &cfg.wal {
1225                    w.published(signal, &block_seqs);
1226                }
1227                rejects.published.fetch_add(1, Relaxed);
1228                rejects.rows.fetch_add(rows as u64, Relaxed);
1229                rejects.bytes.fetch_add(bytes, Relaxed);
1230                rejects.clear_stalled(shard);
1231                tracing::info!(signal = B::SIGNAL, rows, bytes, seq = this_seq, path = %path.display(), "block published");
1232                Ok(())
1233            }
1234            // Logged here and not only counted: a disk that filled up at 02:00
1235            // is the one fact that explains every NACK the senders are about to
1236            // report, and it is invisible from their side.
1237            Ok(Err(e)) => {
1238                rejects.mark_stalled(shard);
1239                tracing::error!(signal = B::SIGNAL, seq = this_seq, error = %e, "block not published");
1240                Err(e.to_string())
1241            }
1242            Err(e) => {
1243                rejects.mark_stalled(shard);
1244                Err(format!("flush task panicked: {e}"))
1245            }
1246        };
1247        for w in waiters.drain(..) {
1248            // Every failure here is the block's, not any one caller's, so they
1249            // all get a retryable answer.
1250            let _ = w.send(outcome.clone().map_err(Rejected::Unavailable));
1251        }
1252        deadline = Instant::now() + cfg.max_block_age;
1253    }
1254}
1255
1256/// Answer every reader waiting on the open block, if there is nothing left
1257/// queued ahead of them (section 4).
1258///
1259/// `pending` is the correctness condition, not an optimisation: a reader is
1260/// promised everything acknowledged before it asked, and an acknowledged export
1261/// is in the channel or in `carry` until the flusher appends it. Answering with
1262/// either non-empty would be answering early. Nothing is lost by waiting —
1263/// non-empty means the loop is about to turn again anyway.
1264///
1265/// The snapshot itself is best-effort. One that fails to build is a query that
1266/// misses the newest rows for a moment; failing the flush over it would turn a
1267/// read-path nicety into an ingest outage, and the same error is about to be
1268/// reported properly by the real seal.
1269fn answer<B: SignalBuilder>(
1270    builder: &B,
1271    asked: &mut Vec<oneshot::Sender<Option<Arc<Open>>>>,
1272    snapped: &mut Option<usize>,
1273    pending: bool,
1274    slot: &Shard,
1275    node: u32,
1276    seq: u64,
1277) {
1278    if asked.is_empty() || pending {
1279        return;
1280    }
1281    // Re-copying a block nothing has been appended to since the last answer
1282    // would be pure memcpy, and a live tail asks several times a second for
1283    // exactly that. `approx_bytes` and not a row count because it is the number
1284    // the builder already keeps; appends only ever grow it.
1285    let bytes = builder.approx_bytes();
1286    if builder.is_empty() {
1287        slot.put(None);
1288        *snapped = None;
1289    } else if *snapped != Some(bytes) {
1290        *snapped = Some(bytes);
1291        match builder.snapshot() {
1292            Ok(sealed) => slot.put(Some(Arc::new(Open { node, seq, sealed }))),
1293            Err(e) => {
1294                slot.put(None);
1295                tracing::debug!(signal = B::SIGNAL, error = %e, "open block not snapshotted");
1296            }
1297        }
1298    }
1299    let cur = slot.get();
1300    for who in asked.drain(..) {
1301        let _ = who.send(cur.clone());
1302    }
1303}
1304
1305/// The size of one block, as the filesystem sees it. Best-effort: a block being
1306/// unlinked by another replica mid-walk is worth a slightly low counter, not an
1307/// error path on the flush.
1308fn dir_bytes(dir: &Path) -> u64 {
1309    std::fs::read_dir(dir)
1310        .into_iter()
1311        .flatten()
1312        .flatten()
1313        .filter_map(|e| e.metadata().ok())
1314        .map(|m| m.len())
1315        .sum()
1316}
1317
1318/// Free space below which retention stops waiting for the TTL.
1319///
1320/// This is not a setting and there is deliberately no key for it. Retention as a
1321/// TTL alone assumes the ingest rate the window was sized for; the first spike,
1322/// chatty service or debug level left on fills the volume before the clock
1323/// expires anything, every `publish` then fails ENOSPC, every export is NACKed,
1324/// and nothing in the process ever undoes it — the only thing that deletes
1325/// blocks is a clock that has not advanced far enough. The number the engine
1326/// needs is not "how full may I get", it is read off the volume every sweep; the
1327/// only constant here is the margin, and 10% is enough headroom for the blocks
1328/// in flight (three signals' `target_block_bytes` plus their staging copies) on
1329/// any volume big enough to hold a day of telemetry, while still leaving the
1330/// sweep room to act before `publish` starts failing.
1331const MIN_FREE: f64 = 0.10;
1332
1333/// Drop the oldest blocks, across every signal, until the volume is back above
1334/// `min_free`. Returns what was unlinked, oldest first.
1335///
1336/// `min_free` is a parameter only so a test can say "pretend the volume is
1337/// full" without one; the sweep passes [`MIN_FREE`] and nothing else ever will.
1338///
1339/// ponytail: one `statfs` per unlink and one full `scan` per sweep that trips.
1340/// Both are O(blocks) on a path that only runs when the volume is nearly full,
1341/// where the unlink dominates anyway. If a volume ever spends long enough down
1342/// here for that to matter, the fix is to stop after freeing a target fraction
1343/// in one pass rather than re-measuring per block.
1344fn reclaim(dir: &Path, min_free: f64) -> mira_core::error::Result<Vec<PathBuf>> {
1345    let mut dropped = Vec::new();
1346    let mut free = block::free_fraction(dir)?;
1347    if free >= min_free {
1348        return Ok(dropped);
1349    }
1350    // Oldest first across all three signals at once, not one signal at a time:
1351    // the volume is shared, so the block worth losing is the oldest one on it. A
1352    // per-signal sweep would drop an hour-old trace block while a week-old log
1353    // block sat beside it.
1354    let mut blocks = Vec::new();
1355    for s in SIGNALS {
1356        blocks.extend(block::scan(dir, s)?);
1357    }
1358    blocks.sort_by_key(|b| (b.max_ts, b.seq));
1359    for b in blocks {
1360        if free >= min_free {
1361            break;
1362        }
1363        // The unlink `block::expire` does, read the same way: another replica
1364        // sharing this volume getting there first is not a conflict, and a
1365        // block this process cannot remove says nothing about the next one.
1366        match std::fs::remove_dir_all(&b.dir) {
1367            Ok(()) => {
1368                // WARN and one line per block. Deleting a user's telemetry
1369                // before they asked is only defensible if it is impossible to
1370                // miss afterwards, and "which blocks" is the question the
1371                // person who finds the gap will ask.
1372                tracing::warn!(
1373                    block = %b.dir.display(),
1374                    free = format!("{free:.3}"),
1375                    "volume is nearly full; dropped a block that had not reached its retention"
1376                );
1377                dropped.push(b.dir);
1378            }
1379            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
1380            Err(e) => tracing::warn!(
1381                block = %b.dir.display(),
1382                error = %e,
1383                "cannot drop block to reclaim space; skipping it",
1384            ),
1385        }
1386        // Re-read rather than subtracting the block's size: compaction, another
1387        // replica and everything else on this volume are all moving it too.
1388        free = block::free_fraction(dir)?;
1389    }
1390    if !dropped.is_empty() {
1391        // One line for the whole burst above it, carrying the thing the operator
1392        // has to change: the per-block warnings say what went, this says why it
1393        // will keep going.
1394        tracing::warn!(
1395            blocks = dropped.len(),
1396            free = format!("{free:.3}"),
1397            "dropped blocks ahead of their retention to keep the volume writable; \
1398             retention is longer than this disk can hold at the current ingest rate"
1399        );
1400    }
1401    Ok(dropped)
1402}
1403
1404async fn retention(cfg: Arc<Config>) {
1405    let mut tick = tokio::time::interval(Duration::from_secs(60));
1406    loop {
1407        tick.tick().await;
1408        let dir = cfg.data_dir.clone();
1409        let ttl = cfg.retention;
1410        let node = cfg.node;
1411        let swept = tokio::task::spawn_blocking(move || {
1412            // Wall clock is only used to place the horizons; block timestamps
1413            // themselves come from the data, never from this clock.
1414            let now = std::time::SystemTime::now()
1415                .duration_since(std::time::UNIX_EPOCH)
1416                .unwrap_or_default()
1417                .as_nanos() as i64;
1418            // Saturating, and not `now - ttl.as_nanos() as i64`: a retention
1419            // longer than ~292 years does not fit an `i64` of nanoseconds, and
1420            // `as` wraps it negative — which puts the cutoff in the *future*
1421            // and expires the whole volume on the first sweep. `retention:
1422            // 999999d` is how an operator says "keep it forever", and it used
1423            // to mean the exact opposite.
1424            let cutoff = now.saturating_sub(i64::try_from(ttl.as_nanos()).unwrap_or(i64::MAX));
1425            // One signal failing must not skip the others; a full disk is
1426            // exactly when the remaining sweeps matter most.
1427            let results = SIGNALS.map(|s| {
1428                let dropped = block::expire(&dir, s, cutoff);
1429                // Expire first: compressing a block this sweep is about to
1430                // delete is pure wasted bandwidth.
1431                let cold = block::compact(&dir, s, node, now - block::COLD_AFTER_NS);
1432                (s, dropped, cold)
1433            });
1434            // Last, and only then: the TTL is the policy, and free space is the
1435            // floor under it. A sweep that expired enough by the clock has
1436            // nothing to do here and pays one `statfs` to find that out.
1437            (results, reclaim(&dir, MIN_FREE))
1438        })
1439        .await;
1440        match swept {
1441            Ok((results, reclaimed)) => {
1442                // `reclaim` has already logged every block it dropped and why.
1443                // Only the case where it could not even ask the volume is left,
1444                // and it is a warning rather than a stop: a sweep that cannot
1445                // read free space still expired by TTL above.
1446                if let Err(e) = reclaimed {
1447                    tracing::warn!(error = %e, "cannot read free space; retention is TTL-only this sweep");
1448                }
1449                for (signal, dropped, cold) in results {
1450                    match dropped {
1451                        Ok(0) => {}
1452                        Ok(n) => tracing::info!(signal, blocks = n, "retention dropped blocks"),
1453                        Err(e) => tracing::warn!(signal, error = %e, "retention failed"),
1454                    }
1455                    match cold {
1456                        Ok(0) => {}
1457                        Ok(n) => tracing::info!(signal, blocks = n, "compacted blocks to zstd"),
1458                        Err(e) => tracing::warn!(signal, error = %e, "compaction failed"),
1459                    }
1460                }
1461            }
1462            Err(e) => tracing::warn!(error = %e, "retention task panicked"),
1463        }
1464    }
1465}
1466
1467#[cfg(test)]
1468mod tests {
1469    use super::*;
1470    use mira_core::logs::LogsBuilder;
1471    use mira_proto::collector::logs::v1::ExportLogsServiceRequest;
1472    use mira_proto::common::v1::{AnyValue, KeyValue, any_value};
1473    use mira_proto::logs::v1::{LogRecord, ResourceLogs, ScopeLogs};
1474
1475    fn cfg(name: &str) -> (Arc<Config>, PathBuf) {
1476        let dir = std::env::temp_dir().join(format!("mira-pipe-{name}-{}", std::process::id()));
1477        let _ = std::fs::remove_dir_all(&dir);
1478        std::fs::create_dir_all(&dir).unwrap();
1479        (
1480            Arc::new(Config {
1481                data_dir: dir.clone(),
1482                // Short enough that a test can wait out the age timer, long
1483                // enough that two submits still land in one `recv_many`.
1484                max_block_age: Duration::from_millis(50),
1485                ..Default::default()
1486            }),
1487            dir,
1488        )
1489    }
1490
1491    fn blocks(dir: &std::path::Path) -> usize {
1492        block::scan(dir, "logs").map_or(0, |b| b.len())
1493    }
1494
1495    /// Every published logs block's sequence, ascending.
1496    fn seqs(dir: &std::path::Path) -> Vec<u64> {
1497        let mut v: Vec<u64> = block::scan(dir, "logs")
1498            .unwrap()
1499            .iter()
1500            .map(|b| b.seq)
1501            .collect();
1502        v.sort_unstable();
1503        v
1504    }
1505
1506    /// One record carrying `n` attributes with distinct keys. The key dictionary
1507    /// is the thing with a ceiling, and distinct keys are the only way to reach
1508    /// it — a million records sharing one key never do.
1509    fn wide(n: usize) -> ExportLogsServiceRequest {
1510        ExportLogsServiceRequest {
1511            resource_logs: vec![ResourceLogs {
1512                scope_logs: vec![ScopeLogs {
1513                    log_records: vec![LogRecord {
1514                        time_unix_nano: 1_000,
1515                        attributes: (0..n)
1516                            .map(|i| KeyValue {
1517                                key: format!("k{i}"),
1518                                value: Some(AnyValue {
1519                                    value: Some(any_value::Value::StringValue("v".into())),
1520                                }),
1521                            })
1522                            .collect(),
1523                        ..Default::default()
1524                    }],
1525                    ..Default::default()
1526                }],
1527                ..Default::default()
1528            }],
1529        }
1530    }
1531
1532    /// A request that does not fit the open block is deferred into the next one,
1533    /// never refused and never reordered.
1534    ///
1535    /// Both submits are acknowledged, so neither caller loses its data, and both
1536    /// blocks land — which is the difference between "the dictionary is full" and
1537    /// "your export is rejected". The second is what a client sees as a permanent
1538    /// failure for data whose real cardinality was fine.
1539    #[tokio::test]
1540    async fn a_request_that_does_not_fit_lands_in_the_next_block() {
1541        let (c, dir) = cfg("carry");
1542        let (tx, _open, h) = spawn::<LogsBuilder>(&c);
1543        // 40k distinct keys each: the first fits an empty block, the second
1544        // cannot join it, and 80k would overflow the u16 dictionary.
1545        let (a, b) = tokio::join!(tx.submit(wide(40_000)), tx.submit(wide(40_000)));
1546        assert!(a.is_ok() && b.is_ok(), "both callers must be acknowledged");
1547        drop(tx);
1548        h.await.unwrap();
1549        assert_eq!(blocks(&dir), 2, "the deferred request got its own block");
1550        let _ = std::fs::remove_dir_all(&dir);
1551    }
1552
1553    /// The case the headroom hint deliberately cannot answer: one request that
1554    /// is too wide for *any* block. Sealing first cannot help, so the append is
1555    /// attempted and its failure is the caller's answer — after which the
1556    /// builder has to be usable again, or the node rejects everything until it
1557    /// is restarted.
1558    #[tokio::test]
1559    async fn an_impossible_request_fails_only_itself() {
1560        let (c, dir) = cfg("toowide");
1561        let (tx, _open, h) = spawn::<LogsBuilder>(&c);
1562        let before = tx.rejects.failed.load(Relaxed);
1563        let destroyed = tx.rejects.refused.load(Relaxed);
1564        // `Failed`, not `Unavailable`: this one is permanent, and the receiver
1565        // turns the two into statuses an exporter treats differently.
1566        let refusal = tx.submit(wide(70_000)).await;
1567        assert!(
1568            matches!(&refusal, Err(Rejected::Failed(e))
1569                if e.contains("65535") || e.contains("dictionary")),
1570            "70k distinct keys cannot fit a u16 dictionary, and the refusal has \
1571             to be the permanent one that names why"
1572        );
1573        // `>`, not `== before + 1`: `REJECTS` is process-wide and the other
1574        // tests in this file refuse logs exports of their own, in parallel.
1575        assert!(tx.rejects.failed.load(Relaxed) > before);
1576        // Counted apart from `failed`, because this is the only refusal in the
1577        // pipeline that destroys data: the sender is told not to retry and it
1578        // will not. A number nobody can read is a deletion nobody can audit.
1579        assert!(tx.rejects.refused.load(Relaxed) > destroyed);
1580        // The next export proves the builder was replaced, not poisoned.
1581        tx.submit(crate::e2e::logs_export("checkout", 2_000, 4))
1582            .await
1583            .unwrap_or_else(|_| panic!("the pipeline is still open for business"));
1584        drop(tx);
1585        h.await.unwrap();
1586        assert_eq!(blocks(&dir), 1, "only the good export was published");
1587        let _ = std::fs::remove_dir_all(&dir);
1588    }
1589
1590    /// An export with no records is legal and the Collector sends them. There is
1591    /// nothing in it to make durable, so parking its caller behind a block that
1592    /// will never be sealed strands them for as long as they are willing to wait.
1593    #[tokio::test]
1594    async fn an_empty_export_is_acknowledged_without_a_block() {
1595        let (c, dir) = cfg("empty");
1596        let (tx, _open, h) = spawn::<LogsBuilder>(&c);
1597        // No timeout needed: if this ever blocks, it blocks forever, and the
1598        // test harness reports the hang for what it is.
1599        tx.submit(ExportLogsServiceRequest::default())
1600            .await
1601            .unwrap_or_else(|_| panic!("an empty export is not an error"));
1602        drop(tx);
1603        h.await.unwrap();
1604        assert_eq!(blocks(&dir), 0, "nothing to seal, so nothing was sealed");
1605        let _ = std::fs::remove_dir_all(&dir);
1606    }
1607
1608    /// The whole point of the log, in one assertion: with one configured, the
1609    /// acknowledgement lands while the block is still open.
1610    ///
1611    /// `max_block_age` is a full second here and the submit is not allowed to
1612    /// take a tenth of it. Without the log that submit is the block age by
1613    /// definition — it is section 11's 2,647 ms p99 — so a regression that quietly
1614    /// puts the ack back behind the publish fails this by a factor of ten
1615    /// rather than by a margin that could be scheduler noise.
1616    #[tokio::test]
1617    async fn a_logged_export_is_acknowledged_before_its_block_is_sealed() {
1618        let dir = std::env::temp_dir().join(format!("mira-pipe-wal-{}", std::process::id()));
1619        let _ = std::fs::remove_dir_all(&dir);
1620        std::fs::create_dir_all(&dir).unwrap();
1621        let node = block::node_id("waltest");
1622        let wal = Arc::new(Wal::open(&dir, node).unwrap());
1623        let c = Arc::new(Config {
1624            data_dir: dir.clone(),
1625            node,
1626            max_block_age: Duration::from_secs(1),
1627            wal: Some(Arc::clone(&wal)),
1628            ..Default::default()
1629        });
1630        let (tx, _open, h) = spawn::<LogsBuilder>(&c);
1631
1632        let started = std::time::Instant::now();
1633        tx.submit(crate::e2e::logs_export("checkout", 2_000, 4))
1634            .await
1635            .unwrap_or_else(|_| panic!("the log accepted it"));
1636        let acked = started.elapsed();
1637        assert!(
1638            acked < Duration::from_millis(100),
1639            "acknowledged in {acked:?}, which is the block age, not the log"
1640        );
1641        assert_eq!(blocks(&dir), 0, "the ack did not wait for a block");
1642        assert_eq!(wal.next_seq(), 1, "the export is a frame");
1643
1644        drop(tx);
1645        h.await.unwrap();
1646
1647        // The block claims the frame, so the next boot does not replay it. The
1648        // watermark is exclusive and nothing else is pending, so it is the
1649        // log's `next_seq` — 1, for the single frame 0.
1650        let published = block::scan(&dir, "logs").unwrap();
1651        assert_eq!(published.len(), 1);
1652        assert_eq!(published[0].wal_hi, 1);
1653        assert_eq!(block::wal_watermarks(&dir).unwrap(), [1, 0, 0]);
1654        let _ = std::fs::remove_dir_all(&dir);
1655    }
1656
1657    /// A frame that no block claims comes back, under its own sequence, and
1658    /// then *is* claimed — so the boot after that one replays nothing.
1659    ///
1660    /// Convergence is the property, not recovery. Re-appending a replayed frame
1661    /// instead of carrying its sequence would leave the original uncovered and
1662    /// replay it again at every start, for ever, growing the log each time.
1663    #[tokio::test]
1664    async fn a_replayed_frame_is_claimed_by_the_block_that_finally_stores_it() {
1665        let dir = std::env::temp_dir().join(format!("mira-pipe-replay-{}", std::process::id()));
1666        let _ = std::fs::remove_dir_all(&dir);
1667        std::fs::create_dir_all(&dir).unwrap();
1668        let node = block::node_id("replaytest");
1669
1670        // A crash: framed, never sealed. Dropping the log without `sync` is the
1671        // harsher case — the frames are only in the page cache, which is what
1672        // an acknowledgement here promises and all it promises.
1673        let wal = Wal::open(&dir, node).unwrap();
1674        let body = {
1675            use prost::Message as _;
1676            crate::e2e::logs_export("checkout", 2_000, 4).encode_to_vec()
1677        };
1678        wal.append(wal::Signal::Logs, &body).unwrap();
1679        wal.append(wal::Signal::Logs, &body).unwrap();
1680        drop(wal);
1681
1682        let wal = Arc::new(Wal::open(&dir, node).unwrap());
1683        let c = Arc::new(Config {
1684            data_dir: dir.clone(),
1685            node,
1686            max_block_age: Duration::from_millis(50),
1687            wal: Some(Arc::clone(&wal)),
1688            ..Default::default()
1689        });
1690        let (tx, _open, h) = spawn::<LogsBuilder>(&c);
1691        let replayed = {
1692            let tx = tx.clone();
1693            let dir = dir.clone();
1694            tokio::task::spawn_blocking(move || {
1695                Wal::replay(&dir, node, [0, 0, 0], |_, seq, body| {
1696                    assert!(tx.replay(body, seq).is_ok(), "the flusher took it");
1697                    Ok(())
1698                })
1699                .unwrap()
1700            })
1701            .await
1702            .unwrap()
1703        };
1704        assert_eq!(replayed.replayed, 2);
1705
1706        drop(tx);
1707        h.await.unwrap();
1708        assert_eq!(block::wal_watermarks(&dir).unwrap(), [2, 0, 0]);
1709
1710        // The second boot: every frame is behind the watermark, so nothing is
1711        // handed back and the log can be truncated.
1712        let again = Wal::replay(
1713            &dir,
1714            node,
1715            block::wal_watermarks(&dir).unwrap(),
1716            |_, _, _| unreachable!("a frame a block already claims must never be replayed again"),
1717        )
1718        .unwrap();
1719        assert_eq!((again.replayed, again.skipped), (0, 2));
1720        let _ = std::fs::remove_dir_all(&dir);
1721    }
1722
1723    /// A volume that stops accepting blocks NACKs retryably. Nobody is told
1724    /// their export is stored, and the signal starts counting as stalled —
1725    /// which is what `/health` reads, and what takes this node out of a load
1726    /// balancer instead of leaving it silently eating telemetry.
1727    #[tokio::test]
1728    async fn a_block_that_cannot_be_published_is_a_retryable_answer() {
1729        let (c, dir) = cfg("unpublishable");
1730        let (tx, _open, h) = spawn::<LogsBuilder>(&c);
1731        // A file where the staging directory belongs: every publish fails at its
1732        // first `create_dir_all` and none of them can reach the block tree. That
1733        // is the shape of a full or detached volume without needing one, and it
1734        // is the one the flusher cannot see at startup — a signal directory it
1735        // cannot scan stops it before it takes a single export.
1736        std::fs::write(dir.join(".tmp"), b"not a directory").unwrap();
1737
1738        let answer = tx.submit(wide(1)).await;
1739        assert!(
1740            matches!(&answer, Err(Rejected::Unavailable(why)) if !why.is_empty()),
1741            "a publish that failed has to be answered retryably, and with a reason: \
1742             `Failed` would have the exporter drop the batch, and an empty string \
1743             leaves the operator reading the sender's log for a disk fault"
1744        );
1745        // Not asserted here: the stall clock this also starts. `REJECTS` is
1746        // process-wide and the other tests in this file publish into it, so the
1747        // threshold is pinned on a local `Rejects` instead — see
1748        // `a_stall_is_only_reportable_once_it_has_outlasted_the_recovery`.
1749        drop(tx);
1750        h.await.unwrap();
1751        let _ = std::fs::remove_dir_all(&dir);
1752    }
1753
1754    /// An export too large to frame is the one log failure the sender can fix,
1755    /// so it is the one that comes back as permanent. Retrying it would burn the
1756    /// link forever: the second attempt is the same bytes and the same refusal.
1757    #[tokio::test]
1758    async fn an_export_too_large_for_a_frame_is_refused_permanently() {
1759        let dir = std::env::temp_dir().join(format!("mira-pipe-huge-{}", std::process::id()));
1760        let _ = std::fs::remove_dir_all(&dir);
1761        std::fs::create_dir_all(&dir).unwrap();
1762        let node = block::node_id("hugetest");
1763        let c = Arc::new(Config {
1764            data_dir: dir.clone(),
1765            node,
1766            wal: Some(Arc::new(Wal::open(&dir, node).unwrap())),
1767            ..Default::default()
1768        });
1769        let (tx, _open, h) = spawn::<LogsBuilder>(&c);
1770
1771        let mut req = wide(1);
1772        req.resource_logs[0].scope_logs[0].log_records[0].body = Some(AnyValue {
1773            value: Some(any_value::Value::StringValue("x".repeat(64 << 20))),
1774        });
1775        let answer = tx.submit(req).await;
1776        assert!(
1777            matches!(&answer, Err(Rejected::Failed(why)) if why.contains("frame")),
1778            "an export that can never be framed has to be refused permanently and \
1779             named as a framing limit; retrying it burns the link on the same bytes"
1780        );
1781
1782        drop(tx);
1783        h.await.unwrap();
1784        assert_eq!(blocks(&dir), 0, "nothing was framed, so nothing was stored");
1785        let _ = std::fs::remove_dir_all(&dir);
1786    }
1787
1788    /// The sweep is two jobs on one blocking hop, and only every 240th tick
1789    /// does the second: a sync every quarter second, a truncate every minute.
1790    ///
1791    /// The open segment is never dropped, whichever tick it is, so what the
1792    /// first half pins is the watermark arithmetic being reached at all, and a
1793    /// truncate half that fails leaving the sync half done rather than taking
1794    /// the maintenance task down with it.
1795    ///
1796    /// Then the case where the slow tick does have something to remove: the
1797    /// slow tick is the only thing that ever shrinks the log, and a sweep that
1798    /// removed a segment on the fast tick — or left a dead one on the slow one
1799    /// — is the difference between a log that stays bounded and one that drops
1800    /// frames no block has claimed yet.
1801    #[tokio::test]
1802    async fn a_wal_sweep_syncs_every_tick_and_only_truncates_on_the_slow_one() {
1803        let dir = std::env::temp_dir().join(format!("mira-pipe-sweep-{}", std::process::id()));
1804        let _ = std::fs::remove_dir_all(&dir);
1805        std::fs::create_dir_all(&dir).unwrap();
1806        let node = block::node_id("sweeptest");
1807        let wal = Arc::new(Wal::open(&dir, node).unwrap());
1808        wal.append(wal::Signal::Logs, b"a frame").unwrap();
1809
1810        // The common tick: sync, and nothing else looked at.
1811        wal_sweep(Arc::clone(&wal), dir.clone(), false).await;
1812        // The 240th: no block claims that frame, so the covered watermark is 0
1813        // and the segment holding it stays.
1814        wal_sweep(Arc::clone(&wal), dir.clone(), true).await;
1815        assert_eq!(wal.next_seq(), 1, "a sweep renumbers nothing");
1816        assert_eq!(
1817            std::fs::read_dir(dir.join(".wal")).unwrap().count(),
1818            1,
1819            "the open segment is never dropped"
1820        );
1821
1822        // A truncate that cannot read the block tree is a warning, not a stop:
1823        // the sync half already happened and the next append still lands. A
1824        // file where the `logs` directory belongs is the cheapest unreadable
1825        // tree — a *missing* one is legitimately empty, and scans as such.
1826        let bad = dir.join("unreadable");
1827        std::fs::create_dir_all(&bad).unwrap();
1828        std::fs::write(bad.join("logs"), b"not a directory").unwrap();
1829        wal_sweep(Arc::clone(&wal), bad, true).await;
1830        wal.append(wal::Signal::Logs, b"another").unwrap();
1831        assert_eq!(wal.next_seq(), 2);
1832
1833        // The segment a crash between `roll` and the first append leaves: no
1834        // frames, so no watermark can ever cover it, and the empty-segment rule
1835        // is the only thing that will ever get rid of it.
1836        let stale = dir.join(".wal").join(format!("{node:08x}-{:020}.wal", 9));
1837        std::fs::File::create(&stale).unwrap();
1838        wal_sweep(Arc::clone(&wal), dir.clone(), false).await;
1839        assert!(stale.exists(), "a sync is not a truncation");
1840        wal_sweep(Arc::clone(&wal), dir.clone(), true).await;
1841        assert!(!stale.exists(), "the slow tick removed the dead segment");
1842        assert_eq!(
1843            std::fs::read_dir(dir.join(".wal")).unwrap().count(),
1844            1,
1845            "and left the open one, which is still holding two unclaimed frames"
1846        );
1847        let _ = std::fs::remove_dir_all(&dir);
1848    }
1849
1850    /// A queue that stays full past [`ADMIT_WAIT`] sheds, and shedding has to be
1851    /// distinguishable from shutdown: `Busy` is retryable and `Closed` is not,
1852    /// and an exporter that confuses them either drops good data or hammers a
1853    /// draining node.
1854    ///
1855    /// `start_paused`, so the five seconds are five seconds of the test's clock.
1856    /// Tokio only auto-advances once every task is idle, which here is exactly
1857    /// the state the wait is supposed to end in.
1858    #[tokio::test(start_paused = true)]
1859    async fn a_full_queue_sheds_and_a_closed_one_says_so() {
1860        let (tx, rx) = mpsc::channel::<Job<ExportLogsServiceRequest>>(1);
1861        let rejects = &REJECTS[0];
1862        let ingest = Ingest {
1863            tx: [tx].into(),
1864            turn: Arc::default(),
1865            rejects,
1866            wal: None,
1867            signal: wal::Signal::Logs,
1868        };
1869        let req = || ExportLogsServiceRequest::default();
1870        // Relative, not absolute: the counters are process-wide and every other
1871        // test in this binary shares them.
1872        let before = rejects.shed.load(Relaxed);
1873
1874        // Nothing is reading, so the first send fills the channel and the
1875        // second finds no permit. The first never returns; that is the point.
1876        let pending = tokio::spawn({
1877            let i = ingest.clone();
1878            async move { i.submit(req()).await }
1879        });
1880        while rx.capacity() > 0 {
1881            tokio::task::yield_now().await;
1882        }
1883        assert!(matches!(ingest.submit(req()).await, Err(Rejected::Busy)));
1884        // Shedding is counted, because a 503 with no server-side number behind
1885        // it is a fact the operator can only get from the sender's log.
1886        assert_eq!(rejects.shed.load(Relaxed), before + 1);
1887
1888        // The flusher is gone. In flight becomes `Closed` because the ack sender
1889        // dropped with it; new work becomes `Closed` because the channel did.
1890        drop(rx);
1891        assert!(matches!(pending.await.unwrap(), Err(Rejected::Closed)));
1892        assert!(matches!(ingest.submit(req()).await, Err(Rejected::Closed)));
1893    }
1894
1895    /// The other half of that contract, and the one the measurement is about: a
1896    /// queue that is full *now* but drains inside [`ADMIT_WAIT`] admits the
1897    /// export instead of shedding it. Without this the sender re-sends bytes
1898    /// this node has already decoded, which at 96 connections cost 93% of
1899    /// exports and two thirds of the throughput.
1900    #[tokio::test(start_paused = true)]
1901    async fn a_queue_that_drains_inside_the_wait_admits_instead_of_shedding() {
1902        let (tx, mut rx) = mpsc::channel::<Job<ExportLogsServiceRequest>>(1);
1903        // Its own counter, not `REJECTS[0]`: this one asserts that *nothing* was
1904        // shed, and the process-wide slot is being written by whichever other
1905        // test in this binary is running beside it.
1906        let rejects: &'static Rejects = Box::leak(Box::new(Rejects::new("logs")));
1907        let ingest = Ingest {
1908            tx: [tx].into(),
1909            turn: Arc::default(),
1910            rejects,
1911            wal: None,
1912            signal: wal::Signal::Logs,
1913        };
1914        let req = || ExportLogsServiceRequest::default();
1915
1916        // Fill it, and leave the filler parked on its ack so the slot stays
1917        // taken until something reads.
1918        let first = tokio::spawn({
1919            let i = ingest.clone();
1920            async move { i.submit(req()).await }
1921        });
1922        while rx.capacity() > 0 {
1923            tokio::task::yield_now().await;
1924        }
1925
1926        // A second export finds no permit and waits. A reader that comes back
1927        // four seconds later — inside the wait, well past anything `try_reserve`
1928        // would have tolerated — frees the slot, and the waiter takes it.
1929        let waiter = tokio::spawn({
1930            let i = ingest.clone();
1931            async move { i.submit(req()).await }
1932        });
1933        tokio::time::sleep(Duration::from_secs(4)).await;
1934        let job = rx.recv().await.expect("the filler's job");
1935        let _ = job.ack.send(Ok(()));
1936        assert!(matches!(first.await.unwrap(), Ok(())));
1937
1938        // The waiter is now queued rather than shed. Ack it the same way.
1939        let job = rx.recv().await.expect("the waiter's job");
1940        let _ = job.ack.send(Ok(()));
1941        assert!(matches!(waiter.await.unwrap(), Ok(())));
1942        assert_eq!(rejects.shed.load(Relaxed), 0, "nothing was shed");
1943    }
1944
1945    /// A data directory that cannot be scanned stops the flusher at startup
1946    /// rather than at the first flush. Sequence numbers are resumed from what is
1947    /// on disk, so a pipeline that could not read it would reuse a sequence and
1948    /// never publish again — failing loudly here is the cheaper end of that.
1949    #[tokio::test]
1950    async fn an_unreadable_data_directory_stops_the_flusher_at_startup() {
1951        let (c, dir) = cfg("unscannable");
1952        std::fs::write(dir.join("logs"), b"not a directory").unwrap();
1953        let (tx, _open, h) = spawn::<LogsBuilder>(&c);
1954        h.await.unwrap();
1955        assert!(matches!(
1956            tx.submit(ExportLogsServiceRequest::default()).await,
1957            Err(Rejected::Closed)
1958        ));
1959        let _ = std::fs::remove_dir_all(&dir);
1960    }
1961
1962    /// Retention runs on a timer, and `interval` fires its first tick straight
1963    /// away — so a zero TTL expires everything on the first pass, with no clock
1964    /// to advance and no sleep to wait out.
1965    #[tokio::test]
1966    async fn retention_drops_expired_blocks_on_its_first_pass() {
1967        let (c, dir) = cfg("retention");
1968        let (tx, _open, h) = spawn::<LogsBuilder>(&c);
1969        tx.submit(crate::e2e::logs_export("checkout", 1_000, 4))
1970            .await
1971            .unwrap_or_else(|_| panic!("export"));
1972        drop(tx);
1973        h.await.unwrap();
1974        assert_eq!(blocks(&dir), 1);
1975
1976        spawn_retention(Arc::new(Config {
1977            data_dir: dir.clone(),
1978            retention: Duration::ZERO,
1979            ..Default::default()
1980        }));
1981        // The sweep is a `spawn_blocking`, so yielding is not enough to see it.
1982        for _ in 0..200 {
1983            if blocks(&dir) == 0 {
1984                break;
1985            }
1986            tokio::time::sleep(Duration::from_millis(10)).await;
1987        }
1988        assert_eq!(blocks(&dir), 0, "a block older than its TTL is unlinked");
1989        let _ = std::fs::remove_dir_all(&dir);
1990    }
1991
1992    /// A volume that fills faster than the TTL expires is the outage the whole
1993    /// stack exists to explain, and before this it was permanent: every
1994    /// `publish` ENOSPC, every export NACKed, and the only thing that deletes
1995    /// blocks a clock that has not advanced far enough.
1996    ///
1997    /// The order is the contract, not the count. Three blocks are published with
1998    /// their timestamps deliberately out of sequence order, so a sweep that
1999    /// walked the directory as `scan` returns it — or in the order the blocks
2000    /// were written — would drop the newest first and delete the data the
2001    /// incident is being read out of.
2002    #[tokio::test]
2003    async fn a_full_volume_drops_the_oldest_blocks_before_their_ttl() {
2004        let (c, dir) = cfg("space");
2005        let (tx, _open, h) = spawn::<LogsBuilder>(&c);
2006        // Awaited one at a time: `submit` returns only once the block holding it
2007        // is durable, so each of these is a block of its own.
2008        for ts in [3_000_000, 1_000_000, 2_000_000] {
2009            tx.submit(crate::e2e::logs_export("checkout", ts, 4))
2010                .await
2011                .unwrap_or_else(|_| panic!("export"));
2012        }
2013        drop(tx);
2014        h.await.unwrap();
2015        assert_eq!(blocks(&dir), 3);
2016
2017        // Publishing is counted, or `/api/v1/stats` is three zeroes. Relative
2018        // and monotone, because every other test in this binary shares these.
2019        let logs = rejects_for("logs");
2020        assert!(logs.published.load(Relaxed) >= 3, "blocks are counted");
2021        assert!(logs.rows.load(Relaxed) >= 12, "rows are counted");
2022        assert!(logs.bytes.load(Relaxed) > 0, "bytes on disk are counted");
2023
2024        // The TTL is the policy and free space is only the floor under it, so a
2025        // volume with room loses nothing whatever its blocks' ages.
2026        assert!(reclaim(&dir, 0.0).unwrap().is_empty());
2027
2028        // A margin no real volume can satisfy stands in for a full disk: every
2029        // block goes, oldest first, and the returned order is that order.
2030        let mut want = block::scan(&dir, "logs").unwrap();
2031        want.sort_by_key(|b| b.max_ts);
2032        let want: Vec<PathBuf> = want.into_iter().map(|b| b.dir).collect();
2033        assert_eq!(reclaim(&dir, 2.0).unwrap(), want);
2034        assert_eq!(blocks(&dir), 0);
2035        let _ = std::fs::remove_dir_all(&dir);
2036    }
2037
2038    /// Runs `f` with a subscriber attached.
2039    ///
2040    /// Not decoration. A `tracing` field whose value is a call —
2041    /// `%dir.display()`, `format!("{free:.3}")` — is not evaluated at all when
2042    /// nothing is listening, so the code that builds the warnings below only
2043    /// *runs* under this. Thread-local rather than global, so the flushers the
2044    /// other tests in this binary are running stay quiet.
2045    fn listening<T>(f: impl FnOnce() -> T) -> T {
2046        let sub = tracing_subscriber::fmt()
2047            .with_max_level(tracing::Level::TRACE)
2048            .with_test_writer()
2049            .finish();
2050        tracing::subscriber::with_default(sub, f)
2051    }
2052
2053    /// Polls `done` for two seconds. The sweeps below run on a blocking thread,
2054    /// so yielding is not enough to see one land, and a fixed sleep is either a
2055    /// flake on a loaded machine or dead time on an idle one.
2056    async fn until(mut done: impl FnMut() -> bool) -> bool {
2057        for _ in 0..200 {
2058            if done() {
2059                return true;
2060            }
2061            tokio::time::sleep(Duration::from_millis(10)).await;
2062        }
2063        done()
2064    }
2065
2066    /// A block directory with nothing in it. `scan` reads the name and
2067    /// `reclaim` unlinks the directory; neither opens a table, so a test about
2068    /// *which* blocks go does not need a flusher to produce them.
2069    fn fake_block(dir: &Path, signal: &str, max_ts: i64, seq: u64) -> PathBuf {
2070        let partition = dir.join(signal).join("p=1970-01-01-00");
2071        std::fs::create_dir_all(&partition).unwrap();
2072        let block = partition.join(format!(
2073            "{:020}-{max_ts:020}-{:08x}-{seq:012}-{:020}",
2074            0, 7, 0
2075        ));
2076        std::fs::create_dir_all(&block).unwrap();
2077        block
2078    }
2079
2080    /// Shedding is thousands of exports a second when it happens at all: the
2081    /// counter has to take every one of them and the log has to take one a
2082    /// second, or the incident is either invisible or drowned in its own
2083    /// warnings. A local `Rejects`, not the process-wide one, so the count is
2084    /// exact rather than a lower bound.
2085    #[test]
2086    fn every_shed_export_is_counted_and_at_most_one_a_second_is_logged() {
2087        let gate = AtomicU64::new(0);
2088        assert!(once_a_second(&gate), "the first caller in a second speaks");
2089        assert!(!once_a_second(&gate), "and everyone behind it is silent");
2090
2091        let r = Rejects::new("logs");
2092        listening(|| {
2093            for _ in 0..3 {
2094                r.record_shed();
2095            }
2096        });
2097        assert_eq!(r.shed.load(Relaxed), 3, "every shed export is counted");
2098        assert_ne!(
2099            r.warned.load(Relaxed),
2100            0,
2101            "the gate is armed, so the next thousand this second are silent"
2102        );
2103    }
2104
2105    /// A log that cannot take a frame is not the sender's fault unless the
2106    /// frame is too large, and the difference is the whole failure contract: a
2107    /// full or detached volume answered `Failed` has every exporter drop the
2108    /// batch it is holding, which is data loss chosen by an error variant.
2109    #[tokio::test]
2110    async fn a_log_failure_that_is_not_the_senders_fault_is_answered_retryably() {
2111        let dir = std::env::temp_dir().join(format!("mira-pipe-walgone-{}", std::process::id()));
2112        let _ = std::fs::remove_dir_all(&dir);
2113        std::fs::create_dir_all(&dir).unwrap();
2114        let node = block::node_id("walgone");
2115        let wal = Arc::new(Wal::open(&dir, node).unwrap());
2116        // One segment's worth, so the next append has to roll to a new file.
2117        // The size trigger is the only way in from here — `Wal`'s internals are
2118        // private to `mira-core` — and it buys the one failure that is neither
2119        // "too large" nor a corrupt disk.
2120        wal.append(wal::Signal::Logs, &vec![0u8; 64 << 20]).unwrap();
2121        let c = Arc::new(Config {
2122            data_dir: dir.clone(),
2123            node,
2124            wal: Some(Arc::clone(&wal)),
2125            ..Default::default()
2126        });
2127        let (tx, _open, h) = spawn::<LogsBuilder>(&c);
2128        // The log's directory, removed under it: the roll cannot create its
2129        // successor, which is what a volume that went away looks like from
2130        // inside `append`.
2131        std::fs::remove_dir_all(dir.join(".wal")).unwrap();
2132
2133        let answer = tx.submit(wide(1)).await;
2134        assert!(
2135            matches!(&answer, Err(Rejected::Unavailable(why)) if !why.is_empty()),
2136            "a log that cannot write must be retryable and say why"
2137        );
2138        drop(tx);
2139        h.await.unwrap();
2140        let _ = std::fs::remove_dir_all(&dir);
2141    }
2142
2143    /// `spawn_retention` only starts the maintenance task when a log is
2144    /// configured, and the guard inside it is what makes that safe to get
2145    /// wrong: without it the task would tick four times a second on a node
2146    /// that has nothing to sync.
2147    #[tokio::test]
2148    async fn wal_maintenance_without_a_log_has_nothing_to_do() {
2149        let (c, dir) = cfg("nowal");
2150        assert!(
2151            c.wal.is_none(),
2152            "the shipped default for this test's config"
2153        );
2154        // It returns. If the guard were gone this would tick for ever and the
2155        // timeout, not the assertion, would be the failure.
2156        tokio::time::timeout(Duration::from_millis(250), wal_maintenance(c))
2157            .await
2158            .expect("a node with no log has no maintenance loop to run");
2159        let _ = std::fs::remove_dir_all(&dir);
2160    }
2161
2162    /// A staging directory is what a `publish` killed mid-write leaves behind.
2163    /// Nothing will ever finish it and nothing reads it, so a boot that did not
2164    /// sweep it would leak a copy of a whole block per crash onto the volume
2165    /// retention is trying to keep free.
2166    #[tokio::test]
2167    async fn a_staging_directory_a_crash_left_behind_is_swept_at_boot() {
2168        let (c, dir) = cfg("staging");
2169        let node = c.node;
2170        let stale = dir
2171            .join(".tmp")
2172            .join(format!("logs-{node:08x}-000000000007"));
2173        std::fs::create_dir_all(&stale).unwrap();
2174        std::fs::write(stale.join("logs.arrow"), b"half a block").unwrap();
2175        // Another node's staging directory, on the shared volume of section 12: not
2176        // this process's to remove, and removing it would delete a block a live
2177        // replica is part-way through writing.
2178        let theirs = dir
2179            .join(".tmp")
2180            .join(format!("logs-{:08x}-000000000007", 0));
2181        std::fs::create_dir_all(&theirs).unwrap();
2182
2183        let (tx, _open, h) = spawn::<LogsBuilder>(&c);
2184        tx.submit(ExportLogsServiceRequest::default())
2185            .await
2186            .unwrap_or_else(|_| panic!("the flusher booted"));
2187        drop(tx);
2188        h.await.unwrap();
2189
2190        assert!(!stale.exists(), "the leaked staging directory is gone");
2191        assert!(
2192            theirs.exists(),
2193            "another replica's is not this node's to take"
2194        );
2195        let _ = std::fs::remove_dir_all(&dir);
2196    }
2197
2198    /// A builder that fails where the real ones only fail on a request no
2199    /// block can hold. `SEAL` picks which of the two seams breaks: the seal
2200    /// that publishes, or the snapshot the read path asks for. Reaching either
2201    /// through `LogsBuilder` would take a 65k-key request per attempt, and
2202    /// what is under test is not the encoder — it is what the flusher promises
2203    /// when an encoder does fail.
2204    #[derive(Default)]
2205    struct Brittle<const SEAL: bool>(LogsBuilder);
2206
2207    impl<const SEAL: bool> SignalBuilder for Brittle<SEAL> {
2208        type Request = ExportLogsServiceRequest;
2209        const SIGNAL: &'static str = "logs";
2210
2211        fn has_headroom_for(&self, req: &Self::Request) -> bool {
2212            self.0.has_headroom_for(req)
2213        }
2214        fn append_request(&mut self, req: &Self::Request) -> mira_core::error::Result<usize> {
2215            self.0.append_request(req)
2216        }
2217        fn approx_bytes(&self) -> usize {
2218            self.0.approx_bytes()
2219        }
2220        fn is_empty(&self) -> bool {
2221            self.0.is_empty()
2222        }
2223        fn finish(&mut self) -> mira_core::error::Result<mira_core::signal::Sealed> {
2224            if SEAL {
2225                // A real one: this is what an overflowing dictionary raises,
2226                // and `finish` leaves a fresh builder behind either way.
2227                let _ = self.0.finish();
2228                return Err(mira_core::Error::DictionaryFull("attr_key"));
2229            }
2230            self.0.finish()
2231        }
2232        fn snapshot(&self) -> mira_core::error::Result<mira_core::signal::Sealed> {
2233            if SEAL {
2234                return self.0.snapshot();
2235            }
2236            Err(mira_core::Error::DictionaryFull("attr_key"))
2237        }
2238    }
2239
2240    /// A block that cannot be sealed must not take anybody's data with it. Every
2241    /// caller is told `Unavailable` — never `Failed`, because whose export broke
2242    /// the encoder is not knowable from here — and, with a log, the watermark
2243    /// the discarded block would have claimed is dropped on the floor so the
2244    /// frames come back on the next boot. A `wal_hi` left standing here is the
2245    /// one bug in this file that loses acknowledged data silently: the block
2246    /// never existed, but the log would be truncated as if it had.
2247    #[tokio::test]
2248    async fn a_block_that_cannot_be_sealed_nacks_retryably_and_leaves_its_frames_in_the_log() {
2249        // Without a log the caller is the one waiting on the seal, so it is the
2250        // one that has to be told.
2251        let (c, dir) = cfg("brittle-seal");
2252        let (tx, _open, h) = spawn::<Brittle<true>>(&c);
2253        let answer = tx.submit(wide(1)).await;
2254        assert!(
2255            matches!(&answer, Err(Rejected::Unavailable(why)) if why.contains("dictionary")),
2256            "a seal that failed is the block's fault, not this caller's"
2257        );
2258        drop(tx);
2259        h.await.unwrap();
2260        assert_eq!(blocks(&dir), 0, "nothing was published");
2261        let _ = std::fs::remove_dir_all(&dir);
2262
2263        // With one, the caller was acknowledged long before the seal, so the
2264        // promise that survives is the log's.
2265        let dir = std::env::temp_dir().join(format!("mira-pipe-brittle-{}", std::process::id()));
2266        let _ = std::fs::remove_dir_all(&dir);
2267        std::fs::create_dir_all(&dir).unwrap();
2268        let node = block::node_id("brittletest");
2269        let wal = Arc::new(Wal::open(&dir, node).unwrap());
2270        let c = Arc::new(Config {
2271            data_dir: dir.clone(),
2272            node,
2273            max_block_age: Duration::from_millis(50),
2274            wal: Some(Arc::clone(&wal)),
2275            ..Default::default()
2276        });
2277        let (tx, open, h) = spawn::<Brittle<true>>(&c);
2278        tx.submit(wide(1))
2279            .await
2280            .unwrap_or_else(|_| panic!("the log took it, whatever the block does later"));
2281        drop(tx);
2282        h.await.unwrap();
2283
2284        assert_eq!(
2285            block::wal_watermarks(&dir).unwrap(),
2286            [0, 0, 0],
2287            "a block that was never published claims no sequence"
2288        );
2289        assert!(
2290            open.fresh().await.is_empty(),
2291            "and advertises no rows the read path could no longer produce"
2292        );
2293        // Which is what makes the acknowledgement honest: the frame is still
2294        // there and the next boot hands it back.
2295        let replayed = Wal::replay(
2296            &dir,
2297            node,
2298            block::wal_watermarks(&dir).unwrap(),
2299            |_, _, _| Ok(()),
2300        )
2301        .unwrap();
2302        assert_eq!(
2303            (replayed.replayed, replayed.skipped),
2304            (1, 0),
2305            "the acknowledged export survived the block that could not hold it"
2306        );
2307        let _ = std::fs::remove_dir_all(&dir);
2308    }
2309
2310    /// The open-block snapshot is best-effort, and "best-effort" has to mean
2311    /// *nothing* rather than *something stale*: the read path shows a snapshot
2312    /// as if it were on disk, so one that could not be rebuilt must clear the
2313    /// slot. Leaving the last one in place would serve rows from a block that
2314    /// has since been sealed and republished — the same records twice.
2315    #[tokio::test]
2316    async fn an_open_block_that_cannot_be_snapshotted_shows_nothing_rather_than_stale_rows() {
2317        for (breaks, want) in [(true, false), (false, true)] {
2318            let dir =
2319                std::env::temp_dir().join(format!("mira-pipe-snap{breaks}-{}", std::process::id()));
2320            let _ = std::fs::remove_dir_all(&dir);
2321            std::fs::create_dir_all(&dir).unwrap();
2322            let node = block::node_id("snaptest");
2323            let c = Arc::new(Config {
2324                data_dir: dir.clone(),
2325                node,
2326                // Long enough that nothing seals under the test: what is being
2327                // read is the block while it is still open.
2328                max_block_age: Duration::from_secs(30),
2329                wal: Some(Arc::new(Wal::open(&dir, node).unwrap())),
2330                ..Default::default()
2331            });
2332            // `Brittle<false>` fails `snapshot` and seals fine; `Brittle<true>`
2333            // is the other way round, so the healthy comparison runs through
2334            // the same wrapper rather than a different type.
2335            let (tx, open, h) = if breaks {
2336                spawn::<Brittle<false>>(&c)
2337            } else {
2338                spawn::<Brittle<true>>(&c)
2339            };
2340            tx.submit(crate::e2e::logs_export("checkout", 2_000, 4))
2341                .await
2342                .unwrap_or_else(|_| panic!("acknowledged by the log"));
2343
2344            // `fresh` waits for the flusher to drain its queue, so this is not
2345            // a race: the export is in the builder by the time it answers.
2346            assert_eq!(
2347                !open.fresh().await.is_empty(),
2348                want,
2349                "breaks={breaks}: a snapshot that failed must clear the slot"
2350            );
2351            drop(tx);
2352            h.await.unwrap();
2353            let _ = std::fs::remove_dir_all(&dir);
2354        }
2355    }
2356
2357    /// Reclaim stops the moment the volume is back above the floor. It is
2358    /// deleting telemetry nobody asked it to delete, so "enough" is the whole
2359    /// contract: a sweep that ran to the end of the list because it only
2360    /// checked before the first unlink would empty the disk to free one block's
2361    /// worth of space.
2362    ///
2363    /// ponytail: the floor is derived from 128 MiB of real files on the real
2364    /// volume, because `free_fraction` is a `statfs` and there is nothing to
2365    /// inject. Something else on this disk moving 64 MiB the wrong way during
2366    /// the sweep would flap it; the upgrade path is a free-space probe the
2367    /// caller supplies, which would also make this test instant.
2368    #[test]
2369    fn reclaim_stops_as_soon_as_the_volume_is_back_over_the_floor() {
2370        let dir = std::env::temp_dir().join(format!("mira-pipe-ballast-{}", std::process::id()));
2371        let _ = std::fs::remove_dir_all(&dir);
2372        std::fs::create_dir_all(&dir).unwrap();
2373        let oldest = fake_block(&dir, "logs", 1_000, 0);
2374        let newer = fake_block(&dir, "logs", 2_000, 1);
2375        let newest = fake_block(&dir, "traces", 3_000, 2);
2376
2377        let empty = block::free_fraction(&dir).unwrap();
2378        // Many synced files rather than one big one, and both halves matter.
2379        // Synced, because until the extents are allocated `statfs` has not
2380        // noticed them and `full` below is just `empty`. Many, because APFS
2381        // returns the space of an unlinked file asynchronously — measured here
2382        // at up to 176 ms for a single 64 MiB file, which is far longer than
2383        // the whole sweep — while a directory of 1 MiB files comes back
2384        // essentially whole by the time the last unlink returns (measured:
2385        // 0.998 of it, worst of five runs).
2386        {
2387            use std::io::Write;
2388            for i in 0..128 {
2389                let mut f = std::fs::File::create(oldest.join(format!("{i}.arrow"))).unwrap();
2390                f.write_all(&vec![0u8; 1 << 20]).unwrap();
2391                f.sync_all().unwrap();
2392            }
2393        }
2394        let full = block::free_fraction(&dir).unwrap();
2395        assert!(full < empty, "128 MiB moved the needle: {full} vs {empty}");
2396        // Halfway between the two, so the sweep starts below the floor and is
2397        // back above it after exactly one unlink.
2398        let floor = (full + empty) / 2.0;
2399
2400        let dropped = listening(|| reclaim(&dir, floor).unwrap());
2401        assert_eq!(
2402            dropped,
2403            vec![oldest],
2404            "the oldest block, and then it stopped"
2405        );
2406        assert!(
2407            newer.exists() && newest.exists(),
2408            "nothing else was touched"
2409        );
2410        let _ = std::fs::remove_dir_all(&dir);
2411    }
2412
2413    /// One block the sweep cannot unlink says nothing about the next one.
2414    /// Returning at the first error stopped retention for the whole volume at
2415    /// its oldest broken block — the disk stayed full and every export was
2416    /// NACKed, which is the outage reclaim exists to prevent — and a block
2417    /// another replica removed first is not an error at all.
2418    #[test]
2419    fn a_block_that_cannot_be_dropped_does_not_stop_the_ones_behind_it() {
2420        let dir = std::env::temp_dir().join(format!("mira-pipe-undrop-{}", std::process::id()));
2421        let _ = std::fs::remove_dir_all(&dir);
2422        std::fs::create_dir_all(&dir).unwrap();
2423
2424        // A regular file wearing a block's name. `scan` reads names, so it is
2425        // returned like any other block and `remove_dir_all` refuses it — the
2426        // same shape as a directory this process cannot traverse.
2427        let partition = dir.join("logs").join("p=1970-01-01-00");
2428        std::fs::create_dir_all(&partition).unwrap();
2429        let impostor = partition.join(format!("{:020}-{:020}-{:08x}-{:012}-{:020}", 0, 1, 7, 0, 0));
2430        std::fs::write(&impostor, b"not a block").unwrap();
2431
2432        // A margin no volume can satisfy: the sweep tries everything it can see.
2433        let dropped = listening(|| reclaim(&dir, 2.0).unwrap());
2434        assert!(
2435            dropped.is_empty() && impostor.exists(),
2436            "nothing was dropped, and the sweep still returned"
2437        );
2438
2439        // The same block seen twice — one replica's unlink landing between this
2440        // sweep's `scan` and its `remove_dir_all` — simulated by listing one
2441        // tree under two signals.
2442        let real = fake_block(&dir, "traces", 5_000, 3);
2443        std::os::unix::fs::symlink(dir.join("traces"), dir.join("metrics")).unwrap();
2444        let dropped = listening(|| reclaim(&dir, 2.0).unwrap());
2445        assert_eq!(
2446            dropped,
2447            vec![real.clone()],
2448            "the block is reported once, and the second sighting is not an error"
2449        );
2450        assert!(!real.exists());
2451        let _ = std::fs::remove_dir_all(&dir);
2452    }
2453
2454    /// Every way one sweep can fail, and the property is the same for all of
2455    /// them: the loop keeps its next tick. Retention is the only thing that
2456    /// frees space, so a sweep that took the task down with it would turn one
2457    /// unreadable signal into a volume that fills up and stays full.
2458    #[tokio::test]
2459    async fn a_sweep_that_fails_never_takes_the_retention_loop_with_it() {
2460        let dir = std::env::temp_dir().join(format!("mira-pipe-sweepfail-{}", std::process::id()));
2461        let _ = std::fs::remove_dir_all(&dir);
2462        std::fs::create_dir_all(&dir).unwrap();
2463        // `logs` cannot be scanned, so both `expire` and `compact` fail for it;
2464        // `traces` holds a block a zero TTL expires. One signal failing must
2465        // not skip the others, which is the half of this that is silent.
2466        std::fs::write(dir.join("logs"), b"not a directory").unwrap();
2467        let doomed = fake_block(&dir, "traces", 1_000, 0);
2468
2469        // The first tick is immediate and the second is a minute away, so a
2470        // task that is still unfinished after its sweep is a task that took the
2471        // failure and went back to waiting.
2472        let sweeping = tokio::spawn(retention(Arc::new(Config {
2473            data_dir: dir.clone(),
2474            retention: Duration::ZERO,
2475            ..Default::default()
2476        })));
2477        assert!(
2478            until(|| !doomed.exists()).await,
2479            "the signal that could be swept was swept, whatever the broken one did"
2480        );
2481        assert!(!sweeping.is_finished(), "and the loop kept its next tick");
2482        sweeping.abort();
2483
2484        // A data directory that is not there: `free_fraction` cannot answer, so
2485        // the sweep is TTL-only rather than a dead task. Nothing on disk changes
2486        // — the observable is that the task is still there afterwards.
2487        let sweeping = tokio::spawn(retention(Arc::new(Config {
2488            data_dir: dir.join("never-created"),
2489            retention: Duration::ZERO,
2490            ..Default::default()
2491        })));
2492        tokio::time::sleep(Duration::from_millis(200)).await;
2493        assert!(
2494            !sweeping.is_finished(),
2495            "a volume it cannot even measure is not a reason to stop measuring it"
2496        );
2497        sweeping.abort();
2498        let _ = std::fs::remove_dir_all(&dir);
2499    }
2500
2501    /// `retention: 999999d` is how an operator writes "keep it forever", and
2502    /// until the cutoff was made saturating it meant the exact opposite: the
2503    /// TTL in nanoseconds overflowed an `i64`, wrapped negative, and put the
2504    /// cutoff in the future — where every block on the volume is older than it.
2505    /// The sweep still has to do its other job while keeping everything.
2506    #[tokio::test]
2507    async fn an_absurd_retention_keeps_every_block_and_still_compacts_the_cold_ones() {
2508        let (c, dir) = cfg("forever");
2509        let (tx, _open, h) = spawn::<LogsBuilder>(&c);
2510        // Timestamped in 1970, so it is cold by any clock: the compaction half
2511        // of the sweep has something to do and the TTL half must not.
2512        tx.submit(crate::e2e::logs_export("checkout", 1_000, 4))
2513            .await
2514            .unwrap_or_else(|_| panic!("export"));
2515        drop(tx);
2516        h.await.unwrap();
2517        assert_eq!(blocks(&dir), 1);
2518
2519        let cfg = Arc::new(Config {
2520            data_dir: dir.clone(),
2521            // 547 years. `Duration::as_nanos` is a `u128` and holds it; an
2522            // `i64` of nanoseconds does not.
2523            retention: Duration::from_secs(200_000 * 86_400),
2524            ..Default::default()
2525        });
2526        let sweeping = tokio::spawn(retention(cfg));
2527        let cold = block::scan(&dir, "logs").unwrap()[0].dir.join("cold");
2528        assert!(
2529            until(|| cold.exists()).await,
2530            "the block was compacted rather than deleted"
2531        );
2532        assert!(
2533            !sweeping.is_finished(),
2534            "one sweep, and the loop is still there"
2535        );
2536        sweeping.abort();
2537
2538        assert_eq!(
2539            blocks(&dir),
2540            1,
2541            "a retention longer than i64 nanoseconds keeps everything"
2542        );
2543        let _ = std::fs::remove_dir_all(&dir);
2544    }
2545
2546    /// Readiness has to be a *sustained* condition. A probe that flipped on the
2547    /// first failed publish would pull the node out of its Service for every
2548    /// EIO and every remount, and an endpoint list that changes every ten
2549    /// seconds loses more exports than the node it is protecting.
2550    #[test]
2551    fn a_stall_is_only_reportable_once_it_has_outlasted_the_recovery() {
2552        let r = Rejects::new("logs");
2553        let now = 1_700_000_000;
2554        assert_eq!(stall_of(&r, now), None, "a healthy signal is never unready");
2555
2556        r.stalled_since.store(now, Relaxed);
2557        let after = UNREADY_AFTER.as_secs();
2558        assert_eq!(
2559            stall_of(&r, now),
2560            None,
2561            "one failed publish is not an outage"
2562        );
2563        assert_eq!(stall_of(&r, now + after - 1), None);
2564        assert_eq!(stall_of(&r, now + after), Some(after));
2565
2566        // The clock is the *first* failure of the run, not the latest one, or a
2567        // node failing every two seconds would reset itself to healthy forever.
2568        r.shard_stalled_since[0].store(1, Relaxed);
2569        r.mark_stalled(0);
2570        assert_eq!(r.stalled_since.load(Relaxed), 1);
2571        // ...and the first failure does start it, from zero.
2572        let fresh = Rejects::new("logs");
2573        assert_eq!(fresh.stalled_since.load(Relaxed), 0);
2574        fresh.mark_stalled(0);
2575        assert_ne!(fresh.stalled_since.load(Relaxed), 0);
2576
2577        // Nothing in this test binary has been unable to store for two minutes,
2578        // so the live answer is the healthy one.
2579        assert_eq!(stalled(), None);
2580    }
2581
2582    /// A signal's health is the worst of its shards, not the last one to report.
2583    ///
2584    /// The bug this exists to prevent: with the flushers writing the aggregate
2585    /// directly, a shard sealing normally would clear `open_since` while a
2586    /// sibling sat on a block it could not flush, and `/healthz` would call a
2587    /// stuck node healthy. Oldest-of-nonzero is the only reduction that answers
2588    /// "is anything stuck" rather than "was the last thing that happened fine".
2589    #[test]
2590    fn a_signals_clocks_report_the_worst_shard_not_the_latest_one() {
2591        let r = Rejects::new("logs");
2592        r.set_open_since(0, 100);
2593        r.set_open_since(1, 500);
2594        assert_eq!(r.open_since.load(Relaxed), 100);
2595
2596        // Shard 0 seals. Shard 1 is still holding its block open, so the signal
2597        // still has something open — and it is shard 1's clock now.
2598        r.set_open_since(0, 0);
2599        assert_eq!(r.open_since.load(Relaxed), 500);
2600        r.set_open_since(1, 0);
2601        assert_eq!(
2602            r.open_since.load(Relaxed),
2603            0,
2604            "nothing open is zero, not min"
2605        );
2606
2607        // Same rule for stalls, and the same failure mode: one shard recovering
2608        // does not make the node ready while another cannot write.
2609        r.shard_stalled_since[1].store(900, Relaxed);
2610        r.mark_stalled(0);
2611        let both = r.stalled_since.load(Relaxed);
2612        assert!(both > 0 && both <= 900, "the older of the two, got {both}");
2613        r.clear_stalled(0);
2614        assert_eq!(r.stalled_since.load(Relaxed), 900);
2615        r.clear_stalled(1);
2616        assert_eq!(r.stalled_since.load(Relaxed), 0);
2617    }
2618
2619    /// Shard count is a function of core count and nothing else — section 4's
2620    /// rule for what a shard may be keyed on.
2621    #[test]
2622    fn shards_are_counted_from_cores_and_clamped_at_both_ends() {
2623        // Auto. Halved because a flusher is a consumer and the decode is the
2624        // producer; never zero, whatever the machine claims.
2625        assert_eq!(shard_count(0, 1), 1);
2626        assert_eq!(shard_count(0, 2), 1);
2627        assert_eq!(shard_count(0, 12), 6);
2628        // The ceiling is what keeps a 128-core host from publishing 64 files
2629        // per seal window per signal.
2630        assert_eq!(shard_count(0, 128), MAX_SHARDS);
2631        // Configured wins, up to the same ceiling — this is the escape hatch
2632        // for a machine that miscounts the cores this process actually gets,
2633        // and an operator who types 1 gets the old behaviour.
2634        assert_eq!(shard_count(1, 128), 1);
2635        assert_eq!(shard_count(4, 2), 4);
2636        assert_eq!(shard_count(999, 2), MAX_SHARDS);
2637    }
2638
2639    /// A sharded config with the log on, which is the shipped default and the
2640    /// only setting under which a test can submit without waiting for a seal.
2641    ///
2642    /// Without a log `submit` returns at the publish, so a long `max_block_age`
2643    /// makes every serial submit wait out the age timer and publish a block of
2644    /// its own — which is the behaviour under test, inverted.
2645    fn sharded(name: &str, shards: usize) -> (Arc<Config>, PathBuf) {
2646        let (c, dir) = cfg(name);
2647        let node = block::node_id(name);
2648        let mut c = Arc::try_unwrap(c).ok().expect("freshly built");
2649        c.node = node;
2650        c.shards = shards;
2651        c.wal = Some(Arc::new(Wal::open(&dir, node).unwrap()));
2652        // Long enough that nothing seals on the timer mid-test: the blocks here
2653        // are sealed by a full dictionary or by the shutdown path.
2654        c.max_block_age = Duration::from_secs(30);
2655        (Arc::new(c), dir)
2656    }
2657
2658    /// Every shard's blocks land, and no two of them collide on a sequence.
2659    ///
2660    /// A collision is not a cosmetic problem: the block directory name is the
2661    /// manifest, so `rename` onto an existing directory means the node stops
2662    /// publishing — and with one queue per shard, "shard 2 picks the next
2663    /// number" is not a thing anything can observe. Stride and offset are all
2664    /// that keep them apart.
2665    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
2666    async fn shards_partition_the_sequence_space() {
2667        let (c, dir) = sharded("shardseq", 4);
2668        let (tx, _open, h) = spawn::<LogsBuilder>(&c);
2669        // 40k distinct keys each: two of these cannot share a `UInt16`
2670        // dictionary, so whichever shard takes two publishes two, and the
2671        // sequences still may not collide.
2672        let mut sent = Vec::new();
2673        for _ in 0..8 {
2674            let tx = tx.clone();
2675            sent.push(tokio::spawn(async move { tx.submit(wide(40_000)).await }));
2676        }
2677        for s in sent {
2678            s.await
2679                .unwrap()
2680                .unwrap_or_else(|_| panic!("nothing may be shed: the wait is 5s"));
2681        }
2682        drop(tx);
2683        h.await.unwrap();
2684
2685        let seqs = seqs(&dir);
2686        assert_eq!(seqs.len(), 8, "one block per export, none lost");
2687        let mut uniq = seqs.clone();
2688        uniq.dedup();
2689        assert_eq!(uniq, seqs, "two shards reused a sequence: {seqs:?}");
2690        let _ = std::fs::remove_dir_all(&dir);
2691    }
2692
2693    /// A node that is not saturating one flusher keeps behaving like a node
2694    /// with one flusher, and its sequences stride by the shard count.
2695    ///
2696    /// The reason dispatch is first-fit and not round-robin. Round-robin would
2697    /// spread a trickle over every shard and publish `shards` nearly-empty
2698    /// blocks per seal window, which is the small-file explosion section 4
2699    /// rejects hash sharding for, arrived at from the other direction. Shard
2700    /// 0's queue has room every time, so `try_reserve` never has to look past
2701    /// it.
2702    #[tokio::test]
2703    async fn a_trickle_stays_on_one_shard_and_strides_its_sequences() {
2704        let (c, dir) = sharded("shardtrickle", 4);
2705        let (tx, _open, h) = spawn::<LogsBuilder>(&c);
2706        for _ in 0..4 {
2707            tx.submit(crate::e2e::logs_export("checkout", 2_000, 4))
2708                .await
2709                .unwrap_or_else(|_| panic!("acknowledged"));
2710        }
2711        // Two 40k-key exports: the first joins the open block, the second
2712        // cannot share its dictionary and so seals it. Two blocks from one
2713        // shard, which is what makes the stride observable.
2714        for _ in 0..2 {
2715            tx.submit(wide(40_000))
2716                .await
2717                .unwrap_or_else(|_| panic!("acknowledged"));
2718        }
2719        drop(tx);
2720        h.await.unwrap();
2721        assert_eq!(
2722            seqs(&dir),
2723            vec![0, 4],
2724            "one shard's blocks, striding by the shard count — four shards must \
2725             not mean four files for a load one shard can take"
2726        );
2727        let _ = std::fs::remove_dir_all(&dir);
2728    }
2729
2730    /// A `Config` that never went through [`shard_count`] is still bounded, and
2731    /// the bound is observable from outside: the sequence stride *is* the shard
2732    /// count.
2733    ///
2734    /// [`shard_count`] is what `main` calls, so it is the gate an operator hits.
2735    /// This tests the other one — [`spawn`]'s own `clamp` — because a `Config`
2736    /// is also built by hand, by the TUI, by a test, and by whatever calls this
2737    /// next. A count of zero would spawn no flushers and hang every export
2738    /// forever; a count of 4 billion would try to spawn 4 billion tasks. Neither
2739    /// belongs to `main`'s gate alone.
2740    #[tokio::test]
2741    async fn spawn_bounds_a_shard_count_that_never_went_through_shard_count() {
2742        for (configured, stride) in [(0, 1), (usize::MAX, MAX_SHARDS)] {
2743            let (c, dir) = sharded(&format!("shardclamp{configured}"), configured);
2744            let (tx, _open, h) = spawn::<LogsBuilder>(&c);
2745            // First fit keeps a trickle on shard 0, so consecutive blocks off
2746            // that one shard are exactly `stride` apart.
2747            for _ in 0..2 {
2748                tx.submit(wide(40_000))
2749                    .await
2750                    .unwrap_or_else(|_| panic!("acknowledged"));
2751            }
2752            drop(tx);
2753            h.await.unwrap();
2754            assert_eq!(
2755                seqs(&dir),
2756                vec![0, stride as u64],
2757                "{configured} shards must be clamped to {stride}"
2758            );
2759            let _ = std::fs::remove_dir_all(&dir);
2760        }
2761    }
2762
2763    /// A crash after a shard sealed *out of sequence order* replays every frame
2764    /// no published block holds — and in particular replays the older frame the
2765    /// sealed block's own sequences step over.
2766    ///
2767    /// This is the whole reason the watermark is a set rather than
2768    /// `max(seq) + 1`, driven end to end instead of at the `Wal` API: real
2769    /// flushers, a real block directory name carrying `wal_hi`, the real
2770    /// `wal_watermarks` reduction over it, and a real `Wal::replay`. Getting it
2771    /// wrong is silent loss, and silent loss is exactly what a test that only
2772    /// counts rows does not see.
2773    ///
2774    /// The schedule is forced, not hoped for. Holding shard 0's only queue slot
2775    /// makes first-fit spill to shard 1 deterministically, so shard 1 takes the
2776    /// *higher* sequences and is made to seal — by a second 40k-key dictionary
2777    /// it cannot merge — while frame 0 is still sitting in shard 0. If the
2778    /// sealed block claimed `max(seq) + 1` it would claim 2, the watermark would
2779    /// be 2, and frame 0 would never be handed back by any later boot.
2780    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
2781    async fn a_crash_after_an_out_of_order_seal_replays_every_frame_no_block_holds() {
2782        let (mut c, dir) = sharded("shardcrash", 2);
2783        // One slot per shard: `queue` is a per-signal total and splits.
2784        Arc::get_mut(&mut c).unwrap().queue = 2;
2785        let node = c.node;
2786        let wal = Arc::clone(c.wal.as_ref().unwrap());
2787        let (tx, _open, mut h) = spawn::<LogsBuilder>(&c);
2788
2789        // Frame 0 goes to shard 0 by first fit, and stays there: nothing below
2790        // seals it.
2791        tx.submit(crate::e2e::logs_export("checkout", 2_000, 4))
2792            .await
2793            .unwrap_or_else(|_| panic!("the log took it"));
2794
2795        // Shard 0's one slot, taken and held. The await is itself the barrier
2796        // that frame 0 has left the channel — capacity only comes back when the
2797        // flusher has taken it.
2798        let held = tx.tx[0].clone().reserve_owned().await.unwrap();
2799
2800        // So frames 1 and 2 both land on shard 1, and 2 cannot share 1's key
2801        // dictionary — which seals the block holding frame 1 and carries 2 into
2802        // the next one.
2803        for _ in 0..2 {
2804            tx.submit(wide(40_000))
2805                .await
2806                .unwrap_or_else(|_| panic!("the log took it"));
2807        }
2808        assert!(
2809            until(|| blocks(&dir) == 1).await,
2810            "shard 1's first block never landed"
2811        );
2812
2813        // The crash: every shard stops where it stands, so frames 0 and 2 are
2814        // in the log and in nobody's block.
2815        h.abort();
2816        drop(held);
2817
2818        let published = block::scan(&dir, "logs").unwrap();
2819        assert_eq!(published.len(), 1);
2820        assert_eq!(
2821            published[0].wal_hi, 0,
2822            "a block holding frame 1 may not claim past frame 0, which shard 0 \
2823             still has — this is the assertion `max(seq) + 1` fails"
2824        );
2825        assert_eq!(
2826            block::wal_watermarks(&dir).unwrap(),
2827            [0, 0, 0],
2828            "and the reduction over the directory says the same"
2829        );
2830
2831        // Recovery: nothing is skipped, and the frame the sealed block does
2832        // hold comes back too. Re-ingesting frame 1 is the direction the
2833        // watermark is allowed to be wrong in — the other direction is the one
2834        // that loses data.
2835        let mut got = Vec::new();
2836        let replayed = Wal::replay(
2837            &dir,
2838            node,
2839            block::wal_watermarks(&dir).unwrap(),
2840            |_, seq, body| {
2841                got.push((seq, body.to_vec()));
2842                Ok(())
2843            },
2844        )
2845        .unwrap();
2846        assert_eq!(
2847            got.iter().map(|(s, _)| *s).collect::<Vec<_>>(),
2848            vec![0, 1, 2],
2849            "every frame, none skipped"
2850        );
2851        assert_eq!((replayed.replayed, replayed.skipped), (3, 0));
2852
2853        // And it converges. Feeding the recovered frames to a fresh node under
2854        // their own sequences leaves every one of them claimed, so the boot
2855        // after this one replays nothing at all.
2856        let (tx, _open, h) = spawn::<LogsBuilder>(&c);
2857        for (seq, body) in got {
2858            tx.replay(&body, seq)
2859                .unwrap_or_else(|_| panic!("the flusher took frame {seq}"));
2860        }
2861        drop(tx);
2862        h.await.unwrap();
2863        assert_eq!(
2864            wal.watermark_for(wal::Signal::Logs, &[]),
2865            3,
2866            "every frame is published, so nothing is pending"
2867        );
2868        let again = Wal::replay(
2869            &dir,
2870            node,
2871            block::wal_watermarks(&dir).unwrap(),
2872            |_, seq, _| unreachable!("frame {seq} is in a block already"),
2873        )
2874        .unwrap();
2875        assert_eq!((again.replayed, again.skipped), (0, 3));
2876        let _ = std::fs::remove_dir_all(&dir);
2877    }
2878
2879    /// Read-your-writes survives the fan-out: every acknowledged export is
2880    /// visible in some shard's open block, before anything has been sealed.
2881    ///
2882    /// The property section 4 buys from FIFO ordering, re-asserted now that
2883    /// there is more than one FIFO. It holds for the same reason it did — an
2884    /// acknowledged export is in exactly one shard's channel until that shard
2885    /// appends it — but only because `fresh` asks all of them.
2886    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
2887    async fn every_shard_answers_the_read_path() {
2888        let (mut c, dir) = sharded("shardfresh", 4);
2889        // One slot per shard, so eight concurrent submits have to spill past
2890        // shard 0 rather than hoping the scheduler spreads them.
2891        Arc::get_mut(&mut c).unwrap().queue = 4;
2892        let (tx, open, h) = spawn::<LogsBuilder>(&c);
2893        let mut sent = Vec::new();
2894        for i in 0..8 {
2895            let tx = tx.clone();
2896            sent.push(tokio::spawn(async move {
2897                tx.submit(crate::e2e::logs_export("checkout", 2_000 + i * 10, 4))
2898                    .await
2899            }));
2900        }
2901        for s in sent {
2902            s.await.unwrap().unwrap_or_else(|_| panic!("acknowledged"));
2903        }
2904        let rows: usize = open.fresh().await.iter().map(|o| o.sealed.num_rows).sum();
2905        assert_eq!(
2906            rows, 32,
2907            "eight exports of four records, all of them findable"
2908        );
2909
2910        drop(tx);
2911        h.await.unwrap();
2912        let _ = std::fs::remove_dir_all(&dir);
2913    }
2914
2915    /// The second shard takes what the first one cannot.
2916    ///
2917    /// `reserve` at the unit level, with no flusher behind either channel: the
2918    /// first is full, so the job has to land in the second rather than wait out
2919    /// `ADMIT_WAIT` and shed.
2920    #[tokio::test]
2921    async fn a_full_shard_spills_into_the_next_one() {
2922        let (tx0, _rx0) = mpsc::channel::<Job<ExportLogsServiceRequest>>(1);
2923        let (tx1, mut rx1) = mpsc::channel::<Job<ExportLogsServiceRequest>>(1);
2924        // Shard 0's one slot, taken and held.
2925        let _held = tx0.clone().reserve_owned().await.unwrap();
2926        let ingest = Ingest {
2927            tx: [tx0, tx1].into(),
2928            turn: Arc::default(),
2929            rejects: Box::leak(Box::new(Rejects::new("logs"))),
2930            wal: None,
2931            signal: wal::Signal::Logs,
2932        };
2933        let sent = tokio::spawn({
2934            let i = ingest.clone();
2935            async move { i.submit(ExportLogsServiceRequest::default()).await }
2936        });
2937        let job = rx1
2938            .recv()
2939            .await
2940            .expect("shard 1 gets what shard 0 cannot take");
2941        let _ = job.ack.send(Ok(()));
2942        sent.await
2943            .unwrap()
2944            .unwrap_or_else(|_| panic!("admitted, not shed"));
2945        assert_eq!(
2946            ingest.rejects.shed.load(Relaxed),
2947            0,
2948            "spilling to a free shard is not shedding"
2949        );
2950    }
2951}