mira/main.rs
1//! Mira: OTLP in, immutable Arrow blocks out, one binary.
2
3mod alert;
4mod api;
5mod config;
6#[cfg(test)]
7mod e2e;
8mod json;
9mod mcp;
10mod pipeline;
11mod receiver;
12mod telemetry;
13mod term;
14mod tui;
15mod ui;
16mod update;
17
18use std::io::IsTerminal;
19use std::path::{Path, PathBuf};
20use std::sync::atomic::Ordering::Relaxed;
21
22use axum::Router;
23use axum::response::{IntoResponse, Response};
24use axum::routing::get;
25
26use config::Config;
27
28const USAGE: &str = "mira [--config FILE] [--node NAME] [--grpc ADDR] [--http ADDR]
29 [--data-dir PATH] [--retention DURATION]
30 [--max-request-bytes SIZE] [--queue N] [--shards N] [--wal]
31 [--self-telemetry] [--telemetry-interval DURATION]
32 [--alerts FILE] [--version]
33
34mira mira [--config FILE] [--data-dir PATH] [--addr HOST[:PORT]]
35mira update [--version VERSION] [--dry-run]
36
37Flags override the config file, which overrides the defaults. Every value can
38also come from the file via ${env:VAR} — see https://miradb.dev/config/.
39
40`mira mira` opens the terminal UI. With --data-dir it reads a block directory
41in-process and needs no server running; with --addr it queries one over HTTP.
42`mira tui` is the same thing, for anyone who guesses that first.
43
44`mira update` replaces this binary with the latest GitHub release, using the
45same installer as the curl one-liner at https://miradb.dev/install/.";
46
47/// Precedence is flag > file > default. Hand-rolled: the flag set exists only to
48/// override the file, so a parser crate would be more code than the thing it
49/// parses.
50fn load() -> Result<Config, String> {
51 let argv: Vec<String> = std::env::args().skip(1).collect();
52 if argv.iter().any(|a| a == "-h" || a == "--help") {
53 println!("{USAGE}");
54 std::process::exit(0);
55 }
56 if argv.iter().any(|a| a == "-V" || a == "--version") {
57 println!("mira {}", env!("CARGO_PKG_VERSION"));
58 std::process::exit(0);
59 }
60 load_from(argv)
61}
62
63/// [`load`] without the two flags that end the process, so it can be called.
64fn load_from(argv: Vec<String>) -> Result<Config, String> {
65 // The file has to be read first so flags can override it.
66 let mut cfg = match argv.iter().position(|a| a == "--config") {
67 Some(i) => Config::load(Path::new(argv.get(i + 1).ok_or("--config needs a value")?))?,
68 None => Config::default(),
69 };
70
71 let mut it = argv.into_iter();
72 while let Some(flag) = it.next() {
73 let mut value = || it.next().ok_or_else(|| format!("{flag} needs a value"));
74 match flag.as_str() {
75 "--config" => {
76 value()?;
77 }
78 "--node" => cfg.node = value()?,
79 "--grpc" => cfg.grpc = value()?.parse().map_err(|e| format!("--grpc: {e}"))?,
80 "--http" => cfg.http = value()?.parse().map_err(|e| format!("--http: {e}"))?,
81 "--data-dir" => cfg.data_dir = PathBuf::from(value()?),
82 "--retention" => cfg.retention = config::duration(&value()?)?,
83 "--max-request-bytes" => cfg.max_request_bytes = config::bytes(&value()?)?,
84 "--queue" => cfg.queue = config::positive(&value()?)?,
85 "--shards" => cfg.shards = config::whole(&value()?)?,
86 "--telemetry-interval" => cfg.telemetry_interval = config::duration(&value()?)?,
87 "--alerts" => cfg.alerts = Some(PathBuf::from(value()?)),
88 // These two take no value, unlike every other flag here. They are
89 // the settings whose file form has to be able to say `false` — to
90 // turn off what an inherited config turned on — and whose flag form
91 // never does, because a flag is only ever typed to enable something
92 // the file did not.
93 "--wal" => cfg.wal = true,
94 "--self-telemetry" => cfg.self_telemetry = true,
95 other => return Err(format!("unknown flag {other}\n\n{USAGE}")),
96 }
97 }
98 Ok(cfg)
99}
100
101/// Where a `mira mira` invocation should read from.
102///
103/// `--addr` wins if given; otherwise the same `data_dir` the server would use,
104/// so `mira mira --config mira.yaml` looks at exactly the directory that config
105/// writes to.
106fn tui_source(argv: &[String]) -> Result<tui::Source, String> {
107 let mut cfg = match argv.iter().position(|a| a == "--config") {
108 Some(i) => Config::load(Path::new(argv.get(i + 1).ok_or("--config needs a value")?))?,
109 None => Config::default(),
110 };
111 let mut addr = None;
112 let mut it = argv.iter().cloned();
113 while let Some(flag) = it.next() {
114 let mut value = || it.next().ok_or_else(|| format!("{flag} needs a value"));
115 match flag.as_str() {
116 "--config" => {
117 value()?;
118 }
119 "--data-dir" => cfg.data_dir = PathBuf::from(value()?),
120 "--addr" => addr = Some(tui::parse_addr(&value()?)?),
121 other => return Err(format!("unknown flag {other}\n\n{USAGE}")),
122 }
123 }
124 Ok(match addr {
125 Some(a) => tui::Source::Remote(a),
126 None => tui::Source::Local(cfg.data_dir),
127 })
128}
129
130/// The guards `serve_with` runs, for a `mira mira --data-dir` that maps exactly
131/// the same blocks with no server in front of it.
132///
133/// Without them [`mira_core::block::scan`] reads `ENOENT` as an empty directory,
134/// so a typo or a volume that never mounted prints `no rows in this window ·
135/// 0/0 blocks` — indistinguishable from a healthy empty store, and "is the data
136/// gone" is the question this feature exists to answer at 3am. A network
137/// filesystem is worse: it reaches `mmap` and leaves on `SIGBUS`, with nothing
138/// printed at all, where the server would have refused with a paragraph naming
139/// the mount.
140///
141/// `check_writable` is deliberately not here. The TUI writes nothing, and a
142/// read-only mount is the normal way to look at a detached volume.
143///
144/// The two cases with nothing to check are decided here rather than at the call
145/// site, because both of them are rules about this check and not about the
146/// caller. A remote source is somebody else's directory: the server answering
147/// on that address ran these on its own way up, and this process never maps one
148/// of its blocks. And off a terminal `tui::run` refuses a redirected stdin or
149/// stdout on its first line, which outranks a diagnosis of a directory nothing
150/// was going to be drawn from anyway.
151fn check_source(src: &tui::Source, on_a_tty: bool) -> Result<(), Box<dyn std::error::Error>> {
152 let tui::Source::Local(dir) = src else {
153 return Ok(());
154 };
155 if !on_a_tty {
156 return Ok(());
157 }
158 if !dir.is_dir() {
159 return Err(format!(
160 "{} is not a directory. `mira mira --data-dir` reads an existing block \
161 directory in place and creates nothing, so this is a volume that never \
162 mounted, a typo, or the path a different replica writes to. An empty \
163 but real directory is fine and shows no rows.",
164 dir.display()
165 )
166 .into());
167 }
168 // `check_filesystem`'s FUSE arm warns rather than refusing — the magic
169 // number cannot tell gcsfuse from a local overlay, so only a human can — and
170 // this process installs no subscriber, because one stray line lands in the
171 // middle of a frame. So: one stderr subscriber for the length of the call,
172 // before `Term::enter` takes the screen. Routing it rather than reprinting
173 // the sentence keeps the wording in `block.rs`, where the check lives.
174 let to_stderr = tracing_subscriber::fmt()
175 .with_writer(std::io::stderr)
176 .with_ansi(std::io::stderr().is_terminal())
177 .without_time()
178 .finish();
179 tracing::subscriber::with_default(to_stderr, || mira_core::block::check_filesystem(dir))?;
180 Ok(())
181}
182
183fn main() {
184 if let Err(e) = run() {
185 // Returning the error from `main` would print it with `Debug`, which
186 // for `Error::Io` is a struct dump with `Os { code: 13, .. }` in it and
187 // for `Error::NetworkFilesystem` throws away the paragraph explaining
188 // what to do instead. Everything that reaches here is aimed at whoever
189 // typed the command; they need the sentence, not the struct.
190 eprintln!("mira: {e}");
191 std::process::exit(1);
192 }
193}
194
195fn run() -> Result<(), Box<dyn std::error::Error>> {
196 let argv: Vec<String> = std::env::args().skip(1).collect();
197 // `mira mira` is the name; `tui` stays because it is what someone types when
198 // they have not read the usage, and answering that is cheaper than a
199 // "no such flag" they have to think about.
200 // Before the tracing subscriber for the same reason `mira mira` is: the
201 // installer writes its own progress to this terminal, and an `info!` line
202 // interleaved with a `sudo` prompt is a prompt someone does not answer.
203 if argv.first().is_some_and(|a| a == "update") {
204 return update::run(&argv[1..]).map_err(Into::into);
205 }
206 if argv.first().is_some_and(|a| a == "mira" || a == "tui") {
207 if argv.iter().any(|a| a == "-h" || a == "--help") {
208 println!("{USAGE}");
209 return Ok(());
210 }
211 // No tracing subscriber on this path, and no runtime. Both write to the
212 // terminal the TUI has just taken over, and one stray `info!` in the
213 // middle of a frame corrupts the whole screen.
214 let src = tui_source(&argv[1..]).map_err(|e| -> Box<dyn std::error::Error> { e.into() })?;
215 // The same two guards `serve_with` runs, for the same reason and before
216 // the same mmap — see `check_source`, which is also where the two cases
217 // it has nothing to say about are written down.
218 let on_a_tty = std::io::stdin().is_terminal() && std::io::stdout().is_terminal();
219 check_source(&src, on_a_tty)?;
220 return tui::run(src).map_err(Into::into);
221 }
222
223 tracing_subscriber::fmt()
224 .with_env_filter(
225 tracing_subscriber::EnvFilter::try_from_default_env()
226 .unwrap_or_else(|_| "mira=info,mira_core=info".into()),
227 )
228 // Colour only for a terminal. `with_ansi` defaults to on and does not
229 // check, so without this every field name in every line reaches a log
230 // file, a collector, or an agent wrapped in escape codes.
231 .with_ansi(std::io::stdout().is_terminal())
232 .init();
233 tokio::runtime::Builder::new_multi_thread()
234 .enable_all()
235 .build()?
236 .block_on(serve())
237}
238
239async fn serve() -> Result<(), Box<dyn std::error::Error>> {
240 let cfg = load().map_err(|e| -> Box<dyn std::error::Error> { e.into() })?;
241 serve_with(cfg, shutdown()).await
242}
243
244/// The server proper, with its stop edge passed in rather than taken from the
245/// process. A test can hold that edge; nothing else needs to.
246async fn serve_with(
247 cfg: Config,
248 stop_signal: impl std::future::Future<Output = ()>,
249) -> Result<(), Box<dyn std::error::Error>> {
250 // First, because a rules file that does not parse is a deployment that
251 // believes it is being paged and is not. Nothing has been created, bound or
252 // mapped at this point, so the failure is a message and an exit rather than
253 // a half-started node.
254 let rules = match &cfg.alerts {
255 Some(p) => alert::Rules::load(p)?,
256 None => alert::Rules::off(),
257 };
258
259 // Named, because `File exists (os error 17)` on its own sends whoever reads
260 // it looking for a bug in Mira rather than at the path they passed.
261 std::fs::create_dir_all(&cfg.data_dir).map_err(|e| {
262 format!(
263 "cannot create data directory {}: {e}. Mira makes this path on first \
264 start, so this is a parent that is not writable or something that is \
265 not a directory already sitting there (in Kubernetes: a subPath that \
266 names a file, or a volume mounted readOnly).",
267 cfg.data_dir.display()
268 )
269 })?;
270 // Before anything is mapped. A network mount is not a slow start, it is a
271 // SIGBUS the first time the server hiccups, and by then there is a process
272 // to explain rather than a flag to change.
273 mira_core::block::check_filesystem(&cfg.data_dir)?;
274 mira_core::block::check_writable(&cfg.data_dir)?;
275
276 let node = mira_core::block::node_id(&cfg.node);
277
278 // Both sockets are bound here, before a flusher starts and long before the
279 // line that says which addresses Mira is listening on. tonic binds inside
280 // its own future, so a port still held by the container that is shutting
281 // down used to surface as `mira: transport error` *after* the log line
282 // claiming the address — the most common way a start fails, reported as the
283 // least useful sentence Mira can print.
284 let taken = |addr: std::net::SocketAddr, what: &str, e: std::io::Error| {
285 format!(
286 "cannot bind {addr} for {what}: {e}. Nothing has started yet, so this \
287 is another process on the port — most often the previous instance \
288 still draining (in Kubernetes: a terminationGracePeriodSeconds \
289 shorter than the drain takes, or two replicas sharing a hostPort)."
290 )
291 };
292 let grpc_socket = tonic::transport::server::TcpIncoming::bind(cfg.grpc)
293 .map_err(|e| taken(cfg.grpc, "OTLP/gRPC", e))?
294 // `serve_with_incoming` ignores the builder's TCP settings, and tonic's
295 // default is nodelay on: without this every small export pays a Nagle
296 // delay that `serve` would not have charged it.
297 .with_nodelay(Some(true));
298 let http_socket = tokio::net::TcpListener::bind(cfg.http)
299 .await
300 .map_err(|e| taken(cfg.http, "OTLP/HTTP and the query API", e))?;
301 // The bound addresses, not the requested ones: `--grpc 127.0.0.1:0` is a
302 // real thing to ask for and the "listening" line is the only place the
303 // chosen port is ever written down.
304 let (grpc_addr, http_addr) = (grpc_socket.local_addr()?, http_socket.local_addr()?);
305
306 // Read before `cfg.data_dir` moves into the pipeline config, and before the
307 // uptime clock starts, so the "listening" line below can print what was
308 // actually resolved rather than what was asked for.
309 let data_dir = std::sync::Arc::new(cfg.data_dir.clone());
310 let _ = *START;
311 // Opened before the flushers, because they take a handle to it, and before
312 // anything is served, because replay has to reach the flushers ahead of the
313 // first live export or the sequences interleave.
314 let wal = match cfg.wal {
315 true => Some(std::sync::Arc::new(mira_core::wal::Wal::open(
316 &cfg.data_dir,
317 node,
318 )?)),
319 false => None,
320 };
321 // The node *name*, not the hashed `node` above: a series is labelled with
322 // what an operator typed, and the hash is a filename detail.
323 let node_name = cfg.node.clone();
324 let pcfg = std::sync::Arc::new(pipeline::Config {
325 data_dir: cfg.data_dir,
326 node,
327 retention: cfg.retention,
328 queue: cfg.queue,
329 shards: pipeline::shard_count(
330 cfg.shards,
331 std::thread::available_parallelism().map_or(1, |n| n.get()),
332 ),
333 wal: wal.clone(),
334 ..Default::default()
335 });
336 // One channel and block sequence per signal per shard, so a slow flush on
337 // one cannot stall another.
338 let (logs, o_logs, h_logs) = pipeline::spawn::<mira_core::logs::LogsBuilder>(&pcfg);
339 let (traces, o_traces, h_traces) = pipeline::spawn::<mira_core::traces::TracesBuilder>(&pcfg);
340 let (metrics, o_metrics, h_metrics) =
341 pipeline::spawn::<mira_core::metrics::MetricsBuilder>(&pcfg);
342 let flushers = [h_logs, h_traces, h_metrics];
343 // In `pipeline::SIGNALS` order, which is what `Api::open` indexes with.
344 let open_blocks = [o_logs, o_traces, o_metrics];
345 if wal.is_some() {
346 replay(
347 &pcfg.data_dir,
348 node,
349 logs.clone(),
350 traces.clone(),
351 metrics.clone(),
352 )
353 .await?;
354 }
355 // After the replay, so the first sample is of a node that has finished
356 // recovering rather than of one part-way through it.
357 //
358 // The handle is kept rather than detached, because the sampler holds an
359 // `Ingest` clone and a flusher only stops when the last one drops. Detached,
360 // it is the one holder that never lets go: every stop would sit out the full
361 // `DRAIN_GRACE` waiting for a channel that cannot close, and turning
362 // self-telemetry on would silently make each restart fifteen seconds slower.
363 let sampler = cfg.self_telemetry.then(|| {
364 tracing::info!(
365 interval_s = cfg.telemetry_interval.as_secs(),
366 "storing this node's own telemetry in this node"
367 );
368 tokio::spawn(telemetry::run(
369 node_name,
370 pcfg.data_dir.clone(),
371 cfg.telemetry_interval,
372 metrics.clone(),
373 ))
374 });
375 let recv = receiver::Receivers {
376 logs,
377 traces,
378 metrics,
379 max_request_bytes: cfg.max_request_bytes,
380 };
381 pipeline::spawn_retention(pcfg);
382
383 // One `watch` rather than two oneshots because both servers need the same
384 // edge and neither owns it.
385 let (stop, stop_rx) = tokio::sync::watch::channel(());
386 let stopped = |mut rx: tokio::sync::watch::Receiver<()>| async move {
387 let _ = rx.changed().await;
388 };
389
390 let grpc = tokio::spawn(
391 tonic::transport::Server::builder()
392 .add_service(recv.logs_server())
393 .add_service(recv.traces_server())
394 .add_service(recv.metrics_server())
395 .serve_with_incoming_shutdown(grpc_socket, stopped(stop_rx.clone())),
396 );
397
398 // One listener for all of it: `/v1/*` is OTLP in, `/api/v1/*` is query out
399 // and `/api/v1/stats` is this node describing itself, `/mcp` is the agent
400 // surface, `/health` and `/readyz` are the probes, `/` and `/{file}` are the
401 // UI. The UI's wildcard is one segment deep, so it cannot swallow any of the
402 // others.
403 let api = api::Api {
404 data_dir: std::sync::Arc::clone(&data_dir),
405 open: open_blocks,
406 alerts: std::sync::Arc::new(alert::Engine::new(rules)),
407 };
408 // Always routed, even with no rules: `/api/v1/alerts` answering `[]` is how
409 // the UI, the TUI and an agent learn that alerting is off, and a 404 is
410 // indistinguishable from an old build.
411 alert::spawn(api.clone());
412 let serve = axum::serve(
413 http_socket,
414 receiver::http_router(recv)
415 // Only the query router is timed. Layering the merged router would
416 // put OTLP exports and UI asset fetches in the same average, which
417 // is a latency number that means nothing.
418 .merge(api::router(api.clone()).layer(axum::middleware::from_fn(timed)))
419 .merge(mcp::router(api.clone()))
420 .merge(alert::router(api))
421 .merge(ops_router(std::sync::Arc::clone(&data_dir)))
422 .merge(ui::router()),
423 )
424 .with_graceful_shutdown(stopped(stop_rx));
425 // `IntoFuture`, not `Future`, so it cannot be spawned directly.
426 let http = tokio::spawn(async move { serve.await });
427
428 // One line, still one line, because it is what gets grepped out of a pod log
429 // and pasted into an issue. What it carries is every resolved value whose
430 // being wrong is silent: the node id, because it is the only externally
431 // visible thing that distinguishes two replicas' blocks and a collision is
432 // diagnosed here; the data directory, because a config that resolved
433 // `${env:MIRA_DATA}` to nothing writes a week of telemetry into `./data`
434 // inside a container and loses it at the next restart; the retention,
435 // because it is the one value whose mistake is irreversible — too short and
436 // the sweep has already deleted what it was going to delete by the time
437 // anyone reads this line; the request cap, because too small is a 413 the
438 // sender reports as Mira being broken. The UI URL is spelled out because
439 // `http://0.0.0.0:4318/` is not a thing anyone guesses from `http=0.0.0.0:4318`.
440 //
441 // `retention` is printed in the syntax `--retention` accepts, so the line
442 // round-trips: whatever it says can be pasted back in.
443 tracing::info!(
444 grpc = %grpc_addr, http = %http_addr,
445 ui = %format!("http://{http_addr}/"),
446 node = %cfg.node, node_id = format!("{node:08x}"),
447 data_dir = %data_dir.display(),
448 retention = %format!("{}s", cfg.retention.as_secs()),
449 max_request_bytes = cfg.max_request_bytes,
450 "mira listening"
451 );
452
453 let (mut grpc, mut http) = (grpc, http);
454 let mut flushers = flushers;
455 let mut wedged = false;
456 tokio::select! {
457 r = &mut grpc => r??,
458 r = &mut http => r??,
459 // A flusher cannot see its channel close while the listeners still hold
460 // an `Ingest`, so one that returns before the stop signal has failed and
461 // said why on its way out. Carrying on is what this used to do: the other
462 // two signals keep working, that one answers every export with a 503
463 // forever, and every probe stays green. A crashloop is the honest shape
464 // of "this process cannot store logs" — the orchestrator reports it, and
465 // the restart is the recovery for the case that caused it, a data
466 // directory that was not there yet.
467 _ = first_stopped(&mut flushers) => wedged = true,
468 _ = stop_signal => tracing::info!("draining"),
469 }
470
471 // Before the drain, and awaited so the cancellation has actually landed:
472 // dropping the sampler's `Ingest` clone is what lets the metrics flusher see
473 // its channel close. A self-sample lost to the abort is the least important
474 // row this process will ever not write.
475 if let Some(s) = sampler {
476 s.abort();
477 let _ = s.await;
478 }
479 drain(stop, grpc, http, flushers, DRAIN_GRACE).await;
480 tracing::info!("stopped");
481 if wedged {
482 // The other two signals were still drained above; only then is this
483 // process allowed to be a failed one.
484 return Err("a flusher stopped, so one signal can no longer be stored; \
485 exiting for the supervisor to restart (the cause is logged above)"
486 .into());
487 }
488 Ok(())
489}
490
491/// How long a stop is allowed to take before the process leaves without it.
492///
493/// Bounded below by `pipeline::Config::max_block_age`: step 1 of [`drain`] only
494/// makes progress once the block an in-flight export is waiting on seals, and
495/// with the listener closed nothing else will grow it. So this is a multiple of
496/// that constant, not a number chosen freely.
497const DRAIN_GRACE: std::time::Duration = std::time::Duration::from_secs(15);
498
499/// Stop accepting, then let everything already in flight land.
500///
501/// Three ordered steps, and the order is the whole point.
502///
503/// 1. Stop accepting, but let in-flight exports run to their acknowledgement.
504/// Skipping this is not a data-loss bug — the queued job is still sealed
505/// below — it is a *duplicate* bug: the exporter sees a reset, OTLP tells it
506/// to retry, and it re-sends data that did get stored. Every rolling restart
507/// would double-write whatever was in flight.
508/// 2. Await the servers, which drops the last `Ingest` clone per signal and so
509/// closes each flusher's channel.
510/// 3. A flusher answering a closed channel seals whatever is open, publishes it
511/// and acks the waiters (`pipeline::flusher`). Exiting before that lands is
512/// what turns step 1's ack into a reset after all.
513///
514/// ponytail: bounded by `grace` because a hung fsync must not outlive the
515/// orchestrator's grace period and become a SIGKILL with no explanation in the
516/// log. Whatever had not landed by then is lost, which is why the bound is a
517/// last resort and not a policy.
518async fn drain<G, H>(
519 stop: tokio::sync::watch::Sender<()>,
520 grpc: tokio::task::JoinHandle<G>,
521 http: tokio::task::JoinHandle<H>,
522 flushers: [pipeline::Flushers; 3],
523 grace: std::time::Duration,
524) {
525 let _ = stop.send(());
526 let landed = async move {
527 let _ = grpc.await;
528 let _ = http.await;
529 for h in flushers {
530 // Awaited unconditionally: `first_stopped` may already have run one
531 // of these to completion, and a drained set answers at once rather
532 // than panicking the way a twice-polled `JoinHandle` would.
533 let _ = h.await;
534 }
535 };
536 if tokio::time::timeout(grace, landed).await.is_err() {
537 tracing::warn!(?grace, "did not drain in time; exiting anyway");
538 }
539}
540
541/// Push everything the log holds that no block claims back through the
542/// flushers, before the listeners open.
543///
544/// Ordering is the reason this is not a background task. A replayed frame keeps
545/// the sequence it already has, and `Ingest::replay` puts that sequence back
546/// among the log's unpublished before it queues the frame, which is what stops
547/// a shard sealing beside it from computing a watermark over it — but that only
548/// covers frames already read off disk. A frame still sitting in a segment is
549/// in nobody's pending set, so a live export sealing first would claim a
550/// watermark past it and the next boot would not replay it. Draining the whole
551/// log before the first new export is framed is the blunt guarantee that this
552/// never happens. It is also the reason nobody is waiting: the client that sent
553/// this either got its answer before the crash or gave up long before this
554/// process started.
555///
556/// One `spawn_blocking` for the whole log, not one per frame. The channel is
557/// bounded at `ingest.queue` and the sends are blocking, so the flushers set the pace and
558/// a multi-gigabyte log is decoded at the rate it can be sealed rather than
559/// into memory all at once.
560async fn replay(
561 dir: &Path,
562 node: u32,
563 logs: pipeline::Ingest<mira_proto::collector::logs::v1::ExportLogsServiceRequest>,
564 traces: pipeline::Ingest<mira_proto::collector::trace::v1::ExportTraceServiceRequest>,
565 metrics: pipeline::Ingest<mira_proto::collector::metrics::v1::ExportMetricsServiceRequest>,
566) -> Result<(), Box<dyn std::error::Error>> {
567 use mira_core::wal::Signal;
568
569 let dir = dir.to_path_buf();
570 let started = std::time::Instant::now();
571 let done = tokio::task::spawn_blocking(move || {
572 let watermarks = mira_core::block::wal_watermarks(&dir)?;
573 let mut undecodable = 0u64;
574 let out = mira_core::wal::Wal::replay(&dir, node, watermarks, |signal, seq, body| {
575 let pushed = match signal {
576 Signal::Logs => logs.replay(body, seq),
577 Signal::Traces => traces.replay(body, seq),
578 Signal::Metrics => metrics.replay(body, seq),
579 };
580 match pushed {
581 Ok(()) | Err(pipeline::Rejected::Failed(_)) => {
582 // A frame that passed its checksum and then would not decode
583 // is one export, and it is already unrecoverable — stopping
584 // the boot over it would lose every frame behind it too.
585 undecodable += u64::from(pushed.is_err());
586 Ok(())
587 }
588 // The flusher is gone, so nothing after this would land either.
589 Err(_) => Err(mira_core::Error::WalCorrupt {
590 path: dir.clone(),
591 why: "the flusher for this signal stopped during replay",
592 }),
593 }
594 })?;
595 Ok::<_, mira_core::Error>((out, undecodable))
596 })
597 .await??;
598
599 let (out, undecodable) = done;
600 if undecodable > 0 {
601 tracing::error!(
602 frames = undecodable,
603 "write-ahead log frames passed their checksum and would not decode as OTLP; \
604 those exports are gone"
605 );
606 }
607 if out.torn_segments > 0 {
608 // Expected after a hard kill, and only after one. Info, not warn: the
609 // torn frame is the export that was mid-write when the process died,
610 // which the sender never got an acknowledgement for and has retried.
611 tracing::info!(
612 segments = out.torn_segments,
613 "write-ahead log segments ended in a torn frame; that is what a crash looks like"
614 );
615 }
616 if out.replayed > 0 || out.skipped > 0 {
617 tracing::info!(
618 replayed = out.replayed,
619 skipped = out.skipped,
620 bytes = out.bytes,
621 elapsed_ms = started.elapsed().as_millis() as u64,
622 "recovered from the write-ahead log"
623 );
624 }
625 Ok(())
626}
627
628/// Resolve as soon as any one flusher has returned.
629async fn first_stopped(flushers: &mut [pipeline::Flushers; 3]) {
630 let [logs, traces, metrics] = flushers;
631 tokio::select! {
632 _ = logs => {}
633 _ = traces => {}
634 _ = metrics => {}
635 }
636}
637
638/// The three endpoints that answer for the process rather than for the data:
639/// liveness, readiness, and everything this node counts about itself.
640///
641/// Here rather than in `api.rs` because `/health` and `/readyz` must not touch
642/// the block directory — no query engine, nothing that can be slow, nothing that
643/// can fail for a reason that is not the process's fault. `/api/v1/stats` does
644/// open the directory, which is why it is a query-namespace URL and not a probe.
645///
646/// `/health` and `/readyz` used to be the same handler, on the argument that
647/// Mira has no warm-up and no cluster to join, so there is no state in which it
648/// is alive and not ready. A node whose disk is full is exactly that state: the
649/// process is fine, answers every request, and cannot store a byte. That is the
650/// state readiness exists for — it is what takes the node out of the Service's
651/// endpoints so the exporters retry somewhere that can — so the two are now
652/// different answers. Liveness stays a constant 200: a flusher that stops takes
653/// the process with it (`serve_with`), so answering at all *is* the liveness
654/// answer, and restarting a node whose volume is full fixes nothing.
655fn ops_router(data_dir: std::sync::Arc<PathBuf>) -> Router {
656 Router::new()
657 .route("/health", get(health))
658 .route("/readyz", get(readyz))
659 .route("/api/v1/stats", get(stats))
660 .with_state(data_dir)
661}
662
663/// Liveness, plus what the ingest path has refused so far.
664async fn health() -> Response {
665 // A flusher that stops takes the process with it (see `serve_with`), so
666 // answering at all is the liveness answer. The counters are here so that the
667 // probe and the log agree about how much has been refused, and because a
668 // shedding node is the case where an operator is looking at exactly this.
669 let mut j = mira_core::json::Json::new();
670 j.obj(|j| {
671 j.key("status");
672 j.str("ok");
673 for r in &pipeline::REJECTS {
674 j.key(r.signal);
675 j.obj(|j| {
676 j.key("shed");
677 j.u64(r.shed.load(Relaxed));
678 j.key("failed");
679 j.u64(r.failed.load(Relaxed));
680 });
681 }
682 });
683 json_ok(j.into_string())
684}
685
686/// Readiness: 200 while exports can be made durable, 503 once they cannot.
687async fn readyz() -> Response {
688 ready(pipeline::stalled())
689}
690
691/// Readiness is one question: can this instance accept an export and make it
692/// durable? Everything else about the process is `/health`'s.
693///
694/// Split from the handler so both answers are testable without a disk that has
695/// actually filled up — and so the only input is the one fact the decision turns
696/// on. The threshold itself is `pipeline::UNREADY_AFTER`, and its whole job is to
697/// make this a *sustained* condition: a probe that flips on one failed publish
698/// would deregister a node for every EIO and every restart of the volume
699/// underneath it, and an endpoint list that changes every ten seconds costs more
700/// exports than the node it was protecting.
701fn ready(stalled: Option<(&'static str, u64)>) -> Response {
702 let mut j = mira_core::json::Json::new();
703 j.obj(|j| match stalled {
704 None => {
705 j.key("status");
706 j.str("ok");
707 }
708 Some((signal, secs)) => {
709 j.key("status");
710 j.str("unavailable");
711 j.key("signal");
712 j.str(signal);
713 j.key("stalled_s");
714 j.u64(secs);
715 j.key("reason");
716 j.str(
717 "this node has not been able to store an export for this signal; \
718 the usual cause is a full or unwritable volume",
719 );
720 }
721 });
722 let code = match stalled {
723 None => axum::http::StatusCode::OK,
724 Some(_) => axum::http::StatusCode::SERVICE_UNAVAILABLE,
725 };
726 (
727 code,
728 [(axum::http::header::CONTENT_TYPE, "application/json")],
729 j.into_string(),
730 )
731 .into_response()
732}
733
734/// Queries served and what they cost, since start.
735///
736/// Sum and max rather than a histogram: a p99 needs buckets, buckets need a
737/// registry, and a registry is the metrics subsystem this endpoint exists to not
738/// be. The mean says whether the engine is in the shape it was benchmarked in
739/// and the max says whether anything pathological has run at all, which is the
740/// pair an operator acts on; a real p99 is measured at the caller, where the
741/// queue in front of Mira is also counted.
742static QUERIES: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
743static QUERY_NANOS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
744static QUERY_MAX_NANOS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
745
746/// When the process started, so the counters below have a denominator. Forced in
747/// `serve_with` rather than left to first touch, or "uptime" would mean "seconds
748/// since someone first asked".
749static START: std::sync::LazyLock<std::time::Instant> =
750 std::sync::LazyLock::new(std::time::Instant::now);
751
752/// Wrapped around the query router only, so OTLP writes and UI asset fetches do
753/// not land in the read latency an operator is reading.
754async fn timed(req: axum::extract::Request, next: axum::middleware::Next) -> Response {
755 let t = std::time::Instant::now();
756 let res = next.run(req).await;
757 let ns = t.elapsed().as_nanos() as u64;
758 QUERIES.fetch_add(1, Relaxed);
759 QUERY_NANOS.fetch_add(ns, Relaxed);
760 QUERY_MAX_NANOS.fetch_max(ns, Relaxed);
761 res
762}
763
764/// Everything this node knows about itself, in one document.
765///
766/// The shape is the argument. Mira is a telemetry backend, so the wrong move is
767/// to grow a second one inside it: no exposition format, no registry, no
768/// histograms, no scrape endpoint on a second port and no crate to render any of
769/// that. What is served is the counters the ingest path already keeps, plus the
770/// two facts only the filesystem has, in the same JSON object every `/api/v1`
771/// read already answers with — so the UI's `fetch`, an agent and `curl | jq` all
772/// speak it without being told. Rates are left to the reader: `uptime_s` is the
773/// denominator, and two polls give the interval rate, which is the only rate
774/// that is true of *now* rather than of the whole run.
775async fn stats(
776 axum::extract::State(dir): axum::extract::State<std::sync::Arc<PathBuf>>,
777) -> Response {
778 // `scan` and `statfs` are filesystem work, and a cold readdir over a week of
779 // blocks stalls the OS thread it lands on exactly like a query does. So it
780 // goes where queries go.
781 let disk = tokio::task::spawn_blocking(move || {
782 (
783 mira_core::block::free_fraction(&dir).ok(),
784 pipeline::SIGNALS.map(|s| mira_core::block::scan(&dir, s).ok().map(|b| b.len() as u64)),
785 )
786 })
787 .await;
788 // `null`, not zero, when the filesystem would not answer: "no blocks" and "I
789 // could not look" are different operational facts and a zero conflates them.
790 let (free, on_disk) = disk.unwrap_or((None, [None; 3]));
791
792 let now = std::time::SystemTime::now()
793 .duration_since(std::time::UNIX_EPOCH)
794 .unwrap_or_default()
795 .as_secs();
796 // `then_some` and not `then`: one saturating subtraction is cheaper to do
797 // than to defer behind a closure, and that closure is a body only a signal
798 // with a block already open would ever enter.
799 let age = |since: u64| (since != 0).then_some(now.saturating_sub(since));
800 let queries = QUERIES.load(Relaxed);
801
802 let mut j = mira_core::json::Json::new();
803 j.obj(|j| {
804 j.key("uptime_s");
805 j.u64(START.elapsed().as_secs());
806 j.key("peak_rss_bytes");
807 j.u64(peak_rss());
808 j.key("free_fraction");
809 match free {
810 Some(f) => j.f64(f),
811 None => j.null(),
812 }
813 // Zero on any volume that implements write barriers. Non-zero says this
814 // one does not, and that the durability promise here is `fsync`'s
815 // rather than `F_FULLFSYNC`'s — see `mira_core::sync_all`.
816 j.key("degraded_syncs");
817 j.u64(mira_core::degraded_syncs());
818 j.key("queries");
819 j.obj(|j| {
820 j.key("count");
821 j.u64(queries);
822 j.key("mean_ms");
823 j.f64(QUERY_NANOS.load(Relaxed) as f64 / queries.max(1) as f64 / 1e6);
824 j.key("max_ms");
825 j.f64(QUERY_MAX_NANOS.load(Relaxed) as f64 / 1e6);
826 });
827 j.key("signals");
828 j.obj(|j| {
829 for (r, blocks) in pipeline::REJECTS.iter().zip(on_disk) {
830 j.key(r.signal);
831 j.obj(|j| {
832 for (k, v) in [
833 ("shed", r.shed.load(Relaxed)),
834 ("failed", r.failed.load(Relaxed)),
835 ("refused", r.refused.load(Relaxed)),
836 ("blocks_published", r.published.load(Relaxed)),
837 ("rows", r.rows.load(Relaxed)),
838 ("bytes", r.bytes.load(Relaxed)),
839 ] {
840 j.key(k);
841 j.u64(v);
842 }
843 // Absent-as-null again: nothing open, and never stalled, are
844 // both "no age to report" rather than "an age of zero".
845 for (k, v) in [
846 ("blocks_on_disk", blocks),
847 ("open_block_age_s", age(r.open_since.load(Relaxed))),
848 ("stalled_s", age(r.stalled_since.load(Relaxed))),
849 ] {
850 j.key(k);
851 match v {
852 Some(v) => j.u64(v),
853 None => j.null(),
854 }
855 }
856 });
857 }
858 });
859 });
860 json_ok(j.into_string())
861}
862
863/// High-water mark of this process's resident set, in bytes.
864///
865/// Resident footprint is one of the four axes performance is scored on (section 11),
866/// so a node that cannot report it can only be measured from outside with a
867/// sampler — which is how the 64-connection run in the last sweep came back
868/// with an implausible 26 MiB: `ps` simply missed the peak. `getrusage` cannot
869/// miss it, because the kernel keeps the maximum rather than the instant.
870///
871/// The peak and not the current value because the peak is the number that has
872/// to fit in the container's limit, and because it is one portable call:
873/// `ru_maxrss` is bytes on macOS and kibibytes on Linux, which is the whole of
874/// the platform difference and is why this is not inlined at the call site.
875fn peak_rss() -> u64 {
876 // SAFETY: `getrusage` writes the whole struct or returns -1; the zeroed
877 // value is a valid `rusage` either way.
878 let mut ru: libc::rusage = unsafe { std::mem::zeroed() };
879 // The return code is not checked, because the zeroed struct is already the
880 // answer if it failed: `getrusage(RUSAGE_SELF)` documents only EFAULT and
881 // EINVAL, both of which are this call site being wrong rather than anything
882 // that can happen at runtime, and a `ru_maxrss` the kernel never touched
883 // reports 0 — which is what a "cannot measure" branch would have returned.
884 //
885 // SAFETY: `ru` is a live, correctly-typed `rusage` the kernel may write in
886 // full; there is no other precondition on `RUSAGE_SELF`.
887 unsafe { libc::getrusage(libc::RUSAGE_SELF, &mut ru) };
888 // `#[cfg]` and not `cfg!`, which compiles both arms and runs one: the arm
889 // for the platform this is not built for can never execute, so under `cfg!`
890 // it is a line no test can ever reach.
891 #[cfg(target_os = "macos")]
892 const UNIT: u64 = 1;
893 #[cfg(not(target_os = "macos"))]
894 const UNIT: u64 = 1024;
895 ru.ru_maxrss.max(0) as u64 * UNIT
896}
897
898fn json_ok(body: String) -> Response {
899 (
900 [(axum::http::header::CONTENT_TYPE, "application/json")],
901 body,
902 )
903 .into_response()
904}
905
906/// Resolve on the first stop signal.
907///
908/// SIGTERM matters as much as SIGINT here: it is what every container
909/// orchestrator sends, so without this arm a rolling restart kills the process
910/// mid-block and the exporters waiting on that block see a reset.
911///
912/// Two `#[cfg]` bodies and not one with a `cfg!` in it: `cfg!` compiles both,
913/// so the arm for the platform this is not built for is a line no test on this
914/// platform can ever reach.
915#[cfg(unix)]
916async fn shutdown() {
917 // `expect`, rather than falling back to ^C alone. `signal` fails only for a
918 // signal that cannot be caught and on a runtime with no signal driver —
919 // both of them this call site being wrong — and a silent fall-back to ^C is
920 // a node in a container that nothing but SIGKILL can stop, which is exactly
921 // the failure this function exists to prevent, reported nowhere.
922 let mut term = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
923 .expect("a SIGTERM handler on the serving runtime");
924 tokio::select! {
925 _ = tokio::signal::ctrl_c() => {}
926 _ = term.recv() => {}
927 }
928}
929
930/// ^C only: there is no SIGTERM to wait for.
931#[cfg(not(unix))]
932async fn shutdown() {
933 let _ = tokio::signal::ctrl_c().await;
934}
935
936#[cfg(test)]
937mod tests {
938 use super::*;
939
940 fn argv(s: &str) -> Vec<String> {
941 s.split_whitespace().map(str::to_owned).collect()
942 }
943
944 fn tmp(name: &str) -> PathBuf {
945 let d = std::env::temp_dir().join(format!("mira-{name}-{}", std::process::id()));
946 let _ = std::fs::remove_dir_all(&d);
947 std::fs::create_dir_all(&d).unwrap();
948 d
949 }
950
951 /// Both stop edges the tests below drive `serve_with` with, as one type.
952 ///
953 /// `serve_with` is generic over this future, so `ready(())` in one test and
954 /// `pending()` in the next compile two whole servers, each running only the
955 /// half its own callers reach — including under a coverage run, which counts
956 /// each copy separately and so reports lines as unrun that another copy of
957 /// the same source ran.
958 async fn stop_edge(immediately: bool) {
959 if !immediately {
960 std::future::pending::<()>().await;
961 }
962 }
963
964 /// Flag > file > default, and every flag lands in the field it names.
965 ///
966 /// This parser is hand-rolled, and the failure it can produce is the quiet
967 /// kind: a flag written into the wrong field starts a server that looks
968 /// exactly right until someone reads a block from the wrong directory.
969 #[test]
970 fn flags_override_the_file_which_overrides_the_defaults() {
971 let dir = tmp("load");
972 let file = dir.join("mira.yaml");
973 std::fs::write(
974 &file,
975 r#"{ "node": "from-file",
976 "listen": { "grpc": "127.0.0.1:1", "http": "127.0.0.1:2" },
977 "storage": { "dir": "/from/file", "retention": "3h" },
978 "ingest": { "max_request_bytes": "1MiB" } }"#,
979 )
980 .unwrap();
981 let f = file.display();
982
983 let c = load_from(argv(&format!("--config {f}"))).unwrap();
984 assert_eq!(c.node, "from-file");
985 assert_eq!(c.data_dir, PathBuf::from("/from/file"));
986 assert_eq!(c.retention, std::time::Duration::from_secs(3 * 3600));
987 assert_eq!(c.max_request_bytes, 1 << 20);
988 assert_eq!(c.http.port(), 2);
989
990 // The same file, every value overridden. `--config` is seen twice — once
991 // to find the file and once by the loop, which must consume its value
992 // rather than read it as a flag.
993 let c = load_from(argv(&format!(
994 "--config {f} --node cli --grpc 127.0.0.1:3 --http 127.0.0.1:4 \
995 --data-dir /from/cli --retention 30s --max-request-bytes 2MiB"
996 )))
997 .unwrap();
998 assert_eq!(c.node, "cli");
999 assert_eq!(c.grpc.port(), 3);
1000 assert_eq!(c.http.port(), 4);
1001 assert_eq!(c.data_dir, PathBuf::from("/from/cli"));
1002 assert_eq!(c.retention, std::time::Duration::from_secs(30));
1003 assert_eq!(c.max_request_bytes, 2 << 20);
1004
1005 // The three that take no value and the two that were added with them.
1006 // `--self-telemetry` is here rather than above because a boolean flag
1007 // that silently swallowed the next argument would still pass every
1008 // assertion in that block.
1009 let c = load_from(argv(
1010 "--queue 4096 --self-telemetry --telemetry-interval 1m --wal",
1011 ))
1012 .unwrap();
1013 assert_eq!(c.queue, 4096);
1014 assert!(c.self_telemetry);
1015 assert!(c.wal);
1016 assert_eq!(c.telemetry_interval, std::time::Duration::from_secs(60));
1017
1018 // No arguments at all is the shipped configuration.
1019 let d = load_from(vec![]).unwrap();
1020 assert_eq!(d.node, Config::default().node);
1021 assert!(!d.self_telemetry, "self-telemetry is opt-in");
1022
1023 for (args, want) in [
1024 ("--nope", "unknown flag --nope"),
1025 // Deleted along with the fan-out that never existed. It is an
1026 // unknown flag now, which is the whole point of deleting it.
1027 ("--peers a:1", "unknown flag --peers"),
1028 ("--node", "--node needs a value"),
1029 ("--config", "--config needs a value"),
1030 ("--grpc nope", "--grpc:"),
1031 ("--http nope", "--http:"),
1032 ("--retention nope", "not a duration"),
1033 ("--max-request-bytes nope", "not a size"),
1034 ("--queue nope", "not a whole number"),
1035 ("--queue 0", "at least 1"),
1036 // `--shards 0` is *not* here: zero is the documented auto value.
1037 // A negative is rejected rather than wrapped — `usize::from_str`
1038 // has no sign to lose, so `-1` cannot arrive as 18 quintillion
1039 // shards that `clamp` then silently turns into 16.
1040 ("--shards nope", "not a whole number"),
1041 ("--shards -1", "not a whole number"),
1042 ("--telemetry-interval nope", "not a duration"),
1043 ("--config /no/such/file.yaml", "/no/such/file.yaml"),
1044 ] {
1045 let e = load_from(argv(args)).unwrap_err();
1046 assert!(e.contains(want), "{args:?} said {e:?}");
1047 }
1048 let _ = std::fs::remove_dir_all(&dir);
1049 }
1050
1051 /// `mira mira` reads the directory the matching `mira serve` would write to,
1052 /// which is the whole reason it takes `--config` at all.
1053 #[test]
1054 fn the_tui_reads_the_directory_its_config_writes_to() {
1055 let dir = tmp("tui-src");
1056 let file = dir.join("mira.yaml");
1057 std::fs::write(&file, r#"{ "storage": { "dir": "/from/file" } }"#).unwrap();
1058 let f = file.display();
1059
1060 // Rendered rather than matched, so a wrong *variant* is a diff on the
1061 // left of the assertion instead of a panic in a `let ... else`: which
1062 // of the three answers came back is exactly what is under test.
1063 let src = |a: &str| match tui_source(&argv(a)) {
1064 Ok(tui::Source::Local(p)) => format!("local {}", p.display()),
1065 Ok(tui::Source::Remote(a)) => format!("remote {a}"),
1066 // First line only: a flag error carries the whole usage text after
1067 // a blank line, which is `USAGE`'s contract to assert, not this
1068 // one's.
1069 Err(e) => format!("error {}", e.lines().next().unwrap_or_default()),
1070 };
1071 let default_dir = Config::default().data_dir;
1072 for (args, want) in [
1073 (format!("--config {f}"), "local /from/file".to_owned()),
1074 (
1075 format!("--config {f} --data-dir /from/cli"),
1076 "local /from/cli".to_owned(),
1077 ),
1078 (String::new(), format!("local {}", default_dir.display())),
1079 // `--addr` wins outright: a remote source has no directory to read.
1080 ("--addr host:9999".into(), "remote host:9999".to_owned()),
1081 ("--nope".into(), "error unknown flag --nope".to_owned()),
1082 (
1083 "--data-dir".into(),
1084 "error --data-dir needs a value".to_owned(),
1085 ),
1086 ("--config".into(), "error --config needs a value".to_owned()),
1087 ] {
1088 assert_eq!(src(&args), want, "{args:?}");
1089 }
1090 let _ = std::fs::remove_dir_all(&dir);
1091 }
1092
1093 /// Start everything, then stop it.
1094 ///
1095 /// The assertion is that this returns at all. Shutdown is three ordered
1096 /// steps — stop accepting, await the servers so the last `Ingest` clone
1097 /// drops, then let each flusher seal what it holds — and if any link in that
1098 /// chain is wrong the drain never completes and the 15s timeout fires. It
1099 /// also proves the two listeners and the retention worker start from a
1100 /// `Config` alone, which is the one thing `e2e.rs` cannot say: it builds the
1101 /// router itself — and that the one line it logs on the way up carries what
1102 /// was resolved rather than what was asked for.
1103 #[tokio::test]
1104 async fn the_server_starts_from_a_config_and_drains_when_stopped() {
1105 let dir = tmp("serve");
1106 let cfg = Config {
1107 data_dir: dir.join("data"),
1108 grpc: "127.0.0.1:0".parse().unwrap(),
1109 http: "127.0.0.1:0".parse().unwrap(),
1110 // On, with an interval no test run reaches: this asserts that the
1111 // sampler is started and announced, not what it samples — that is
1112 // `telemetry`'s own test, and a 15-second default here would make
1113 // this one's output depend on how slow the machine is. It also makes
1114 // the elapsed-time assertion below a regression test for the sampler
1115 // holding the metrics flusher open: detached, this stop took exactly
1116 // `DRAIN_GRACE` every time.
1117 self_telemetry: true,
1118 telemetry_interval: std::time::Duration::from_secs(3600),
1119 ..Config::default()
1120 };
1121
1122 // A file, because `File` is a `MakeWriter` and writes to one land without
1123 // a flush — a buffer would need a type of its own here.
1124 let log = dir.join("start.log");
1125 // Thread-local, so it holds for this test alone — and `#[tokio::test]`
1126 // is a current-thread runtime, so it holds for everything the server
1127 // does on it too.
1128 let _logging = tracing::subscriber::set_default(
1129 tracing_subscriber::fmt()
1130 .with_writer(std::fs::File::create(&log).unwrap())
1131 .without_time()
1132 // Or every field is wrapped in the escapes that make it readable
1133 // on a terminal, which this is not.
1134 .with_ansi(false)
1135 .finish(),
1136 );
1137
1138 let t = std::time::Instant::now();
1139 serve_with(cfg.clone(), stop_edge(true)).await.unwrap();
1140 assert!(
1141 t.elapsed() < std::time::Duration::from_secs(15),
1142 "timed out"
1143 );
1144 // Created, not required to exist: an operator points `--data-dir` at a
1145 // path and expects the first start to make it.
1146 assert!(cfg.data_dir.is_dir());
1147
1148 // `--http 127.0.0.1:0` is a real thing to ask for, and this line is the
1149 // only place the port that was actually chosen is ever written down. It
1150 // used to be logged from the requested address, which reads as correct
1151 // and sends whoever pastes it at a closed port.
1152 let logged = std::fs::read_to_string(&log).unwrap();
1153 assert!(logged.contains("mira listening"), "{logged}");
1154 // No bound port is spelled `0`, in any of the three fields that carry one.
1155 assert!(!logged.contains("127.0.0.1:0"), "{logged}");
1156 assert!(logged.contains("ui=http://127.0.0.1:"), "{logged}");
1157 assert!(
1158 logged.contains("storing this node's own telemetry"),
1159 "{logged}"
1160 );
1161 // In the syntax `--retention` accepts, so the line round-trips.
1162 assert!(
1163 logged.contains(&format!("retention={}s", cfg.retention.as_secs())),
1164 "{logged}"
1165 );
1166
1167 // A port already taken is reported, not survived, and the message names
1168 // the address — on both listeners. The gRPC half is the one that used to
1169 // print `transport error` *after* logging that it was listening on it.
1170 let held = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1171 let addr = held.local_addr().unwrap();
1172 for taken in [
1173 Config {
1174 http: addr,
1175 ..cfg.clone()
1176 },
1177 Config {
1178 grpc: addr,
1179 ..cfg.clone()
1180 },
1181 ] {
1182 let e = serve_with(taken, stop_edge(false))
1183 .await
1184 .unwrap_err()
1185 .to_string();
1186 assert!(e.contains(&addr.to_string()), "{e}");
1187 assert!(e.to_lowercase().contains("address"), "{e}");
1188 }
1189
1190 // A data directory that cannot be made says which path and why, rather
1191 // than `File exists (os error 17)`.
1192 let file = dir.join("a-file");
1193 std::fs::write(&file, b"").unwrap();
1194 let e = serve_with(
1195 Config {
1196 data_dir: file.clone(),
1197 ..cfg
1198 },
1199 stop_edge(false),
1200 )
1201 .await
1202 .unwrap_err()
1203 .to_string();
1204 assert!(e.contains(&file.display().to_string()), "{e}");
1205 let _ = std::fs::remove_dir_all(&dir);
1206 }
1207
1208 /// The two things `serve_with` does before anything is bound or mapped, and
1209 /// the order they are in.
1210 ///
1211 /// A rules file is read first *because* nothing has been created yet: a
1212 /// deployment that believes it is being paged and is not is the worst
1213 /// possible half-start, so it has to be a message and an exit rather than a
1214 /// node that is up with alerting quietly off. The log directory is opened
1215 /// next, and a failure there has to name the path — `File exists` on its
1216 /// own sends whoever reads it looking for a bug in Mira.
1217 #[tokio::test]
1218 async fn a_node_refuses_to_start_on_a_rules_file_or_a_log_it_cannot_open() {
1219 let dir = tmp("boot-guards");
1220 let rules = dir.join("alerts.kyaml");
1221 let cfg = |alerts: Option<PathBuf>, data_dir: PathBuf| Config {
1222 data_dir,
1223 grpc: "127.0.0.1:0".parse().unwrap(),
1224 http: "127.0.0.1:0".parse().unwrap(),
1225 alerts,
1226 ..Config::default()
1227 };
1228
1229 // A rules file that parses is loaded and the node comes up with it.
1230 std::fs::write(
1231 &rules,
1232 r#"{ "rules": [ { "name": "any-log", "over": "1m", "when": "count >= 1",
1233 "query": { "signal": "logs" } } ] }"#,
1234 )
1235 .unwrap();
1236 serve_with(
1237 cfg(Some(rules.clone()), dir.join("ok")),
1238 std::future::ready(()),
1239 )
1240 .await
1241 .expect("a node with rules starts");
1242
1243 // One that does not is a refusal naming the file, before the data
1244 // directory it was given has been created.
1245 std::fs::write(&rules, "{ rules: nope }").unwrap();
1246 let never_made = dir.join("not-made");
1247 let e = serve_with(
1248 cfg(Some(rules.clone()), never_made.clone()),
1249 std::future::pending(),
1250 )
1251 .await
1252 .unwrap_err()
1253 .to_string();
1254 assert!(e.contains("alerts.kyaml"), "{e}");
1255 assert!(!never_made.exists(), "the boot got past the rules file");
1256
1257 // `.wal` occupied by a regular file: `create_dir_all` cannot make the
1258 // log directory, and an unopenable log is a start that would have
1259 // acknowledged exports it could not recover.
1260 let wal_blocked = dir.join("wal-blocked");
1261 std::fs::create_dir_all(&wal_blocked).unwrap();
1262 std::fs::write(wal_blocked.join(".wal"), b"not a directory").unwrap();
1263 let cfg = Config {
1264 wal: true,
1265 ..cfg(None, wal_blocked.clone())
1266 };
1267 let e = serve_with(cfg, std::future::pending())
1268 .await
1269 .unwrap_err()
1270 .to_string();
1271 assert!(e.contains(".wal"), "{e}");
1272 let _ = std::fs::remove_dir_all(&dir);
1273 }
1274
1275 /// A flusher that cannot start takes the process down with it.
1276 ///
1277 /// The alternative is the failure nobody notices: two signals keep working,
1278 /// the third answers every export with a 503 until someone restarts it, and
1279 /// the process stays up and green throughout. Here `logs` is a regular file,
1280 /// so the logs flusher cannot scan its directory and returns immediately.
1281 #[tokio::test]
1282 async fn a_flusher_that_cannot_start_stops_the_server() {
1283 let dir = tmp("wedged");
1284 std::fs::write(dir.join("logs"), b"not a directory").unwrap();
1285 let cfg = Config {
1286 data_dir: dir.clone(),
1287 grpc: "127.0.0.1:0".parse().unwrap(),
1288 http: "127.0.0.1:0".parse().unwrap(),
1289 // Off, or the replay reaches the same broken directory first and
1290 // this stops being a test about the flusher. That boot order is
1291 // itself correct — a log that cannot be read is a worse thing to
1292 // start over than a directory that cannot be scanned — but it is
1293 // not what is being asserted here.
1294 wal: false,
1295 ..Config::default()
1296 };
1297 // `pending`, so the only thing that can end this is the flusher.
1298 let e = serve_with(cfg, stop_edge(false))
1299 .await
1300 .unwrap_err()
1301 .to_string();
1302 assert!(e.contains("flusher"), "{e}");
1303 let _ = std::fs::remove_dir_all(&dir);
1304 }
1305
1306 /// [`drain`]'s last resort. The happy path is
1307 /// `the_server_starts_from_a_config_and_drains_when_stopped`; this is the
1308 /// branch that one can never take, because there the flushers do land.
1309 /// A real `grace` rather than a paused clock: `tokio`'s `test-util` is a
1310 /// feature the workspace does not carry, and a millisecond costs less than
1311 /// carrying it.
1312 #[tokio::test]
1313 async fn a_drain_that_never_lands_leaves_anyway() {
1314 let (stop, rx) = tokio::sync::watch::channel(());
1315 let wedged = || tokio::spawn(std::future::pending::<()>());
1316 drain(
1317 stop,
1318 wedged(),
1319 wedged(),
1320 std::array::from_fn(|_| pipeline::Flushers::wedged()),
1321 std::time::Duration::from_millis(1),
1322 )
1323 .await;
1324 // It returned, which is the assertion — an unbounded `drain` would still
1325 // be awaiting the first handle. The listeners were told to stop before
1326 // the wait began, so a caller that gave up still stopped accepting.
1327 assert!(
1328 rx.has_changed().is_err(),
1329 "drain owns the sender to the end"
1330 );
1331 }
1332
1333 async fn text(r: Response) -> (axum::http::StatusCode, String) {
1334 let (parts, body) = r.into_parts();
1335 let body = axum::body::to_bytes(body, 1 << 20).await.unwrap();
1336 (parts.status, String::from_utf8(body.to_vec()).unwrap())
1337 }
1338
1339 /// The probe answers, and it answers with the numbers an operator wants
1340 /// while ingest is unhappy — the same ones the warn! lines count.
1341 #[tokio::test]
1342 async fn health_reports_every_signals_rejections() {
1343 let (status, body) = text(health().await).await;
1344 assert_eq!(status, axum::http::StatusCode::OK);
1345 assert!(body.starts_with(r#"{"status":"ok""#), "{body}");
1346 for signal in pipeline::SIGNALS {
1347 assert!(body.contains(&format!(r#""{signal}":{{"shed":"#)), "{body}");
1348 }
1349 }
1350
1351 /// Readiness is no longer liveness under a second name.
1352 ///
1353 /// The state that broke the old argument is a node that answers every
1354 /// request and cannot store a byte: it stayed 200 and stayed in the
1355 /// Service's endpoints while NACKing 100% of exports. A 503 is what moves
1356 /// that traffic to a replica that can take it, and the body says which
1357 /// signal and for how long so the reason is in the probe's own log.
1358 #[tokio::test]
1359 async fn readiness_fails_only_once_a_signal_has_been_unable_to_store() {
1360 let (status, body) = text(ready(None)).await;
1361 assert_eq!(status, axum::http::StatusCode::OK);
1362 assert_eq!(body, r#"{"status":"ok"}"#);
1363
1364 let (status, body) = text(ready(Some(("logs", 300)))).await;
1365 assert_eq!(status, axum::http::StatusCode::SERVICE_UNAVAILABLE);
1366 assert!(body.contains(r#""signal":"logs""#), "{body}");
1367 assert!(body.contains(r#""stalled_s":300"#), "{body}");
1368
1369 // The live wiring, which is healthy in a test binary: this is the only
1370 // thing that proves `/readyz` is not still `/health`'s handler.
1371 let (status, _) = text(readyz().await).await;
1372 assert_eq!(status, axum::http::StatusCode::OK);
1373 }
1374
1375 /// The self-telemetry an agent or an operator reads instead of six numbers
1376 /// and a log stream. Every field is asserted by name because the shape is
1377 /// the API: this is what a dashboard and an MCP client bind to.
1378 #[tokio::test]
1379 async fn stats_reports_what_this_node_is_doing_with_its_disk() {
1380 let dir = tmp("stats");
1381 let (status, body) =
1382 text(stats(axum::extract::State(std::sync::Arc::new(dir.clone()))).await).await;
1383 assert_eq!(status, axum::http::StatusCode::OK);
1384 for key in [
1385 r#""uptime_s":"#,
1386 r#""free_fraction":"#,
1387 r#""queries":{"count":"#,
1388 r#""mean_ms":"#,
1389 r#""max_ms":"#,
1390 ] {
1391 assert!(body.contains(key), "{key} missing from {body}");
1392 }
1393 for signal in pipeline::SIGNALS {
1394 assert!(body.contains(&format!(r#""{signal}":{{"shed":"#)), "{body}");
1395 }
1396 for key in [
1397 "refused",
1398 "blocks_published",
1399 "rows",
1400 "bytes",
1401 "blocks_on_disk",
1402 "open_block_age_s",
1403 "stalled_s",
1404 ] {
1405 assert!(body.contains(&format!(r#""{key}":"#)), "{key}: {body}");
1406 }
1407 // An empty directory is zero blocks, not an unreadable one, and nothing
1408 // is open in a process with no flusher: `null` says so without a zero
1409 // that would read as a real measurement.
1410 assert!(body.contains(r#""blocks_on_disk":0"#), "{body}");
1411 assert!(body.contains(r#""open_block_age_s":null"#), "{body}");
1412 // A fraction, not a byte count and not a percentage — read as the
1413 // number it is rather than as the text it happened to render to, or a
1414 // volume with everything free (`"free_fraction":1`, which a fresh tmpfs
1415 // or a scratch CI disk really does report) fails a test about the
1416 // shape of the document.
1417 let free = api::parse(&body).expect("the document is KYAML")["free_fraction"]
1418 .as_f64()
1419 .expect("a readable volume reports a fraction");
1420 assert!(
1421 free > 0.0 && free <= 1.0,
1422 "free_fraction is a fraction of the volume: {free}"
1423 );
1424
1425 // A path the filesystem will not answer for — a detached volume, a
1426 // `subPath` that vanished. "I could not look" is not "the disk is
1427 // empty": a zero here reads as a volume with no room left and takes a
1428 // healthy node out of rotation, so the answer is `null` and the
1429 // endpoint still returns 200 rather than failing the whole document.
1430 let gone = std::sync::Arc::new(dir.join("no-such-volume"));
1431 let (status, body) = text(stats(axum::extract::State(gone)).await).await;
1432 assert_eq!(status, axum::http::StatusCode::OK);
1433 assert!(body.contains(r#""free_fraction":null"#), "{body}");
1434 let _ = std::fs::remove_dir_all(&dir);
1435 }
1436
1437 /// The peak resident set is one of the four axes performance is scored on,
1438 /// so it has to be a real measurement in the units the field name claims.
1439 ///
1440 /// The unit is the whole hazard: `ru_maxrss` is bytes on macOS and
1441 /// kibibytes on Linux, and getting it backwards is not a visible failure —
1442 /// it is a number 1024× out that a dashboard renders without complaint. A
1443 /// test process is comfortably inside these bounds on either platform;
1444 /// either mistake leaves it outside one of them.
1445 #[test]
1446 fn the_peak_resident_set_is_reported_in_bytes() {
1447 let rss = peak_rss();
1448 assert!(rss > 1 << 20, "{rss} bytes is below a running process");
1449 assert!(rss < 100 << 30, "{rss} bytes is a unit mistake, not an RSS");
1450 }
1451
1452 /// `mira mira --data-dir` maps blocks with no server in front of it, so it
1453 /// needs the guards the server runs or it answers the one question it exists
1454 /// for with a lie: `block::scan` reads ENOENT as an empty directory, and a
1455 /// detached volume comes out as `0/0 blocks` — the same screen a healthy
1456 /// empty store draws.
1457 #[test]
1458 fn the_tui_refuses_a_data_directory_the_server_would_have_refused() {
1459 let dir = tmp("tui-guard");
1460 let local = |p: &Path| tui::Source::Local(p.to_path_buf());
1461 // A real directory passes both guards, empty or not: an empty block
1462 // directory is a normal thing to point this at.
1463 check_source(&local(&dir), true).unwrap();
1464
1465 for bad in [dir.join("nope"), {
1466 let f = dir.join("a-file");
1467 std::fs::write(&f, b"").unwrap();
1468 f
1469 }] {
1470 let e = check_source(&local(&bad), true).unwrap_err().to_string();
1471 assert!(e.contains(&bad.display().to_string()), "{e}");
1472 assert!(e.contains("not a directory"), "{e}");
1473 // Off a terminal the same path is accepted, because `tui::run`'s
1474 // own refusal comes first and is the more useful answer: this
1475 // diagnosis is about a screen that was never going to be drawn.
1476 check_source(&local(&bad), false).unwrap();
1477 // And a remote source is never this path at all, however unusable
1478 // the same string would be as a directory: those blocks are mapped
1479 // by the server on the other end, which ran these guards itself.
1480 let remote = tui::Source::Remote(bad.display().to_string());
1481 check_source(&remote, true).unwrap();
1482 }
1483 let _ = std::fs::remove_dir_all(&dir);
1484 }
1485
1486 /// Boot recovery end to end: every frame no block claims goes back through
1487 /// its own signal's flusher, before anything is served.
1488 ///
1489 /// The three signals share one log, so a replay that read the frame header
1490 /// wrongly would hand a span to the log encoder. And a frame that passed its
1491 /// checksum and then will not decode has to be survivable, because stopping
1492 /// on it would abandon every frame behind it — one dead export instead of
1493 /// the whole log. It stays unclaimed until a later block of that signal
1494 /// covers it, which is the next export, so it does not pin the log either.
1495 #[tokio::test]
1496 async fn a_boot_replays_every_frame_no_block_claims() {
1497 use mira_core::wal::Signal;
1498 use mira_proto::collector::metrics::v1::ExportMetricsServiceRequest;
1499 use mira_proto::collector::trace::v1::ExportTraceServiceRequest;
1500 use mira_proto::metrics::v1::metric::Data;
1501 use mira_proto::metrics::v1::number_data_point::Value as NumValue;
1502 use mira_proto::metrics::v1::{
1503 Gauge, Metric, NumberDataPoint, ResourceMetrics, ScopeMetrics,
1504 };
1505 use mira_proto::trace::v1::{ResourceSpans, ScopeSpans, Span};
1506 use prost::Message as _;
1507
1508 let dir = tmp("replay");
1509 let node = mira_core::block::node_id("replaynode");
1510 let spans = ExportTraceServiceRequest {
1511 resource_spans: vec![ResourceSpans {
1512 scope_spans: vec![ScopeSpans {
1513 spans: vec![Span {
1514 trace_id: vec![0x11; 16].into(),
1515 span_id: vec![0x22; 8].into(),
1516 name: "GET /".into(),
1517 start_time_unix_nano: 3_000,
1518 end_time_unix_nano: 3_500,
1519 ..Default::default()
1520 }],
1521 ..Default::default()
1522 }],
1523 ..Default::default()
1524 }],
1525 };
1526 let points = ExportMetricsServiceRequest {
1527 resource_metrics: vec![ResourceMetrics {
1528 scope_metrics: vec![ScopeMetrics {
1529 metrics: vec![Metric {
1530 name: "process.cpu".into(),
1531 data: Some(Data::Gauge(Gauge {
1532 data_points: vec![NumberDataPoint {
1533 time_unix_nano: 4_000,
1534 value: Some(NumValue::AsDouble(0.5)),
1535 ..Default::default()
1536 }],
1537 })),
1538 ..Default::default()
1539 }],
1540 ..Default::default()
1541 }],
1542 ..Default::default()
1543 }],
1544 };
1545
1546 // A crash: four frames appended, nothing sealed. The last is not an OTLP
1547 // export — field 1 tagged as a varint, where the schema has a message.
1548 {
1549 let wal = mira_core::wal::Wal::open(&dir, node).unwrap();
1550 let logs = e2e::logs_export("checkout", 2_000, 4).encode_to_vec();
1551 wal.append(Signal::Logs, &logs).unwrap();
1552 wal.append(Signal::Traces, &spans.encode_to_vec()).unwrap();
1553 wal.append(Signal::Metrics, &points.encode_to_vec())
1554 .unwrap();
1555 wal.append(Signal::Logs, b"\x08").unwrap();
1556 }
1557
1558 let wal = std::sync::Arc::new(mira_core::wal::Wal::open(&dir, node).unwrap());
1559 let pcfg = std::sync::Arc::new(pipeline::Config {
1560 data_dir: dir.clone(),
1561 node,
1562 wal: Some(wal),
1563 max_block_age: std::time::Duration::from_millis(50),
1564 ..Default::default()
1565 });
1566 let (logs, _ol, h_logs) = pipeline::spawn::<mira_core::logs::LogsBuilder>(&pcfg);
1567 let (traces, _ot, h_traces) = pipeline::spawn::<mira_core::traces::TracesBuilder>(&pcfg);
1568 let (metrics, _om, h_metrics) =
1569 pipeline::spawn::<mira_core::metrics::MetricsBuilder>(&pcfg);
1570
1571 replay(&dir, node, logs.clone(), traces.clone(), metrics.clone())
1572 .await
1573 .unwrap();
1574 drop((logs, traces, metrics));
1575 for h in [h_logs, h_traces, h_metrics] {
1576 h.await.unwrap();
1577 }
1578
1579 for signal in pipeline::SIGNALS {
1580 let published = mira_core::block::scan(&dir, signal).unwrap();
1581 assert_eq!(published.len(), 1, "{signal} did not store its frame");
1582 }
1583 // A block claims the first sequence of its signal that nothing covers,
1584 // not one past the frame it happens to hold — under shards the two are
1585 // different numbers, and only the first is safe to skip on the next
1586 // boot. Here nothing of any signal is left outstanding: frames 0, 1 and
1587 // 2 are in blocks, and frame 3 was retired when it failed to decode. So
1588 // all three claim the whole log, and the next boot replays nothing.
1589 //
1590 // Sequence 3 being dropped rather than pinned is the point of that
1591 // retirement: a frame that will never decode must not hold a watermark,
1592 // or every frame published behind it is replayed on every boot forever.
1593 assert_eq!(mira_core::block::wal_watermarks(&dir).unwrap(), [4, 4, 4]);
1594 let _ = std::fs::remove_dir_all(&dir);
1595 }
1596
1597 /// What a boot after a hard kill says out loud.
1598 ///
1599 /// Recovery is silent work on a path nobody watches, so the report is the
1600 /// only way an operator learns that this start replayed anything — and the
1601 /// torn tail is the line that distinguishes "we crashed" from "a byte on
1602 /// this volume went bad", which are different pages. Both are field
1603 /// expressions inside `tracing` macros, which means they are only ever
1604 /// *evaluated* under a subscriber: without one here, a typo in them ships.
1605 #[tokio::test]
1606 async fn a_boot_after_a_hard_kill_reports_the_torn_tail_and_what_it_recovered() {
1607 use mira_core::wal::Signal;
1608 use prost::Message as _;
1609
1610 let dir = tmp("torn-replay");
1611 let node = mira_core::block::node_id("tornnode");
1612 {
1613 let wal = mira_core::wal::Wal::open(&dir, node).unwrap();
1614 for i in 0..2 {
1615 let body = e2e::logs_export("checkout", 2_000 + i, 1).encode_to_vec();
1616 wal.append(Signal::Logs, &body).unwrap();
1617 }
1618 wal.sync().unwrap();
1619 }
1620 // Four bytes off the tail: the last frame loses its checksum, which is
1621 // exactly what a process killed mid-write leaves behind.
1622 let seg = dir
1623 .join(".wal")
1624 .join(format!("{node:08x}-{:020}.wal", 0u64));
1625 let len = std::fs::metadata(&seg).unwrap().len();
1626 std::fs::OpenOptions::new()
1627 .write(true)
1628 .open(&seg)
1629 .unwrap()
1630 .set_len(len - 4)
1631 .unwrap();
1632
1633 let pcfg = std::sync::Arc::new(pipeline::Config {
1634 data_dir: dir.clone(),
1635 node,
1636 max_block_age: std::time::Duration::from_millis(50),
1637 ..Default::default()
1638 });
1639 let (logs, _ol, h_logs) = pipeline::spawn::<mira_core::logs::LogsBuilder>(&pcfg);
1640 let (traces, _ot, h_traces) = pipeline::spawn::<mira_core::traces::TracesBuilder>(&pcfg);
1641 let (metrics, _om, h_metrics) =
1642 pipeline::spawn::<mira_core::metrics::MetricsBuilder>(&pcfg);
1643
1644 let (guard, log) = e2e::capture();
1645 replay(&dir, node, logs.clone(), traces.clone(), metrics.clone())
1646 .await
1647 .expect("a torn tail is a recovery, not a refusal");
1648 drop(guard);
1649
1650 let text = log.text();
1651 assert!(text.contains("torn frame"), "{text}");
1652 assert!(text.contains("segments=1"), "{text}");
1653 assert!(
1654 text.contains("recovered from the write-ahead log"),
1655 "{text}"
1656 );
1657 // The whole frame before the tear, and only it.
1658 assert!(text.contains("replayed=1"), "{text}");
1659 assert!(text.contains("elapsed_ms="), "{text}");
1660
1661 drop((logs, traces, metrics));
1662 for h in [h_logs, h_traces, h_metrics] {
1663 h.await.unwrap();
1664 }
1665 assert_eq!(mira_core::block::scan(&dir, "logs").unwrap().len(), 1);
1666 let _ = std::fs::remove_dir_all(&dir);
1667 }
1668
1669 /// A replay that cannot deliver stops the boot.
1670 ///
1671 /// The alternative is the quietest possible data loss: the flusher for one
1672 /// signal is gone, every frame for it is dropped on the floor, the node
1673 /// finishes starting and answers `/readyz` with a 200 — and the log those
1674 /// frames were in is truncated by the next block that gets published. So
1675 /// the first undeliverable frame ends the boot with an error naming the
1676 /// directory, and the supervisor restarts into a working process.
1677 #[tokio::test]
1678 async fn a_replay_with_nowhere_to_put_a_frame_refuses_to_finish_the_boot() {
1679 use mira_core::wal::Signal;
1680 use prost::Message as _;
1681
1682 let dir = tmp("replay-closed");
1683 let node = mira_core::block::node_id("closednode");
1684 {
1685 let wal = mira_core::wal::Wal::open(&dir, node).unwrap();
1686 wal.append(
1687 Signal::Logs,
1688 &e2e::logs_export("checkout", 2_000, 1).encode_to_vec(),
1689 )
1690 .unwrap();
1691 wal.sync().unwrap();
1692 }
1693
1694 let pcfg = std::sync::Arc::new(pipeline::Config {
1695 data_dir: dir.clone(),
1696 node,
1697 ..Default::default()
1698 });
1699 let (logs, _ol, mut h_logs) = pipeline::spawn::<mira_core::logs::LogsBuilder>(&pcfg);
1700 let (traces, _ot, _ht) = pipeline::spawn::<mira_core::traces::TracesBuilder>(&pcfg);
1701 let (metrics, _om, _hm) = pipeline::spawn::<mira_core::metrics::MetricsBuilder>(&pcfg);
1702 // The one failure mode a replay cannot route around: the receiving end
1703 // of this signal's channel is gone, so no retry and no other signal
1704 // makes the frame land.
1705 h_logs.abort();
1706 let _ = h_logs.await;
1707
1708 let e = replay(&dir, node, logs, traces, metrics)
1709 .await
1710 .expect_err("a frame with nowhere to go must stop the boot");
1711 let e = e.to_string();
1712 assert!(e.contains("flusher"), "{e}");
1713 assert!(e.contains(&dir.display().to_string()), "{e}");
1714 let _ = std::fs::remove_dir_all(&dir);
1715 }
1716}