Skip to main content

mira_core/
lib.rs

1//! Mira storage engine.
2//!
3//! Three layers, in dependency order:
4//!
5//! * [`schema`] — the OTAP-shaped Arrow schemas that are simultaneously the
6//!   in-memory and the on-disk layout. There is no translation step.
7//! * [`identity`] — the stable entity key that correlation joins on.
8//! * [`attrs`] — the attribute tables and Resource-Scope preamble every signal
9//!   shares.
10//! * [`signal`] — the one shape every signal's encoder presents to the flusher.
11//! * [`logs`] / [`traces`] / [`metrics`] — OTLP protobuf into those schemas,
12//!   with block-local id rebasing.
13//! * [`block`] — atomic publish of immutable block directories and zero-copy
14//!   mmap reads back out of them.
15//!
16//! See `docs/architecture.md` for why each of those is shaped the way it is.
17
18#![deny(unsafe_op_in_unsafe_fn)]
19
20pub mod attrs;
21pub mod block;
22pub mod bloom;
23pub mod error;
24pub mod frame;
25pub mod identity;
26pub mod json;
27pub mod logs;
28pub mod metrics;
29pub mod query;
30pub mod schema;
31pub mod series;
32pub mod signal;
33pub mod traces;
34pub mod wal;
35pub mod zone;
36
37pub use error::{Error, Result};
38pub use signal::{Sealed, SignalBuilder};
39
40/// Backs [`degraded_syncs`].
41static DEGRADED_SYNCS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
42
43/// How many syncs have fallen back to `fsync(2)`.
44///
45/// Zero on a volume that implements write barriers, which is every volume Mira
46/// is meant to run on. Non-zero means the durability promise on this node is
47/// weaker than the one [`sync_all`] documents, and it is reported by
48/// `/api/v1/stats` so that is a fact an operator can see rather than infer.
49pub fn degraded_syncs() -> u64 {
50    DEGRADED_SYNCS.load(std::sync::atomic::Ordering::Relaxed)
51}
52
53/// `File::sync_all`, with the fallback Apple targets need and `std` does not do.
54///
55/// On an Apple target both `sync_all` and `sync_data` compile to
56/// `fcntl(F_FULLFSYNC)` — std's `os_fsync`/`os_datasync` in `sys/fs/unix.rs`,
57/// with no error handling around the `fcntl` beyond `cvt_r`. That is the
58/// correct barrier and it is why an ack is not an fsync (section 4), but not
59/// every filesystem implements it: a Docker Desktop bind mount is FUSE and
60/// answers `ENOTSUP`, and some network volumes answer `EINVAL`. std turns
61/// either into an error, so a publish that a plain `fsync(2)` would have
62/// satisfied fails instead, and the node goes unready over a volume that works.
63///
64/// So: try the barrier, and on exactly those two errnos degrade to `fsync` and
65/// count it. Counting is the whole point — a fallback nobody can see is silent
66/// durability loss, which is worse than the failure it replaces.
67pub fn sync_all(file: &std::fs::File) -> std::io::Result<()> {
68    sync_with(file, std::fs::File::sync_all)
69}
70
71/// `File::sync_data`, degrading the same way [`sync_all`] does.
72pub fn sync_data(file: &std::fs::File) -> std::io::Result<()> {
73    sync_with(file, std::fs::File::sync_data)
74}
75
76/// The seam the two above share, and the one a test can drive: `strong` is the
77/// barrier that might not be supported, and there is no way to make a real
78/// filesystem refuse `F_FULLFSYNC` on demand inside a unit test.
79fn sync_with(
80    file: &std::fs::File,
81    strong: impl Fn(&std::fs::File) -> std::io::Result<()>,
82) -> std::io::Result<()> {
83    match strong(file) {
84        Ok(()) => Ok(()),
85        Err(e) => degrade(file, e),
86    }
87}
88
89#[cfg(target_vendor = "apple")]
90fn degrade(file: &std::fs::File, e: std::io::Error) -> std::io::Result<()> {
91    use std::os::fd::AsRawFd;
92    if !matches!(e.raw_os_error(), Some(libc::ENOTSUP | libc::EINVAL)) {
93        return Err(e);
94    }
95    // SAFETY: `file` owns the descriptor and outlives the call.
96    if unsafe { libc::fsync(file.as_raw_fd()) } != 0 {
97        return Err(std::io::Error::last_os_error());
98    }
99    DEGRADED_SYNCS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
100    Ok(())
101}
102
103/// Everywhere else `sync_all` is already `fsync`, so there is nothing to
104/// degrade to and an error is an error.
105#[cfg(not(target_vendor = "apple"))]
106fn degrade(_file: &std::fs::File, e: std::io::Error) -> std::io::Result<()> {
107    Err(e)
108}
109
110#[cfg(test)]
111mod sync_tests {
112    use super::*;
113    use std::io::Error;
114
115    /// One test, not three, because the counter is process-global and
116    /// `cargo test` runs a crate's tests on many threads: three tests each
117    /// asserting a delta on one counter is a race, and the first draft of this
118    /// module was exactly that. No `tempfile` either — the dependency budget is
119    /// a product property (`docs/architecture.md` section 11) for one file.
120    #[test]
121    fn the_barrier_runs_and_only_enotsup_degrades() {
122        let path = std::env::temp_dir().join(format!("mira-sync-{}", std::process::id()));
123        let file = std::fs::File::create(&path).expect("create");
124
125        // A real volume has the barrier, so nothing degrades.
126        sync_all(&file).expect("sync_all");
127        sync_data(&file).expect("sync_data");
128        assert_eq!(degraded_syncs(), 0, "a real volume degrades nothing");
129
130        // EIO is a write that failed, not a feature that is missing.
131        let e = sync_with(&file, |_| Err(Error::from_raw_os_error(libc::EIO)))
132            .expect_err("EIO must not be swallowed");
133        assert_eq!(e.raw_os_error(), Some(libc::EIO));
134
135        // No errno at all — a `std` error that never came from the kernel.
136        let e = sync_with(&file, |_| Err(Error::other("synthetic")))
137            .expect_err("no errno, no fallback");
138        assert!(e.raw_os_error().is_none());
139        assert_eq!(degraded_syncs(), 0, "neither of those is a missing barrier");
140
141        // The whole point of the wrapper, and the only way to reach the
142        // fallback: `strong` refuses the way a FUSE mount refuses, `fsync(2)`
143        // on the real descriptor underneath still works, and the counter says
144        // it happened. Elsewhere `sync_all` is already `fsync`, so there is
145        // nothing to degrade to and every errno stays an error.
146        for (i, errno) in [libc::ENOTSUP, libc::EINVAL].into_iter().enumerate() {
147            let r = sync_with(&file, |_| Err(Error::from_raw_os_error(errno)));
148            if cfg!(target_vendor = "apple") {
149                r.unwrap_or_else(|e| panic!("errno {errno} should have degraded, got {e}"));
150                assert_eq!(degraded_syncs(), i as u64 + 1);
151            } else {
152                assert_eq!(
153                    r.expect_err("no fallback off Apple").raw_os_error(),
154                    Some(errno)
155                );
156                assert_eq!(degraded_syncs(), 0);
157            }
158        }
159
160        let _ = std::fs::remove_file(&path);
161    }
162}
163
164#[cfg(test)]
165mod tests {
166    use super::*;
167    use arrow_array::{Array, StringArray, UInt32Array, UInt64Array};
168    use mira_proto::collector::logs::v1::ExportLogsServiceRequest;
169    use mira_proto::common::v1::{AnyValue, InstrumentationScope, KeyValue, any_value::Value};
170    use mira_proto::logs::v1::{LogRecord, ResourceLogs, ScopeLogs};
171    use mira_proto::resource::v1::Resource;
172
173    fn kv(k: &str, v: &str) -> KeyValue {
174        KeyValue {
175            key: k.into(),
176            value: Some(AnyValue {
177                value: Some(Value::StringValue(v.into())),
178            }),
179        }
180    }
181
182    fn request(service: &str, n: usize, base_ts: u64) -> ExportLogsServiceRequest {
183        request_from(service, &[], n, base_ts)
184    }
185
186    /// `extra` carries non-identifying resource attributes, so that a caller can
187    /// simulate an exporter that starts reporting more about the same instance.
188    fn request_from(
189        service: &str,
190        extra: &[KeyValue],
191        n: usize,
192        base_ts: u64,
193    ) -> ExportLogsServiceRequest {
194        let mut attributes = vec![
195            kv("service.name", service),
196            kv("service.instance.id", "7f3a"),
197        ];
198        attributes.extend_from_slice(extra);
199        ExportLogsServiceRequest {
200            resource_logs: vec![ResourceLogs {
201                resource: Some(Resource {
202                    attributes,
203                    ..Default::default()
204                }),
205                scope_logs: vec![ScopeLogs {
206                    scope: Some(InstrumentationScope {
207                        name: "test".into(),
208                        ..Default::default()
209                    }),
210                    log_records: (0..n)
211                        .map(|i| LogRecord {
212                            time_unix_nano: base_ts + i as u64,
213                            severity_number: 9,
214                            severity_text: "INFO".into(),
215                            body: Some(AnyValue {
216                                value: Some(Value::StringValue(format!("line {i}"))),
217                            }),
218                            attributes: vec![kv("http.method", "GET")],
219                            trace_id: vec![7u8; 16].into(),
220                            span_id: vec![3u8; 8].into(),
221                            ..Default::default()
222                        })
223                        .collect(),
224                    ..Default::default()
225                }],
226                ..Default::default()
227            }],
228        }
229    }
230
231    /// The load-bearing property of the whole engine: what we write, we can read
232    /// back out of a mapping with zero buffer copies. If this fails, Mira is just
233    /// another columnar store with an extra memcpy.
234    #[test]
235    fn roundtrip_is_zero_copy_and_prunable() {
236        let root = std::env::temp_dir().join(format!("mira-test-{}", std::process::id()));
237        let _ = std::fs::remove_dir_all(&root);
238
239        let mut b = logs::LogsBuilder::new();
240        // Two identical requests from the same service: interned once.
241        assert_eq!(
242            b.append_request(&request("checkout", 500, 1_000)).unwrap(),
243            500
244        );
245        assert_eq!(
246            b.append_request(&request("checkout", 500, 2_000)).unwrap(),
247            500
248        );
249        // Same instance, now reporting one more non-identifying attribute. A
250        // second resource row, but it must NOT be a second entity.
251        assert_eq!(
252            b.append_request(&request_from(
253                "checkout",
254                &[kv("k8s.node.name", "node-4")],
255                100,
256                3_000
257            ))
258            .unwrap(),
259            100
260        );
261        assert_eq!(
262            b.append_request(&request("payments", 100, 3_100)).unwrap(),
263            100
264        );
265
266        let sealed = b.finish().unwrap();
267        assert_eq!(sealed.num_rows, 1200);
268        assert_eq!(sealed.min_ts, 1_000);
269        assert_eq!(sealed.max_ts, 3_199);
270        // Every table the on-disk format promises, named exactly as its file.
271        assert_eq!(
272            sealed.tables.iter().map(|(n, _)| *n).collect::<Vec<_>>(),
273            schema::LOGS_BLOCK_TABLES
274        );
275        let rows = |t: &str| sealed.table(t).unwrap().num_rows();
276        // checkout(2 attrs) + checkout-with-node(3) + payments(2).
277        assert_eq!(rows("resources"), 3);
278        assert_eq!(rows("resource_attrs"), 7);
279        assert_eq!(rows("scope_attrs"), 1);
280        assert_eq!(rows("log_attrs"), 1200);
281
282        // The load-bearing correlation invariant: attribute drift does not fork
283        // an entity. Three resource rows, two entities.
284        let keys = sealed
285            .table("resources")
286            .unwrap()
287            .column_by_name("key")
288            .unwrap()
289            .as_any()
290            .downcast_ref::<UInt64Array>()
291            .unwrap();
292        assert_eq!(
293            keys.value(0),
294            keys.value(1),
295            "attribute drift must not fork the entity"
296        );
297        assert_ne!(keys.value(0), keys.value(2));
298
299        let node = block::node_id("replica-a");
300        let published = block::publish(&root, "logs", node, 1, 0, &sealed).unwrap();
301        assert_eq!(published.node, node);
302        // A second replica publishing the same time range must not collide.
303        assert_ne!(node, block::node_id("replica-b"));
304
305        // Catalog comes back from directory names alone — no file opened.
306        let catalog = block::scan(&root, "logs").unwrap();
307        assert_eq!(catalog, vec![published.clone()]);
308        assert!(catalog[0].overlaps(0, 1_500));
309        assert!(!catalog[0].overlaps(10_000, 20_000));
310
311        // Zero-copy read back. require_alignment(true) is on inside open_table,
312        // so this call errors rather than silently copying.
313        let logs = block::open_table(&published.dir.join("logs.arrow")).unwrap();
314        assert_eq!(logs.batches.len(), 1);
315        let rb = &logs.batches[0];
316        assert_eq!(rb.num_rows(), 1200);
317
318        // THE load-bearing invariant: every buffer of every column points into
319        // the mapping. If this ever drops below n/n, Mira has silently become a
320        // store that memcpies its whole working set on every read.
321        let (inside, total) = logs.zero_copy_ratio();
322        assert_eq!(inside, total, "{inside}/{total} buffers zero-copy");
323        assert!(total >= 13, "expected one buffer per column at least");
324
325        let ids = rb
326            .column_by_name("id")
327            .unwrap()
328            .as_any()
329            .downcast_ref::<UInt32Array>()
330            .unwrap();
331        assert_eq!(ids.value(0), 0);
332        assert_eq!(ids.value(1199), 1199, "ids must be dense and block-local");
333
334        let body = rb
335            .column_by_name("body")
336            .unwrap()
337            .as_any()
338            .downcast_ref::<StringArray>()
339            .unwrap();
340        assert_eq!(body.value(0), "line 0");
341
342        // A join inside a block is safe precisely because ids were rebased.
343        let attrs = block::open_table(&published.dir.join("log_attrs.arrow")).unwrap();
344        let parents = attrs.batches[0]
345            .column_by_name("parent_id")
346            .unwrap()
347            .as_any()
348            .downcast_ref::<UInt32Array>()
349            .unwrap();
350        assert_eq!(parents.value(1199), 1199);
351
352        // Retention is an unlink of the directory.
353        assert_eq!(block::expire(&root, "logs", 10_000).unwrap(), 1);
354        assert!(block::scan(&root, "logs").unwrap().is_empty());
355
356        let _ = std::fs::remove_dir_all(&root);
357    }
358
359    /// The two inputs to the seal decision. Both were wrong in ways that only
360    /// show up in production: a block sized by row count alone balloons on large
361    /// bodies, and a dictionary discovered full mid-append leaves a half-written
362    /// row that fails every subsequent flush.
363    #[test]
364    fn seal_decision_sees_bodies_and_dictionary_pressure() {
365        let mut small = logs::LogsBuilder::new();
366        small
367            .append_request(&request("checkout", 100, 1_000))
368            .unwrap();
369
370        let prompt = "x".repeat(32 << 10);
371        let mut req = request("checkout", 100, 1_000);
372        for sl in &mut req.resource_logs[0].scope_logs {
373            for r in &mut sl.log_records {
374                r.body = Some(AnyValue {
375                    value: Some(Value::StringValue(prompt.clone())),
376                });
377            }
378        }
379        let mut big = logs::LogsBuilder::new();
380        big.append_request(&req).unwrap();
381        // Same row count, same schema, three orders of magnitude apart. Counting
382        // fixed-width columns only would make these two numbers equal.
383        assert!(small.approx_bytes() < 64 << 10, "{}", small.approx_bytes());
384        assert!(big.approx_bytes() > 3 << 20, "{}", big.approx_bytes());
385
386        // A request wider than an empty block's dictionary is refused headroom
387        // up front, not discovered part way through an append.
388        let mut wide = request("checkout", 1, 1_000);
389        wide.resource_logs[0].scope_logs[0].log_records[0].attributes = (0..=schema::DICT_CAP)
390            .map(|i| kv(&format!("k{i}"), "v"))
391            .collect();
392        assert!(!small.has_headroom_for(&wide));
393        assert!(small.has_headroom_for(&request("checkout", 10, 1)));
394    }
395
396    /// Spans are the only signal with grandchildren: an event belongs to a span
397    /// and its attributes belong to the event. Both hops are block-local ids, and
398    /// getting either wrong turns a join into a cross product that still returns
399    /// plausible-looking rows.
400    #[test]
401    fn span_events_and_links_are_child_tables_with_their_own_ids() {
402        use mira_proto::collector::trace::v1::ExportTraceServiceRequest;
403        use mira_proto::trace::v1::span::{Event, Link};
404        use mira_proto::trace::v1::{ResourceSpans, ScopeSpans, Span, Status, status::StatusCode};
405
406        // Two spans, the second with two events and one link, so a bug that
407        // parents events to the wrong span shows up as a wrong parent_id rather
408        // than as a coincidentally-correct zero.
409        let span = |i: u64, events: usize, links: usize| Span {
410            trace_id: vec![1u8; 16].into(),
411            span_id: vec![i as u8; 8].into(),
412            name: "GET /checkout".into(),
413            kind: 2,
414            start_time_unix_nano: 10_000 + i,
415            end_time_unix_nano: 10_000 + i + 500,
416            attributes: vec![kv("http.method", "GET")],
417            status: Some(Status {
418                code: StatusCode::Error as i32,
419                message: "boom".into(),
420            }),
421            events: (0..events)
422                .map(|e| Event {
423                    time_unix_nano: 10_100 + e as u64,
424                    name: "exception".into(),
425                    attributes: vec![kv("exception.type", "IOError")],
426                    ..Default::default()
427                })
428                .collect(),
429            // No attributes on the link — the common case, and it leaves
430            // span_link_attrs empty, which the publish check below relies on.
431            links: (0..links)
432                .map(|_| Link {
433                    trace_id: vec![9u8; 16].into(),
434                    span_id: vec![8u8; 8].into(),
435                    ..Default::default()
436                })
437                .collect(),
438            ..Default::default()
439        };
440
441        let mut b = traces::TracesBuilder::new();
442        let req = ExportTraceServiceRequest {
443            resource_spans: vec![ResourceSpans {
444                resource: Some(Resource {
445                    attributes: vec![
446                        kv("service.name", "checkout"),
447                        kv("service.instance.id", "7f3a"),
448                    ],
449                    ..Default::default()
450                }),
451                scope_spans: vec![ScopeSpans {
452                    scope: Some(InstrumentationScope {
453                        name: "test".into(),
454                        ..Default::default()
455                    }),
456                    spans: vec![span(0, 0, 0), span(1, 2, 1)],
457                    ..Default::default()
458                }],
459                ..Default::default()
460            }],
461        };
462        assert!(b.has_headroom_for(&req));
463        assert_eq!(b.append_request(&req).unwrap(), 2);
464
465        let sealed = b.finish().unwrap();
466        assert_eq!(sealed.num_rows, 2);
467        assert_eq!(
468            sealed.tables.iter().map(|(n, _)| *n).collect::<Vec<_>>(),
469            schema::TRACES_BLOCK_TABLES
470        );
471        // The block's range spans start..end, not start..start: a query for the
472        // instant a long span ended has to find it.
473        assert_eq!(sealed.min_ts, 10_000);
474        assert_eq!(sealed.max_ts, 10_501);
475
476        let rows = |t: &str| sealed.table(t).unwrap().num_rows();
477        assert_eq!(rows("span_events"), 2);
478        assert_eq!(rows("span_links"), 1);
479        assert_eq!(rows("span_event_attrs"), 2);
480        assert_eq!(rows("span_link_attrs"), 0);
481        assert_eq!(rows("span_attrs"), 2);
482
483        let u32col = |t: &str, c: &str| {
484            sealed
485                .table(t)
486                .unwrap()
487                .column_by_name(c)
488                .unwrap()
489                .as_any()
490                .downcast_ref::<UInt32Array>()
491                .unwrap()
492                .clone()
493        };
494        // Both events hang off span 1, and they carry ids 0 and 1 of their own —
495        // the ids span_event_attrs.parent_id refers to. Sharing the span's id
496        // here is the bug this test exists to catch.
497        assert_eq!(u32col("span_events", "parent_id").values(), &[1, 1]);
498        assert_eq!(u32col("span_events", "id").values(), &[0, 1]);
499        assert_eq!(u32col("span_event_attrs", "parent_id").values(), &[0, 1]);
500        assert_eq!(u32col("span_links", "parent_id").values(), &[1]);
501
502        // An empty table costs ~1-2.5 KB of Arrow IPC framing and is not written.
503        // A traces block has nine tables and a service that emits no span links
504        // would otherwise pay for four of them on every seal.
505        let root = std::env::temp_dir().join(format!("mira-tr-{}", std::process::id()));
506        let _ = std::fs::remove_dir_all(&root);
507        let published =
508            block::publish(&root, "traces", block::node_id("a"), 1, 0, &sealed).unwrap();
509        assert!(!published.dir.join("span_link_attrs.arrow").exists());
510        assert!(
511            block::open_table_opt(&published.dir.join("span_link_attrs.arrow"))
512                .unwrap()
513                .is_none()
514        );
515        let ev = block::open_table_opt(&published.dir.join("span_events.arrow"))
516            .unwrap()
517            .unwrap();
518        let (inside, total) = ev.zero_copy_ratio();
519        assert_eq!(inside, total, "{inside}/{total} buffers zero-copy");
520        let _ = std::fs::remove_dir_all(&root);
521
522        let spans = sealed.table("spans").unwrap();
523        let durations = spans
524            .column_by_name("duration_nano")
525            .unwrap()
526            .as_any()
527            .downcast_ref::<UInt64Array>()
528            .unwrap();
529        assert_eq!(durations.values(), &[500, 500]);
530
531        // Links point out of the block, so their ids stay raw. Rebasing these
532        // would silently repoint a link at a local row.
533        let lt = sealed.table("span_links").unwrap();
534        let lt = lt
535            .column_by_name("trace_id")
536            .unwrap()
537            .as_any()
538            .downcast_ref::<arrow_array::FixedSizeBinaryArray>()
539            .unwrap();
540        assert_eq!(lt.value(0), &[9u8; 16]);
541    }
542
543    /// A span with a zero start time is malformed; folding it into the block's
544    /// range would make the directory name claim to cover the epoch, and every
545    /// temporal query would then have to open the block to find nothing.
546    #[test]
547    fn malformed_span_times_do_not_widen_the_block_range() {
548        use mira_proto::collector::trace::v1::ExportTraceServiceRequest;
549        use mira_proto::trace::v1::{ResourceSpans, ScopeSpans, Span};
550
551        let mut b = traces::TracesBuilder::new();
552        b.append_request(&ExportTraceServiceRequest {
553            resource_spans: vec![ResourceSpans {
554                scope_spans: vec![ScopeSpans {
555                    spans: vec![
556                        Span {
557                            name: "no-clock".into(),
558                            ..Default::default()
559                        },
560                        Span {
561                            name: "backwards".into(),
562                            start_time_unix_nano: 5_000,
563                            end_time_unix_nano: 4_000,
564                            ..Default::default()
565                        },
566                        Span {
567                            name: "fine".into(),
568                            start_time_unix_nano: 6_000,
569                            end_time_unix_nano: 6_100,
570                            ..Default::default()
571                        },
572                    ],
573                    ..Default::default()
574                }],
575                ..Default::default()
576            }],
577        })
578        .unwrap();
579
580        let sealed = b.finish().unwrap();
581        assert_eq!(
582            sealed.num_rows, 3,
583            "malformed spans are stored, not dropped"
584        );
585        assert_eq!(sealed.min_ts, 5_000);
586        assert_eq!(sealed.max_ts, 6_100);
587        // end < start saturates to zero rather than wrapping to 584 years.
588        let d = sealed.table("spans").unwrap();
589        let d = d
590            .column_by_name("duration_nano")
591            .unwrap()
592            .as_any()
593            .downcast_ref::<UInt64Array>()
594            .unwrap();
595        assert_eq!(d.values(), &[0, 0, 100]);
596    }
597
598    /// All five metric types through one request, because the layout's whole
599    /// premise is that they go to different tables while sharing one point id
600    /// space — and a shared counter is exactly the kind of thing that works for
601    /// one type and silently collides for four.
602    #[test]
603    fn every_metric_type_lands_in_its_own_table_from_one_id_space() {
604        use arrow_array::{Float64Array, ListArray, UInt8Array};
605        use mira_proto::collector::metrics::v1::ExportMetricsServiceRequest;
606        use mira_proto::metrics::v1::metric::Data;
607        use mira_proto::metrics::v1::number_data_point::Value as NumValue;
608        use mira_proto::metrics::v1::summary_data_point::ValueAtQuantile;
609        use mira_proto::metrics::v1::{
610            AggregationTemporality, Exemplar, ExponentialHistogram, ExponentialHistogramDataPoint,
611            Gauge, Histogram, HistogramDataPoint, Metric, NumberDataPoint, ResourceMetrics,
612            ScopeMetrics, Sum, Summary, SummaryDataPoint,
613            exponential_histogram_data_point::Buckets,
614        };
615
616        let number = |t: u64, v: i64| NumberDataPoint {
617            attributes: vec![kv("route", "/checkout")],
618            time_unix_nano: t,
619            start_time_unix_nano: 1, // process start: must not widen the block
620            value: Some(NumValue::AsInt(v)),
621            ..Default::default()
622        };
623        // Two histogram points sharing one bounds array, so the interning is
624        // actually exercised rather than merely present.
625        let hist = |t: u64| HistogramDataPoint {
626            time_unix_nano: t,
627            count: 3,
628            sum: Some(1.5),
629            bucket_counts: vec![1, 1, 1],
630            explicit_bounds: vec![0.1, 0.5],
631            exemplars: vec![Exemplar {
632                time_unix_nano: t,
633                trace_id: vec![4u8; 16].into(),
634                span_id: vec![5u8; 8].into(),
635                ..Default::default()
636            }],
637            ..Default::default()
638        };
639
640        let metrics = vec![
641            Metric {
642                name: "http.server.request.count".into(),
643                unit: "{request}".into(),
644                data: Some(Data::Sum(Sum {
645                    data_points: vec![number(1_000, 7), number(2_000, 9)],
646                    aggregation_temporality: AggregationTemporality::Cumulative as i32,
647                    is_monotonic: true,
648                })),
649                metadata: vec![kv("owner", "checkout-team")],
650                ..Default::default()
651            },
652            Metric {
653                name: "process.memory".into(),
654                data: Some(Data::Gauge(Gauge {
655                    data_points: vec![number(1_500, 42)],
656                })),
657                ..Default::default()
658            },
659            Metric {
660                name: "http.server.duration".into(),
661                data: Some(Data::Histogram(Histogram {
662                    data_points: vec![hist(1_100), hist(2_100)],
663                    aggregation_temporality: AggregationTemporality::Delta as i32,
664                })),
665                ..Default::default()
666            },
667            Metric {
668                name: "rpc.duration".into(),
669                data: Some(Data::ExponentialHistogram(ExponentialHistogram {
670                    data_points: vec![ExponentialHistogramDataPoint {
671                        time_unix_nano: 1_200,
672                        count: 4,
673                        scale: 3,
674                        zero_count: 1,
675                        positive: Some(Buckets {
676                            offset: 2,
677                            bucket_counts: vec![1, 2],
678                        }),
679                        ..Default::default()
680                    }],
681                    aggregation_temporality: AggregationTemporality::Delta as i32,
682                })),
683                ..Default::default()
684            },
685            Metric {
686                name: "legacy.latency".into(),
687                data: Some(Data::Summary(Summary {
688                    data_points: vec![SummaryDataPoint {
689                        time_unix_nano: 3_000,
690                        count: 10,
691                        sum: 5.0,
692                        quantile_values: vec![
693                            ValueAtQuantile {
694                                quantile: 0.5,
695                                value: 0.4,
696                            },
697                            ValueAtQuantile {
698                                quantile: 0.99,
699                                value: 0.9,
700                            },
701                        ],
702                        ..Default::default()
703                    }],
704                })),
705                ..Default::default()
706            },
707        ];
708
709        let mut b = metrics::MetricsBuilder::new();
710        let req = ExportMetricsServiceRequest {
711            resource_metrics: vec![ResourceMetrics {
712                resource: Some(Resource {
713                    attributes: vec![kv("service.name", "checkout")],
714                    ..Default::default()
715                }),
716                scope_metrics: vec![ScopeMetrics {
717                    metrics,
718                    ..Default::default()
719                }],
720                ..Default::default()
721            }],
722        };
723        assert!(b.has_headroom_for(&req));
724        // 3 number + 2 histogram + 1 exponential + 1 summary.
725        assert_eq!(b.append_request(&req).unwrap(), 7);
726
727        let sealed = b.finish().unwrap();
728        assert_eq!(sealed.num_rows, 7);
729        assert_eq!(
730            sealed.tables.iter().map(|(n, _)| *n).collect::<Vec<_>>(),
731            schema::METRICS_BLOCK_TABLES
732        );
733        let rows = |t: &str| sealed.table(t).unwrap().num_rows();
734        assert_eq!(rows("metrics"), 5);
735        assert_eq!(rows("number_dp"), 3);
736        assert_eq!(rows("hist_dp"), 2);
737        assert_eq!(rows("exp_hist_dp"), 1);
738        assert_eq!(rows("summary_dp"), 1);
739        assert_eq!(rows("exemplars"), 2);
740        assert_eq!(rows("metric_attrs"), 1);
741        // Two histogram points, one bounds row: the interning is the reason
742        // hist_dp measured 1.67x smaller.
743        assert_eq!(rows("hist_bounds"), 1);
744
745        // start_time_unix_nano is process start and must not widen the block —
746        // otherwise every cumulative metric makes its block match every query.
747        assert_eq!(sealed.min_ts, 1_000);
748        assert_eq!(sealed.max_ts, 3_000);
749
750        let u32col = |t: &str, c: &str| {
751            sealed
752                .table(t)
753                .unwrap()
754                .column_by_name(c)
755                .unwrap()
756                .as_any()
757                .downcast_ref::<UInt32Array>()
758                .unwrap()
759                .clone()
760        };
761        // THE invariant of this layout: one id space across four tables. Sum
762        // takes 0-1, gauge 2, histogram 3-4, exponential 5, summary 6 — never
763        // the same number twice, which is what lets dp_attrs and exemplars key
764        // on a point without a table discriminant.
765        assert_eq!(u32col("number_dp", "id").values(), &[0, 1, 2]);
766        assert_eq!(u32col("hist_dp", "id").values(), &[3, 4]);
767        assert_eq!(u32col("exp_hist_dp", "id").values(), &[5]);
768        assert_eq!(u32col("summary_dp", "id").values(), &[6]);
769        assert_eq!(u32col("exemplars", "parent_id").values(), &[3, 4]);
770        // Only the three number points carry attributes here, and dp_attrs
771        // points straight at them.
772        assert_eq!(u32col("dp_attrs", "parent_id").values(), &[0, 1, 2]);
773        // Both histogram points share bounds row 0.
774        assert_eq!(u32col("hist_dp", "bounds_id").values(), &[0, 0]);
775
776        // Temporality and monotonicity live on the descriptor, not on 300,000
777        // points that would each repeat them.
778        let kinds = sealed.table("metrics").unwrap();
779        let kind = kinds
780            .column_by_name("kind")
781            .unwrap()
782            .as_any()
783            .downcast_ref::<UInt8Array>()
784            .unwrap();
785        assert_eq!(kind.values(), &[2, 1, 3, 4, 5]);
786
787        // Quantiles round-trip as two parallel lists.
788        let sd = sealed.table("summary_dp").unwrap();
789        let q = sd
790            .column_by_name("quantile")
791            .unwrap()
792            .as_any()
793            .downcast_ref::<ListArray>()
794            .unwrap()
795            .value(0);
796        let q = q.as_any().downcast_ref::<Float64Array>().unwrap();
797        assert_eq!(q.values(), &[0.5, 0.99]);
798
799        // And the whole thing survives a publish/mmap round trip. A List column
800        // has three buffers of its own; if any of them were copied, this drops
801        // below n/n.
802        let root = std::env::temp_dir().join(format!("mira-me-{}", std::process::id()));
803        let _ = std::fs::remove_dir_all(&root);
804        let published =
805            block::publish(&root, "metrics", block::node_id("a"), 1, 0, &sealed).unwrap();
806        for t in ["number_dp", "hist_dp", "hist_bounds", "summary_dp"] {
807            let m = block::open_table(&published.dir.join(format!("{t}.arrow"))).unwrap();
808            let (inside, total) = m.zero_copy_ratio();
809            assert_eq!(inside, total, "{t}: {inside}/{total} buffers zero-copy");
810        }
811        // Nothing in this request had exemplar attributes, so that table is
812        // absent rather than 2.5 KB of framing.
813        assert!(!published.dir.join("exemplar_attrs.arrow").exists());
814        let _ = std::fs::remove_dir_all(&root);
815    }
816
817    /// The read path end to end: publish two blocks, then filter on a resource
818    /// attribute, a record attribute, a dictionary column and a raw field, and
819    /// check that pruning actually skips work rather than merely returning the
820    /// right rows by scanning everything.
821    #[test]
822    fn search_filters_across_attribute_levels_and_prunes_by_time() {
823        use query::{Op, Search, Signal, Target, Term, Value as QV};
824
825        let root = std::env::temp_dir().join(format!("mira-q-{}", std::process::id()));
826        let _ = std::fs::remove_dir_all(&root);
827
828        // Two blocks, two services, disjoint time ranges. Block 1 is older.
829        for (seq, (service, base)) in [("checkout", 1_000u64), ("payments", 5_000)]
830            .into_iter()
831            .enumerate()
832        {
833            let mut b = logs::LogsBuilder::new();
834            b.append_request(&request(service, 10, base)).unwrap();
835            let sealed = b.finish().unwrap();
836            block::publish(&root, "logs", block::node_id("a"), seq as u64, 0, &sealed).unwrap();
837        }
838
839        let q = |terms: Vec<Term>, from: i64, to: i64, limit: usize| Search {
840            signal: Signal::Logs,
841            from,
842            to,
843            terms,
844            limit,
845            after: None,
846        };
847        let attr = |k: &str, v: &str| Term {
848            target: Target::Attr(k.into()),
849            op: Op::Eq,
850            value: QV::Str(v.into()),
851        };
852        let field = |c: &str, op: Op, v: QV| Term {
853            target: Target::Field(c.into()),
854            op,
855            value: v,
856        };
857
858        // service.name is a *resource* attribute; the user does not know that
859        // and should not have to. It resolves through resources -> resource_id.
860        let r = query::search(
861            &root,
862            &q(vec![attr("service.name", "payments")], 0, i64::MAX, 100),
863        )
864        .unwrap();
865        assert_eq!(r.stats.rows_matched, 10);
866        assert!(r.json.contains("\"service.name\":\"payments\""));
867        assert!(!r.json.contains("checkout"));
868        // Time did not prune this — both blocks are in the window — but the
869        // attribute filter did: the "checkout" block never carried the value, so
870        // its sidecar ruled it out before it was opened.
871        assert_eq!(r.stats.blocks_scanned, 1);
872
873        // http.method is a *record* attribute, on every row of both blocks.
874        let r = query::search(
875            &root,
876            &q(vec![attr("http.method", "GET")], 0, i64::MAX, 100),
877        )
878        .unwrap();
879        assert_eq!(r.stats.rows_matched, 20);
880
881        // A key that exists nowhere matches nothing rather than erroring.
882        let r = query::search(&root, &q(vec![attr("nope", "x")], 0, i64::MAX, 100)).unwrap();
883        assert_eq!(r.stats.rows_matched, 0);
884        assert_eq!(r.json, "[]");
885
886        // Time pruning happens on the directory name, before any file is
887        // opened: the older block is never touched.
888        let r = query::search(&root, &q(vec![], 5_000, 9_000, 100)).unwrap();
889        assert_eq!(r.stats.blocks_total, 2);
890        assert_eq!(
891            r.stats.blocks_scanned, 1,
892            "the 1_000-range block must prune"
893        );
894        assert_eq!(r.stats.rows_matched, 10);
895
896        // Dictionary column, substring op: resolved once against the dictionary
897        // and then matched on u16 codes.
898        let r = query::search(
899            &root,
900            &q(
901                vec![field("severity_text", Op::Contains, QV::Str("NF".into()))],
902                0,
903                i64::MAX,
904                100,
905            ),
906        )
907        .unwrap();
908        assert_eq!(r.stats.rows_matched, 20);
909
910        // A numeric field given as a string, which is what a browser and an LLM
911        // both send. Coercion happens once the column's type is known.
912        let r = query::search(
913            &root,
914            &q(
915                vec![field("severity_number", Op::Gte, QV::Str("9".into()))],
916                0,
917                i64::MAX,
918                100,
919            ),
920        )
921        .unwrap();
922        assert_eq!(r.stats.rows_matched, 20);
923
924        // FixedSizeBinary queried as hex.
925        let r = query::search(
926            &root,
927            &q(
928                vec![field("trace_id", Op::Eq, QV::Str("07".repeat(16)))],
929                0,
930                i64::MAX,
931                100,
932            ),
933        )
934        .unwrap();
935        assert_eq!(r.stats.rows_matched, 20);
936        // ...and a malformed one matches nothing instead of a prefix.
937        let r = query::search(
938            &root,
939            &q(
940                vec![field("trace_id", Op::Eq, QV::Str("07".into()))],
941                0,
942                i64::MAX,
943                100,
944            ),
945        )
946        .unwrap();
947        assert_eq!(r.stats.rows_matched, 0);
948
949        // Terms are AND-ed.
950        let r = query::search(
951            &root,
952            &q(
953                vec![attr("service.name", "checkout"), attr("http.method", "GET")],
954                0,
955                i64::MAX,
956                100,
957            ),
958        )
959        .unwrap();
960        assert_eq!(r.stats.rows_matched, 10);
961
962        // THE early exit: with a limit satisfied by the newest block, the older
963        // one is never opened even though it overlaps the window.
964        let r = query::search(&root, &q(vec![], 0, i64::MAX, 5)).unwrap();
965        assert_eq!(r.stats.blocks_scanned, 1);
966        assert_eq!(r.json.matches("\"body\"").count(), 5);
967        // Newest first. The timestamp is quoted because OTLP/JSON says a 64-bit
968        // integer is a string — see [`json::Json::i64_str`].
969        assert!(r.json.starts_with("[{\"time_unix_nano\":\"5009\","));
970        // Block-local plumbing never reaches the caller.
971        assert!(!r.json.contains("resource_id"));
972        assert!(!r.json.contains("\"id\""));
973        // Scope name is synthesised into scope_attrs at ingest and merges in
974        // here, so all three attribute levels are present on one row.
975        assert!(r.json.contains("\"otel.scope.name\":\"test\""));
976
977        let _ = std::fs::remove_dir_all(&root);
978    }
979
980    /// Paging must be a partition of the one-shot answer: every row once, in
981    /// the same order, no matter where the page boundaries land.
982    ///
983    /// The hard part is ties. Four blocks here carry *identical* timestamps, so
984    /// every instant is shared by four rows in four different blocks — which is
985    /// exactly what a boundary falling mid-nanosecond looks like, and exactly
986    /// what a cursor of "the last timestamp I saw" gets wrong. Real ingest makes
987    /// these constantly: a batch of records stamped by one call to the clock.
988    #[test]
989    fn paging_returns_every_row_once_even_across_ties() {
990        use query::{Cursor, Search, Signal};
991
992        let root = std::env::temp_dir().join(format!("mira-page-{}", std::process::id()));
993        let _ = std::fs::remove_dir_all(&root);
994
995        // Two clusters of two blocks. Within a cluster the timestamps are the
996        // same to the nanosecond; the clusters are far apart, so the older one
997        // can be pruned by name once paging has passed it.
998        for seq in 0..4u64 {
999            let base = if seq < 2 { 5_000 } else { 1_000 };
1000            let mut req = request("svc", 25, base);
1001            // Five records per nanosecond, so ties exist *inside* a block as
1002            // well as across them. That matters: within a block rows arrive in
1003            // ascending row order and the sort key runs descending, so a sort
1004            // that ignores the tiebreak reorders them, the page boundary lands
1005            // in the middle of the reordering, and rows come back twice.
1006            for (i, r) in req.resource_logs[0].scope_logs[0]
1007                .log_records
1008                .iter_mut()
1009                .enumerate()
1010            {
1011                r.time_unix_nano = base + i as u64 / 5;
1012            }
1013            let mut b = logs::LogsBuilder::new();
1014            b.append_request(&req).unwrap();
1015            let sealed = b.finish().unwrap();
1016            block::publish(&root, "logs", block::node_id("a"), seq, 0, &sealed).unwrap();
1017        }
1018
1019        let page = |limit: usize, after| {
1020            query::search(
1021                &root,
1022                &Search {
1023                    signal: Signal::Logs,
1024                    from: 0,
1025                    to: i64::MAX,
1026                    terms: vec![],
1027                    limit,
1028                    after,
1029                },
1030            )
1031            .unwrap()
1032        };
1033
1034        let all = page(1_000, None);
1035        assert_eq!(all.json.matches("\"body\"").count(), 100);
1036        assert!(all.next.is_none(), "a short page is the last page");
1037
1038        // 100 rows at 7 a page is 14 full pages and a remainder — so boundaries
1039        // land inside a tie group on most of them.
1040        let mut rows = Vec::new();
1041        let mut after = None;
1042        let mut deepest = 0;
1043        for _ in 0..100 {
1044            let r = page(7, after);
1045            rows.push(r.json[1..r.json.len() - 1].to_owned());
1046            deepest = r.stats.blocks_scanned;
1047            match r.next {
1048                Some(c) => after = Some(c),
1049                None => break,
1050            }
1051        }
1052        assert_eq!(rows.len(), 15);
1053        assert_eq!(
1054            format!("[{}]", rows.join(",")),
1055            all.json,
1056            "paged reads must reassemble the one-shot answer byte for byte"
1057        );
1058
1059        // And the last page is cheaper than the first, not dearer: the two
1060        // newest blocks are ruled out by name once the cursor is past them.
1061        // This is the whole reason the cursor is a key and not an offset.
1062        assert_eq!(all.stats.blocks_scanned, 4);
1063        assert_eq!(deepest, 2);
1064
1065        // A cursor from a different query shape is still just a position, and a
1066        // cursor past the end yields nothing rather than wrapping.
1067        let end = Cursor {
1068            ts: 0,
1069            node: 0,
1070            seq: 0,
1071            row: 0,
1072        };
1073        assert_eq!(page(7, Some(end)).json, "[]");
1074
1075        // Round-trips through the wire form, which is the only form a caller
1076        // ever sees.
1077        let c = page(7, None).next.unwrap();
1078        assert_eq!(c.to_string().parse::<Cursor>(), Ok(c));
1079        for bad in ["", "1.2.3", "1.2.3.4.5", "1.-2.3.4", "a.2.3.4", "1.2.3.x"] {
1080            assert!(bad.parse::<Cursor>().is_err(), "{bad:?} parsed");
1081        }
1082
1083        let _ = std::fs::remove_dir_all(&root);
1084    }
1085
1086    /// "Every span of trace X" carries no time bound, so block names prune
1087    /// nothing and the scan reads the whole signal. The Bloom sidecar is the
1088    /// only thing standing between that query and every block on disk.
1089    #[test]
1090    fn a_trace_lookup_opens_only_the_block_holding_the_trace() {
1091        use mira_proto::collector::trace::v1::ExportTraceServiceRequest;
1092        use mira_proto::trace::v1::{ResourceSpans, ScopeSpans, Span};
1093        use query::{Op, Search, Signal, Target, Term, Value as QV};
1094
1095        let root = std::env::temp_dir().join(format!("mira-bloom-{}", std::process::id()));
1096        let _ = std::fs::remove_dir_all(&root);
1097
1098        // Ids from a hash, not a counter: the filter's premise is that trace ids
1099        // are uniform, and 0,1,2,… would test a distribution that cannot occur.
1100        let tid = |n: u64| {
1101            let mut b = [0u8; 16];
1102            b[..8].copy_from_slice(&identity::hash64(&n.to_le_bytes()).to_le_bytes());
1103            b[8..].copy_from_slice(&identity::hash64(&(!n).to_le_bytes()).to_le_bytes());
1104            b
1105        };
1106
1107        const BLOCKS: u64 = 8;
1108        for seq in 0..BLOCKS {
1109            let mut b = traces::TracesBuilder::new();
1110            // Overlapping time ranges on every block, so nothing here can be
1111            // credited to time pruning.
1112            let spans = (0..4)
1113                .map(|i| Span {
1114                    trace_id: tid(seq * 4 + i).to_vec().into(),
1115                    span_id: vec![i as u8 + 1; 8].into(),
1116                    name: "GET /checkout".into(),
1117                    start_time_unix_nano: 1_000 + i,
1118                    end_time_unix_nano: 1_100 + i,
1119                    ..Default::default()
1120                })
1121                .collect();
1122            b.append_request(&ExportTraceServiceRequest {
1123                resource_spans: vec![ResourceSpans {
1124                    resource: Some(Resource::default()),
1125                    scope_spans: vec![ScopeSpans {
1126                        spans,
1127                        ..Default::default()
1128                    }],
1129                    ..Default::default()
1130                }],
1131            })
1132            .unwrap();
1133            let sealed = b.finish().unwrap();
1134            assert!(
1135                sealed.sidecars.iter().any(|(n, _)| *n == bloom::TRACE_IDX),
1136                "traces publish a trace filter"
1137            );
1138            block::publish(&root, "traces", block::node_id("a"), seq, 0, &sealed).unwrap();
1139        }
1140
1141        // The same ids on the logs side. A trace investigation is two questions
1142        // — the spans, then the logs written under them — and the second one has
1143        // no more of a time bound than the first, so it needs the same filter.
1144        for seq in 0..BLOCKS {
1145            let mut req = request("checkout", 4, 1_000);
1146            for (i, r) in req.resource_logs[0].scope_logs[0]
1147                .log_records
1148                .iter_mut()
1149                .enumerate()
1150            {
1151                r.trace_id = tid(seq * 4 + i as u64).to_vec().into();
1152            }
1153            let mut b = logs::LogsBuilder::new();
1154            b.append_request(&req).unwrap();
1155            let sealed = b.finish().unwrap();
1156            block::publish(&root, "logs", block::node_id("a"), seq, 0, &sealed).unwrap();
1157        }
1158
1159        let lookup = |signal, id: String| Search {
1160            signal,
1161            from: 0,
1162            to: i64::MAX,
1163            terms: vec![Term {
1164                target: Target::Field("trace_id".into()),
1165                op: Op::Eq,
1166                value: QV::Str(id),
1167            }],
1168            limit: 100,
1169            after: None,
1170        };
1171
1172        let hex = |b: [u8; 16]| b.iter().map(|x| format!("{x:02x}")).collect::<String>();
1173        for signal in [Signal::Traces, Signal::Logs] {
1174            let r = query::search(&root, &lookup(signal, hex(tid(17)))).unwrap();
1175            assert_eq!(r.stats.blocks_total, BLOCKS as usize);
1176            assert_eq!(r.stats.blocks_scanned, 1, "{signal:?}: skip the rest");
1177            assert_eq!(r.stats.rows_matched, 1);
1178
1179            // An id in no block at all: with 8 filters probed, a false positive
1180            // is possible, so this asserts the bound rather than zero.
1181            let r = query::search(&root, &lookup(signal, hex(tid(9_999)))).unwrap();
1182            assert_eq!(r.stats.rows_matched, 0);
1183            assert!(r.stats.blocks_scanned <= 1, "{}", r.stats.blocks_scanned);
1184        }
1185
1186        // Deleting a sidecar has to cost a block read, never a lost span.
1187        let one = block::scan(&root, "traces").unwrap();
1188        for b in &one {
1189            std::fs::remove_file(b.dir.join(bloom::TRACE_IDX)).unwrap();
1190        }
1191        let r = query::search(&root, &lookup(Signal::Traces, hex(tid(17)))).unwrap();
1192        assert_eq!(r.stats.blocks_scanned, BLOCKS as usize);
1193        assert_eq!(r.stats.rows_matched, 1);
1194
1195        let _ = std::fs::remove_dir_all(&root);
1196    }
1197
1198    /// A scan wide enough to fan out answers exactly as a narrow one would.
1199    ///
1200    /// Twenty-four blocks is past the width the waves ramp to, so this runs the
1201    /// merge at every wave size there is, with several threads reading mappings
1202    /// at once. What it pins is not speed. Blocks are scanned concurrently and
1203    /// merged in wave order, so a merge that loses the global ordering — or a
1204    /// `Hit` whose block index no longer addresses the mapping it came from —
1205    /// produces a page ordered by wave instead of by timestamp. Every one-block
1206    /// test in this file passes with that bug in place; paging is what catches
1207    /// it, because the boundary between two pages is exactly where a
1208    /// mis-ordering shows up as a row returned twice or not at all.
1209    ///
1210    /// The serial half is forced, not hoped for: holding the whole fan-out
1211    /// budget is the only way to make a wave one block wide on demand, and
1212    /// without it this would be two runs of whatever the machine felt like
1213    /// doing. The other half takes the budget back and runs at whatever width
1214    /// `cargo test`'s own parallelism leaves free, which is the arrangement
1215    /// every other multi-block test in this file is already under.
1216    #[test]
1217    fn a_scan_wide_enough_to_fan_out_answers_exactly_as_a_narrow_one() {
1218        use query::{Search, Signal};
1219
1220        let root = std::env::temp_dir().join(format!("mira-fanout-{}", std::process::id()));
1221        let _ = std::fs::remove_dir_all(&root);
1222
1223        // Interleaved in time, not stacked: block 7 holds rows older than block
1224        // 3's and newer than block 11's, so no wave boundary lines up with a
1225        // timestamp boundary and the merge has to do real work on every one.
1226        const BLOCKS: u64 = 24;
1227        for seq in 0..BLOCKS {
1228            let base = 1_000_000 + (seq * 7 % BLOCKS) * 1_000;
1229            let mut req = request("svc", 10, base);
1230            for (i, r) in req.resource_logs[0].scope_logs[0]
1231                .log_records
1232                .iter_mut()
1233                .enumerate()
1234            {
1235                r.time_unix_nano = base + i as u64;
1236            }
1237            let mut b = logs::LogsBuilder::new();
1238            b.append_request(&req).unwrap();
1239            let sealed = b.finish().unwrap();
1240            block::publish(&root, "logs", block::node_id("a"), seq, 0, &sealed).unwrap();
1241        }
1242
1243        let page = |limit: usize, after| {
1244            query::search(
1245                &root,
1246                &Search {
1247                    signal: Signal::Logs,
1248                    from: 0,
1249                    to: i64::MAX,
1250                    terms: vec![],
1251                    limit,
1252                    after,
1253                },
1254            )
1255            .unwrap()
1256        };
1257
1258        // Serial: the whole fan-out budget is held elsewhere, so every wave is
1259        // one block wide and the loop is the one this replaced.
1260        let held = query::Helpers::claim(usize::MAX);
1261        let serial = page(1_000, None);
1262        drop(held);
1263
1264        // No `limit` to exit on, so every block is read and every wave runs.
1265        let all = page(1_000, None);
1266        assert_eq!(all.stats.blocks_scanned, BLOCKS as usize);
1267        assert_eq!(all.json.matches("\"body\"").count(), 240);
1268        assert_eq!(all.json, serial.json, "fanning out changed the answer");
1269
1270        let mut rows = Vec::new();
1271        let mut after = None;
1272        for _ in 0..100 {
1273            let r = page(7, after);
1274            rows.push(r.json[1..r.json.len() - 1].to_owned());
1275            match r.next {
1276                Some(c) => after = Some(c),
1277                None => break,
1278            }
1279        }
1280        assert_eq!(
1281            format!("[{}]", rows.join(",")),
1282            all.json,
1283            "a fanned-out scan must page like a serial one"
1284        );
1285
1286        let _ = std::fs::remove_dir_all(&root);
1287    }
1288
1289    /// A three-service call chain, walked with the frame algebra (section 7.3).
1290    ///
1291    /// One fixture for the whole of `frame.rs`, because the interesting claims
1292    /// are all about how its pieces relate and each of them needs the same
1293    /// store. In order:
1294    ///
1295    /// - `anchor` gives back the trace and the *one* service of the row that
1296    ///   matched, not everything in the window.
1297    /// - The expanders do not commute. `peers` alone finds nothing, because the
1298    ///   log that anchored the frame was written seconds after the request it
1299    ///   describes and the trace blocks do not overlap that window at all;
1300    ///   `traces` first measures the real extent and then `peers` finds all
1301    ///   three. This is the bug the algebra exists to make unwriteable, and it
1302    ///   is silent — the wrong order returns a confident, short answer.
1303    /// - `map` reconstructs the topology from `parent_span_id` alone, including
1304    ///   the entry edge for the root span and the error the leaf reported.
1305    #[test]
1306    fn a_frame_walk_reconstructs_a_call_chain_from_one_log_line() {
1307        use mira_proto::collector::trace::v1::ExportTraceServiceRequest;
1308        use mira_proto::trace::v1::{ResourceSpans, ScopeSpans, Span, Status};
1309        use query::{Op, Search, Signal, Target, Term, Value as QV};
1310
1311        let root = std::env::temp_dir().join(format!("mira-frame-{}", std::process::id()));
1312        let _ = std::fs::remove_dir_all(&root);
1313
1314        // Spans start here; the logs are written five seconds later, which is
1315        // what makes the widening observable rather than incidental.
1316        const T0: u64 = 1_000_000_000;
1317        const LOG_TS: u64 = T0 + 5_000_000_000;
1318        let tid = |n: u8| [n; 16];
1319        let sid = |svc: u8, n: u8| [svc, n, 0, 0, 0, 0, 0, 0];
1320
1321        // gateway -> api -> db, three traces, the third failing at the leaf.
1322        let svc = |name: &str, spans: Vec<Span>| ResourceSpans {
1323            resource: Some(Resource {
1324                // The same pair `request` writes on the logs side, so the two
1325                // signals land on the same `resources.key` and the join below
1326                // is a real cross-signal join rather than two disjoint sets
1327                // that happen to be counted together.
1328                attributes: vec![kv("service.name", name), kv("service.instance.id", "7f3a")],
1329                ..Default::default()
1330            }),
1331            scope_spans: vec![ScopeSpans {
1332                spans,
1333                ..Default::default()
1334            }],
1335            ..Default::default()
1336        };
1337        let span = |t: u8, id: [u8; 8], parent: Option<[u8; 8]>, dur: u64, bad: bool| Span {
1338            trace_id: tid(t).to_vec().into(),
1339            span_id: id.to_vec().into(),
1340            parent_span_id: parent.map(|p| p.to_vec()).unwrap_or_default().into(),
1341            name: "GET /checkout".into(),
1342            start_time_unix_nano: T0 + u64::from(t),
1343            end_time_unix_nano: T0 + u64::from(t) + dur,
1344            status: bad.then(|| Status {
1345                code: 2,
1346                ..Default::default()
1347            }),
1348            ..Default::default()
1349        };
1350        let mut b = traces::TracesBuilder::new();
1351        b.append_request(&ExportTraceServiceRequest {
1352            resource_spans: vec![
1353                svc(
1354                    "gateway",
1355                    (1..=3)
1356                        .map(|t| span(t, sid(1, t), None, 900, false))
1357                        .collect(),
1358                ),
1359                svc(
1360                    "api",
1361                    (1..=3)
1362                        .map(|t| span(t, sid(2, t), Some(sid(1, t)), 500, false))
1363                        .collect(),
1364                ),
1365                svc(
1366                    "db",
1367                    (1..=3)
1368                        .map(|t| span(t, sid(3, t), Some(sid(2, t)), 100, t == 3))
1369                        .collect(),
1370                ),
1371            ],
1372        })
1373        .unwrap();
1374        let sealed = b.finish().unwrap();
1375        block::publish(&root, "traces", block::node_id("a"), 0, 0, &sealed).unwrap();
1376
1377        // One log line per trace, from `api`, long after the request finished —
1378        // the shape of an async worker logging what it did.
1379        let mut req = request("api", 3, LOG_TS);
1380        for (i, r) in req.resource_logs[0].scope_logs[0]
1381            .log_records
1382            .iter_mut()
1383            .enumerate()
1384        {
1385            r.trace_id = tid(i as u8 + 1).to_vec().into();
1386            r.body = Some(AnyValue {
1387                value: Some(Value::StringValue(if i == 2 {
1388                    "boom".into()
1389                } else {
1390                    "fine".into()
1391                })),
1392            });
1393        }
1394        let mut b = logs::LogsBuilder::new();
1395        b.append_request(&req).unwrap();
1396        let sealed = b.finish().unwrap();
1397        block::publish(&root, "logs", block::node_id("a"), 0, 0, &sealed).unwrap();
1398
1399        // "The error I am looking at", as a search a UI would send.
1400        let q = Search {
1401            signal: Signal::Logs,
1402            from: LOG_TS as i64 - 1_000_000_000,
1403            to: LOG_TS as i64 + 1_000_000_000,
1404            terms: vec![Term {
1405                target: Target::Field("body".into()),
1406                op: Op::Eq,
1407                value: QV::Str("boom".into()),
1408            }],
1409            limit: 10,
1410            after: None,
1411        };
1412        let (f, st) = frame::anchor(&root, &q, &[]).unwrap();
1413        assert_eq!(f.traces, vec![tid(3)], "one row matched, so one trace");
1414        assert_eq!(f.entities.len(), 1, "the service that wrote the line, only");
1415        let api = f.entities[0];
1416        assert!(!f.truncated);
1417        assert_eq!(st.rows_matched, 1);
1418
1419        // Wrong order: the trace blocks end five seconds before this window
1420        // opens, so there is nothing to join against.
1421        let (early, _) = frame::expand(&root, &f, &[frame::Expand::Peers], &[]).unwrap();
1422        assert_eq!(early.entities, f.entities, "peers before traces finds none");
1423
1424        let ops = [frame::Expand::Traces, frame::Expand::Peers];
1425        let (walked, st) = frame::expand(&root, &f, &ops, &[]).unwrap();
1426        assert_eq!(
1427            walked.from,
1428            T0 as i64 + 3,
1429            "the window reaches back to the first span of the trace"
1430        );
1431        assert_eq!(walked.to, q.to, "the traces end inside the window already");
1432        assert_eq!(walked.entities.len(), 3, "gateway, api and db");
1433        // Six span rows read for three matches, twice — once per expander.
1434        assert_eq!((st.rows_scanned, st.rows_matched), (18, 6));
1435
1436        let names = frame::names_of(&root, &walked, &[]).unwrap();
1437        assert_eq!(
1438            names.get(&api).map(String::as_str),
1439            Some("api"),
1440            "the key harvested from a log row is the key the spans carry"
1441        );
1442        let mut got: Vec<&str> = names.values().map(String::as_str).collect();
1443        got.sort_unstable();
1444        assert_eq!(got, ["api", "db", "gateway"]);
1445
1446        // The topology, from `parent_span_id` and nothing else.
1447        let m = frame::map(&root, 0, i64::MAX, 1_000_000, &[]).unwrap();
1448        let edge = |from: &str, to: &str| {
1449            let (fk, tk) = (key_of(&names, from), key_of(&names, to));
1450            let from = if from == "entry" {
1451                "\"entry\"".to_owned()
1452            } else {
1453                format!("\"{fk}\"")
1454            };
1455            format!("\"from\":{from},\"to\":\"{tk}\"")
1456        };
1457        for (a, b) in [("entry", "gateway"), ("gateway", "api"), ("api", "db")] {
1458            assert!(m.json.contains(&edge(a, b)), "{a} -> {b} in {}", m.json);
1459        }
1460        assert!(
1461            m.json.contains("\"unresolved\":0"),
1462            "every parent is in the same block: {}",
1463            m.json
1464        );
1465        assert_eq!(
1466            m.json.matches("\"errors\":1").count(),
1467            2,
1468            "the failing leaf shows on its node and on its one in-edge: {}",
1469            m.json
1470        );
1471
1472        // The facet a service picker is built from.
1473        let e = frame::entities(&root, 0, i64::MAX, &[]).unwrap();
1474        assert_eq!(e.stats.rows_matched, 3);
1475        assert!(
1476            e.json.find("gateway") > e.json.find("db"),
1477            "sorted by name, for a human: {}",
1478            e.json
1479        );
1480
1481        let _ = std::fs::remove_dir_all(&root);
1482    }
1483
1484    /// The `resources.key` a name maps back to, for asserting on rendered ids.
1485    fn key_of(names: &std::collections::HashMap<u64, String>, name: &str) -> u64 {
1486        names
1487            .iter()
1488            .find(|(_, v)| *v == name)
1489            .map_or(0, |(k, _)| *k)
1490    }
1491
1492    /// The attribute filter has one dangerous property, and every case here is
1493    /// an instance of it: a query scalar is compared against whatever type the
1494    /// SDK happened to store, so `{eq: "200"}` finds an integer `200` and
1495    /// `{eq: true}` finds a boolean. A filter that indexed the typed bytes would
1496    /// disagree with that rule and prune the block holding the row — and a
1497    /// pruned block is not a slow query, it is a row that silently does not
1498    /// exist. Indexing the value's *text* is what keeps the two in step.
1499    #[test]
1500    fn the_attribute_filter_skips_blocks_without_losing_coercions() {
1501        use query::{Op, Search, Signal, Target, Term, Value as QV};
1502
1503        let root = std::env::temp_dir().join(format!("mira-attrs-{}", std::process::id()));
1504        let _ = std::fs::remove_dir_all(&root);
1505
1506        let attr = |k: &str, v: Value| KeyValue {
1507            key: k.into(),
1508            value: Some(AnyValue { value: Some(v) }),
1509        };
1510
1511        const BLOCKS: u64 = 8;
1512        for seq in 0..BLOCKS {
1513            // Overlapping time ranges, so nothing below can be credited to the
1514            // block name.
1515            let mut req = request("checkout", 2, 1_000);
1516            let mut attrs = vec![attr(
1517                "k8s.pod.name",
1518                Value::StringValue(format!("api-{seq}")),
1519            )];
1520            // One block carries the three non-string types.
1521            if seq == 3 {
1522                attrs.push(attr("http.status_code", Value::IntValue(200)));
1523                attrs.push(attr("retry", Value::BoolValue(true)));
1524                attrs.push(attr("ratio", Value::DoubleValue(1.5)));
1525                // The same kind of number, stored as text — which is what a
1526                // good half of the SDKs emitting a status code actually send.
1527                attrs.push(attr("status.text", Value::StringValue("404".into())));
1528            }
1529            for r in &mut req.resource_logs[0].scope_logs[0].log_records {
1530                r.attributes = attrs.clone();
1531            }
1532            let mut b = logs::LogsBuilder::new();
1533            b.append_request(&req).unwrap();
1534            let sealed = b.finish().unwrap();
1535            let names: Vec<_> = sealed.sidecars.iter().map(|(n, _)| *n).collect();
1536            assert_eq!(
1537                names,
1538                [bloom::ATTR_IDX, zone::ZONE_IDX, bloom::TRACE_IDX],
1539                "logs sidecars"
1540            );
1541            block::publish(&root, "logs", block::node_id("a"), seq, 0, &sealed).unwrap();
1542        }
1543
1544        let find_op = |key: &str, op: Op, value: QV| {
1545            query::search(
1546                &root,
1547                &Search {
1548                    signal: Signal::Logs,
1549                    from: 0,
1550                    to: i64::MAX,
1551                    terms: vec![Term {
1552                        target: Target::Attr(key.into()),
1553                        op,
1554                        value,
1555                    }],
1556                    limit: 100,
1557                    after: None,
1558                },
1559            )
1560            .unwrap()
1561        };
1562        let find = |key: &str, value: QV| find_op(key, Op::Eq, value);
1563        let s = |v: &str| QV::Str(v.into());
1564
1565        // The plain case: one block holds the value, seven are ruled out
1566        // without being opened.
1567        let r = find("k8s.pod.name", s("api-5"));
1568        assert_eq!(r.stats.blocks_total, BLOCKS as usize);
1569        assert_eq!(r.stats.blocks_scanned, 1, "the filter must skip the rest");
1570        assert_eq!(r.stats.rows_matched, 2);
1571
1572        // A value in no block at all. This is the query the filter exists for —
1573        // it has no early exit, so unfiltered it reads all of retention to prove
1574        // a negative. One false positive is allowed for; eight would mean the
1575        // filter is not working.
1576        let r = find("k8s.pod.name", s("api-99"));
1577        assert_eq!(r.stats.rows_matched, 0);
1578        assert!(r.stats.blocks_scanned <= 1, "{}", r.stats.blocks_scanned);
1579
1580        // The coercions. Each of these must find the two rows in block 3.
1581        for (key, value) in [
1582            ("http.status_code", QV::Int(200)),
1583            ("http.status_code", s("200")),
1584            // A whole double reaches an integer column through `as_i64`.
1585            ("http.status_code", QV::Double(200.0)),
1586            ("retry", QV::Bool(true)),
1587            ("retry", s("true")),
1588            // Doubles are not indexed at all; block 3 declares it holds some and
1589            // is scanned, and the other seven are still skipped.
1590            ("ratio", QV::Double(1.5)),
1591            ("ratio", s("1.5")),
1592        ] {
1593            let r = find(key, value.clone());
1594            assert_eq!(r.stats.rows_matched, 2, "{key} = {value:?}");
1595        }
1596
1597        // The reverse direction, which used to be the asymmetry: every other
1598        // arm parses a string, so `eq: "200"` finds an integer column, but the
1599        // string column read only `Value::Str` and `eq: 404` against text was
1600        // unconditionally false. The index never agreed — it holds the text
1601        // "404" either way and pointed straight at the block.
1602        let r = find("status.text", QV::Int(404));
1603        assert_eq!(r.stats.rows_matched, 2);
1604        assert_eq!(r.stats.blocks_scanned, 1, "and it is still pruned");
1605
1606        // Ordering against a number stored as text is numeric, not
1607        // lexicographic. "404" is below 500 both ways, but above 99 only one of
1608        // them — a byte comparison puts '4' before '9' and answers no.
1609        //
1610        // These are also the queries the zone map exists for: an ordering has no
1611        // value to hash, so the attribute filter cannot rule out a single block
1612        // and all eight used to be opened to find the one. The map has to agree
1613        // with the numeric reading above — it folds the parseable text into its
1614        // double range for exactly that reason.
1615        let r = find_op("status.text", Op::Lt, QV::Int(500));
1616        assert_eq!(r.stats.rows_matched, 2);
1617        assert_eq!(r.stats.blocks_scanned, 1, "the zone map must skip the rest");
1618        let r = find_op("status.text", Op::Gt, QV::Int(99));
1619        assert_eq!(r.stats.rows_matched, 2);
1620        assert_eq!(r.stats.blocks_scanned, 1);
1621        // An ordering no block can reach opens nothing at all. Block 3's only
1622        // status code is 200; the other seven have no such key, and a key the
1623        // map does not hold is a key with no comparable value.
1624        let r = find_op("http.status_code", Op::Gte, QV::Int(500));
1625        assert_eq!(r.stats.rows_matched, 0);
1626        assert_eq!(r.stats.blocks_scanned, 0);
1627        // A quoted scalar asked for a text comparison and still gets one.
1628        assert_eq!(
1629            find_op("status.text", Op::Gt, s("99")).stats.rows_matched,
1630            0
1631        );
1632        // `contains` renders the scalar rather than refusing it.
1633        assert_eq!(
1634            find_op("status.text", Op::Contains, QV::Int(40))
1635                .stats
1636                .rows_matched,
1637            2
1638        );
1639
1640        // A string that cannot be read as a number must still prune the block
1641        // that holds doubles — otherwise `HAS_DOUBLE` would disable the filter
1642        // for every text query in a block with one float in it.
1643        let r = find("ratio", s("banana"));
1644        assert_eq!(r.stats.rows_matched, 0);
1645        assert!(r.stats.blocks_scanned <= 1, "{}", r.stats.blocks_scanned);
1646
1647        // Deleting a sidecar has to cost a block read, never a lost row. Both
1648        // of them: a block published before either index existed still answers
1649        // every query, slowly.
1650        for b in block::scan(&root, "logs").unwrap() {
1651            std::fs::remove_file(b.dir.join(bloom::ATTR_IDX)).unwrap();
1652            std::fs::remove_file(b.dir.join(zone::ZONE_IDX)).unwrap();
1653        }
1654        let r = find("k8s.pod.name", s("api-5"));
1655        assert_eq!(r.stats.blocks_scanned, BLOCKS as usize);
1656        assert_eq!(r.stats.rows_matched, 2);
1657        let r = find_op("status.text", Op::Lt, QV::Int(500));
1658        assert_eq!(r.stats.blocks_scanned, BLOCKS as usize);
1659        assert_eq!(r.stats.rows_matched, 2);
1660
1661        let _ = std::fs::remove_dir_all(&root);
1662    }
1663
1664    /// The headroom hint is a conservative guess, and a big export makes it
1665    /// guess wrong. It has to stay wrong in that direction — counting distinct
1666    /// keys would mean hashing every request on the hot path — so what matters
1667    /// is that the guess is never mistaken for the real ceiling.
1668    ///
1669    /// `pipeline::flusher` relies on exactly this: on an empty block it skips
1670    /// the hint and appends, because the hint saying no is not evidence that the
1671    /// data does not fit.
1672    #[test]
1673    fn a_large_export_the_headroom_hint_rejects_still_fits_one_block() {
1674        // 20k records x 4 attributes = 80,000 attribute rows, past DICT_CAP,
1675        // from a grand total of five distinct keys.
1676        let mut req = request("checkout", 20_000, 1_000);
1677        for r in &mut req.resource_logs[0].scope_logs[0].log_records {
1678            r.attributes = vec![
1679                kv("http.method", "GET"),
1680                kv("http.route", "/checkout"),
1681                kv("net.peer.name", "db"),
1682                kv("http.scheme", "https"),
1683            ];
1684        }
1685
1686        let mut b = logs::LogsBuilder::new();
1687        assert!(
1688            !b.has_headroom_for(&req),
1689            "the hint is expected to be conservative here; if it stopped being \
1690             so, this test is no longer testing anything"
1691        );
1692        // ...and yet.
1693        assert_eq!(b.append_request(&req).unwrap(), 20_000);
1694        let sealed = b.finish().unwrap();
1695        assert_eq!(sealed.num_rows, 20_000);
1696        assert_eq!(sealed.table("log_attrs").unwrap().num_rows(), 80_000);
1697    }
1698
1699    /// The cold tier: an aged block is rewritten compressed in place, and every
1700    /// read path keeps working over a directory that is mid-rewrite.
1701    #[test]
1702    fn compaction_shrinks_aged_blocks_without_changing_what_they_answer() {
1703        use query::{Op, Search, Signal, Target, Term, Value as QV};
1704
1705        let root = std::env::temp_dir().join(format!("mira-cold-{}", std::process::id()));
1706        let _ = std::fs::remove_dir_all(&root);
1707
1708        // One block an hour and a half old, one from a minute ago.
1709        let now = 4 * block::COLD_AFTER_NS;
1710        let old = (now - 90 * 60 * 1_000_000_000) as u64;
1711        let new = (now - 60 * 1_000_000_000) as u64;
1712        let mut dirs = Vec::new();
1713        for (seq, base) in [old, new].into_iter().enumerate() {
1714            let mut b = logs::LogsBuilder::new();
1715            b.append_request(&request("checkout", 500, base)).unwrap();
1716            let sealed = b.finish().unwrap();
1717            let node = block::node_id("a");
1718            dirs.push(
1719                block::publish(&root, "logs", node, seq as u64, 0, &sealed)
1720                    .unwrap()
1721                    .dir,
1722            );
1723        }
1724        let size = |dir: &std::path::Path| {
1725            std::fs::read_dir(dir)
1726                .unwrap()
1727                .filter_map(|e| e.ok())
1728                .filter(|e| e.path().extension().is_some_and(|x| x == "arrow"))
1729                .map(|e| e.metadata().unwrap().len())
1730                .sum::<u64>()
1731        };
1732        let before = size(&dirs[0]);
1733
1734        // A crash between two tables leaves a directory holding both tiers. The
1735        // codec is per-batch IPC metadata, so that has to read, and the next
1736        // sweep has to finish rather than skip it.
1737        let partial = dirs[0].join("log_attrs.arrow");
1738        let staged = dirs[0].join("log_attrs.staged");
1739        let batch = block::open_table(&partial).unwrap().batches[0].clone();
1740        // Staged then renamed, not written over the live name: `batch` still
1741        // points into the mapping of `partial`, and truncating a mapped file is
1742        // a SIGBUS on the next page touched. This is the constraint `compact`
1743        // is built around, not a detail of the test.
1744        block::write_table_zstd(&staged, &batch).unwrap();
1745        drop(batch);
1746        std::fs::rename(&staged, &partial).unwrap();
1747        assert!(block::open_table(&dirs[0].join("logs.arrow")).is_ok());
1748
1749        let n = block::compact(
1750            &root,
1751            "logs",
1752            block::node_id("a"),
1753            now - block::COLD_AFTER_NS,
1754        )
1755        .unwrap();
1756        assert_eq!(n, 1, "only the aged block is cold");
1757        assert!(dirs[0].join("cold").exists());
1758        assert!(!dirs[1].join("cold").exists(), "the fresh block is hot");
1759        let after = size(&dirs[0]);
1760        assert!(after * 2 < before, "{before} -> {after} is not a tier");
1761
1762        // Idempotent: the marker means the second sweep does no IO at all.
1763        let stamp = std::fs::metadata(dirs[0].join("logs.arrow"))
1764            .unwrap()
1765            .modified()
1766            .unwrap();
1767        assert_eq!(
1768            block::compact(
1769                &root,
1770                "logs",
1771                block::node_id("a"),
1772                now - block::COLD_AFTER_NS
1773            )
1774            .unwrap(),
1775            0
1776        );
1777        assert_eq!(
1778            std::fs::metadata(dirs[0].join("logs.arrow"))
1779                .unwrap()
1780                .modified()
1781                .unwrap(),
1782            stamp
1783        );
1784
1785        // The trade, stated: the cold block gives up zero-copy, the hot one does
1786        // not. Nothing else about either read changes.
1787        let cold = block::open_table(&dirs[0].join("logs.arrow")).unwrap();
1788        let (inside, total) = cold.zero_copy_ratio();
1789        assert_eq!(cold.batches[0].num_rows(), 500);
1790        // Not zero: arrow leaves an empty buffer — an all-valid null mask — as a
1791        // zero-length slice of the mapping rather than allocating nothing.
1792        assert!(
1793            inside < total,
1794            "{inside}/{total} — a decompressed buffer is a copy"
1795        );
1796        let hot = block::open_table(&dirs[1].join("logs.arrow")).unwrap();
1797        let (inside, total) = hot.zero_copy_ratio();
1798        assert_eq!(inside, total, "the hot tier stays zero-copy");
1799
1800        // And the answer is the same one the plain block gave: both blocks, both
1801        // attribute levels, through the sidecars that compaction left alone.
1802        let r = query::search(
1803            &root,
1804            &Search {
1805                signal: Signal::Logs,
1806                from: 0,
1807                to: i64::MAX,
1808                terms: vec![Term {
1809                    target: Target::Attr("service.name".into()),
1810                    op: Op::Eq,
1811                    value: QV::Str("checkout".into()),
1812                }],
1813                limit: 2_000,
1814                after: None,
1815            },
1816        )
1817        .unwrap();
1818        assert_eq!(r.stats.rows_matched, 1_000);
1819        assert_eq!(r.stats.blocks_scanned, 2);
1820
1821        let _ = std::fs::remove_dir_all(&root);
1822    }
1823
1824    /// The startup guard has to say yes to an ordinary local directory. It
1825    /// cannot be tested against a real NFS mount here, so this is the half that
1826    /// catches the failure that would actually happen: a guard that refuses
1827    /// everything, or one that errors on a path it should ignore.
1828    #[test]
1829    fn the_filesystem_guard_passes_a_local_directory() {
1830        let dir = std::env::temp_dir().join(format!("mira-fs-{}", std::process::id()));
1831        std::fs::create_dir_all(&dir).unwrap();
1832        block::check_filesystem(&dir).unwrap();
1833        // A path that does not exist is not a filesystem verdict.
1834        block::check_filesystem(&dir.join("nope")).unwrap();
1835        let _ = std::fs::remove_dir_all(&dir);
1836    }
1837
1838    /// The other startup guard. `create_dir_all` says yes to a directory that
1839    /// already exists whatever its mode, so without this a read-only data
1840    /// directory reaches a listening socket and fails one export at a time.
1841    #[test]
1842    fn the_write_guard_refuses_a_read_only_directory() {
1843        use std::os::unix::fs::PermissionsExt;
1844
1845        let dir = std::env::temp_dir().join(format!("mira-w-{}", std::process::id()));
1846        let _ = std::fs::remove_dir_all(&dir);
1847        std::fs::create_dir_all(&dir).unwrap();
1848        block::check_writable(&dir).unwrap();
1849        // And it left nothing behind.
1850        assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 0);
1851
1852        std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o555)).unwrap();
1853        // Root defeats the mode bits entirely, so ask the filesystem rather
1854        // than assume: where a bare write still succeeds, the guard passing is
1855        // the right answer and there is nothing here to assert.
1856        let blocked = std::fs::write(dir.join("canary"), []).is_err();
1857        let verdict = block::check_writable(&dir);
1858        assert_eq!(
1859            verdict.is_err(),
1860            blocked,
1861            "the guard must agree with the filesystem: {verdict:?}"
1862        );
1863        assert!(
1864            !blocked || matches!(verdict, Err(Error::NotWritable { .. })),
1865            "{verdict:?}"
1866        );
1867        std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o755)).unwrap();
1868
1869        // A directory that is not there at all is a different error, but still
1870        // an error rather than a successful start.
1871        assert!(block::check_writable(&dir.join("nope")).is_err());
1872        let _ = std::fs::remove_dir_all(&dir);
1873    }
1874
1875    #[test]
1876    fn corrupt_body_is_caught_not_returned_as_data() {
1877        let dir = std::env::temp_dir().join(format!("mira-crc-{}", std::process::id()));
1878        std::fs::create_dir_all(&dir).unwrap();
1879        let path = dir.join("logs.arrow");
1880
1881        let mut b = logs::LogsBuilder::new();
1882        b.append_request(&request("checkout", 64, 1_000)).unwrap();
1883        let sealed = b.finish().unwrap();
1884        block::write_table(&path, sealed.table("logs").unwrap()).unwrap();
1885        assert!(block::open_table(&path).is_ok());
1886
1887        // Flip one bit deep in the body. Arrow's own reader would decode this
1888        // into wrong answers without complaint.
1889        let mut bytes = std::fs::read(&path).unwrap();
1890        let mid = bytes.len() / 2;
1891        bytes[mid] ^= 0x01;
1892        std::fs::write(&path, &bytes).unwrap();
1893
1894        assert!(matches!(
1895            block::open_table(&path),
1896            Err(Error::BadChecksum { .. })
1897        ));
1898
1899        // Truncated from the front, and not an Arrow file at all. arrow-rs seeks
1900        // straight to the trailer and never looks at the magic, so both of these
1901        // reach the decoder as garbage unless we check first.
1902        for junk in [&b"ARROW"[..], &[0u8; 64][..]] {
1903            std::fs::write(&path, junk).unwrap();
1904            assert!(
1905                matches!(block::open_table(&path), Err(Error::BadMagic { .. })),
1906                "{junk:?} decoded as a block"
1907            );
1908        }
1909
1910        // A real Arrow file, written by arrow-rs rather than by us, so the magic
1911        // and the footer are both valid and only the checksum is absent. Reading
1912        // it would be reading something no version of Mira wrote.
1913        let batch = sealed.table("logs").unwrap();
1914        let mut w = arrow_ipc::writer::FileWriter::try_new(
1915            std::fs::File::create(&path).unwrap(),
1916            &batch.schema(),
1917        )
1918        .unwrap();
1919        w.write(batch).unwrap();
1920        w.finish().unwrap();
1921        // `.err().expect` rather than `expect_err`: a `MappedTable` is an mmap
1922        // and a schema, and deriving `Debug` on it to print one in a test that
1923        // must never see one is the wrong direction.
1924        let e = block::open_table(&path)
1925            .err()
1926            .expect("a file with no checksum in it was read as a block");
1927        assert!(matches!(e, Error::MissingMetadata { .. }), "{e}");
1928
1929        let _ = std::fs::remove_dir_all(&dir);
1930    }
1931
1932    /// The block directory is the manifest, which means everything else on the
1933    /// volume is noise it has to survive: a `.DS_Store`, a directory somebody
1934    /// else made, a block half-published by a process that was killed.
1935    ///
1936    /// None of these are hypothetical. Replicas share a volume by design (section 10),
1937    /// and the catalog is rebuilt by `readdir` on every start.
1938    #[test]
1939    fn the_catalog_ignores_everything_it_did_not_write() {
1940        let root = std::env::temp_dir().join(format!("mira-junk-{}", std::process::id()));
1941        let _ = std::fs::remove_dir_all(&root);
1942        let node = block::node_id("a");
1943
1944        let seal = |base: u64| {
1945            let mut b = logs::LogsBuilder::new();
1946            b.append_request(&request("checkout", 4, base)).unwrap();
1947            b.finish().unwrap()
1948        };
1949        let published = block::publish(&root, "logs", node, 0, 0, &seal(1_000)).unwrap();
1950        let partition = published.dir.parent().unwrap().to_path_buf();
1951        let name = published.dir.file_name().unwrap().to_str().unwrap();
1952
1953        // A file where a partition directory should be, a directory whose name
1954        // is not a block name, and a block name with one field too many — which
1955        // is what a future format version would look like from here.
1956        std::fs::write(root.join("logs").join(".DS_Store"), b"junk").unwrap();
1957        for junk in ["not-a-block", &format!("{name}-1")] {
1958            std::fs::create_dir_all(partition.join(junk)).unwrap();
1959        }
1960        assert_eq!(block::scan(&root, "logs").unwrap(), vec![published.clone()]);
1961
1962        // A publish killed between staging and rename leaves the staging
1963        // directory behind. The name is unique per block, so nothing reuses it
1964        // and it is `sweep_staging` at boot that clears it — filtered by signal
1965        // and node, so a directory belonging to another replica sharing the
1966        // volume, or to another signal, survives untouched.
1967        let stale = root
1968            .join(".tmp")
1969            .join(format!("logs-{node:08x}-{:012}-x", 1));
1970        let other_node = root.join(".tmp").join("logs-deadbeef-000000000001-x");
1971        let other_signal = root.join(".tmp").join(format!("traces-{node:08x}-1-x"));
1972        for d in [&stale, &other_node, &other_signal] {
1973            std::fs::create_dir_all(d).unwrap();
1974            std::fs::write(d.join("logs.arrow"), b"leftovers").unwrap();
1975        }
1976        assert_eq!(block::sweep_staging(&root, "logs", node).unwrap(), 1);
1977        assert!(!stale.exists());
1978        assert!(other_node.is_dir() && other_signal.is_dir());
1979        // Sweeping a data directory that never staged anything is not an error.
1980        assert_eq!(
1981            block::sweep_staging(&root.join("nope"), "logs", node).unwrap(),
1982            0
1983        );
1984
1985        let second = block::publish(&root, "logs", node, 1, 0, &seal(2_000)).unwrap();
1986        assert!(block::open_table(&second.dir.join("logs.arrow")).is_ok());
1987        assert_eq!(block::scan(&root, "logs").unwrap().len(), 2);
1988
1989        // And retention takes the block, not the junk beside it.
1990        assert_eq!(block::expire(&root, "logs", i64::MAX).unwrap(), 2);
1991        assert!(block::scan(&root, "logs").unwrap().is_empty());
1992        assert!(partition.join("not-a-block").is_dir());
1993
1994        let _ = std::fs::remove_dir_all(&root);
1995    }
1996
1997    /// Every codec writes a file the reader reads back identically. LZ4 is not
1998    /// written by the engine — it exists so the `tier` example can price the
1999    /// pure-Rust codec against the C one — and an untested writer is how that
2000    /// comparison ends up measuring a bug.
2001    #[test]
2002    fn every_codec_round_trips_the_same_rows() {
2003        let dir = std::env::temp_dir().join(format!("mira-codec-{}", std::process::id()));
2004        let _ = std::fs::remove_dir_all(&dir);
2005        std::fs::create_dir_all(&dir).unwrap();
2006
2007        let mut b = logs::LogsBuilder::new();
2008        b.append_request(&request("checkout", 500, 1_000)).unwrap();
2009        let sealed = b.finish().unwrap();
2010        let batch = sealed.table("logs").unwrap();
2011
2012        type Writer = fn(&std::path::Path, &arrow_array::RecordBatch) -> Result<()>;
2013        let write: [(&str, Writer); 3] = [
2014            ("plain", block::write_table),
2015            ("zstd", block::write_table_zstd),
2016            ("lz4", block::write_table_lz4),
2017        ];
2018        let mut sizes = Vec::new();
2019        for (name, f) in write {
2020            let path = dir.join(format!("{name}.arrow"));
2021            f(&path, batch).unwrap();
2022            let read = block::open_table(&path).unwrap();
2023            assert_eq!(read.batches, vec![batch.clone()], "{name}");
2024            sizes.push((name, std::fs::metadata(&path).unwrap().len()));
2025        }
2026        let plain = sizes[0].1;
2027        for (name, size) in &sizes[1..] {
2028            assert!(*size < plain, "{name} is {size} against {plain} plain");
2029        }
2030
2031        let _ = std::fs::remove_dir_all(&dir);
2032    }
2033}