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}