mira_core/frame.rs
1//! The frame algebra: correlation as a closed set of operations (section 7.3).
2//!
3//! A **frame** is a bounded region of telemetry — a time window, plus the
4//! traces and entities found in it. Every operation here is `Frame -> Frame`,
5//! which is the property the design turns on: an investigation is a walk over
6//! frames, every intermediate state is a legal frame, and there is no way to
7//! build one that is not executable.
8//!
9//! That closure is what makes this the agentic surface rather than SQL. An
10//! agent handed a star schema with EAV attribute tables writes wrong joins, and
11//! they are *silently* wrong — a missing `parent_id` predicate returns a cross
12//! product that looks like data. An agent handed `anchor` and three expanders
13//! cannot express a wrong join at all.
14//!
15//! There is no `fetch` here, and that is deliberate. Once a frame names a trace
16//! or a service, reading its rows is `trace_id = ...` or `service.name = ...`
17//! through the ordinary [`crate::query::search`] — both of which the block
18//! sidecars already prune on (section 7.4). A second read path would be a second
19//! predicate language for no new answer.
20//!
21//! Blocking: this mmaps and page-faults, the same as [`crate::query`]. Callers
22//! on an async runtime must go through `spawn_blocking`.
23
24use std::collections::HashMap;
25use std::path::Path;
26use std::sync::Arc;
27
28use arrow_array::cast::AsArray;
29use arrow_array::types::{TimestampNanosecondType, UInt8Type, UInt16Type, UInt32Type, UInt64Type};
30use arrow_array::{Array, ArrayRef, FixedSizeBinaryArray};
31
32use crate::block::{self, Src};
33use crate::error::Result;
34use crate::json::Json;
35use crate::query::{self, Search};
36use crate::signal::Open;
37
38/// A bounded region of telemetry.
39///
40/// No `spans` member, unlike section 7.3's sketch: the one question it was for — "this
41/// span and its children" — is what a `trace_id` query already answers, and a
42/// set nothing reads is a set nothing keeps correct.
43#[derive(Debug, Clone, Default, PartialEq)]
44pub struct Frame {
45 pub from: i64,
46 pub to: i64,
47 /// `resources.key` values (section 7.2). Never contains
48 /// [`crate::identity::NO_IDENTITY`]: treating "no stable identity" as an
49 /// identity would merge every resource an exporter failed to describe into
50 /// one entity.
51 pub entities: Vec<u64>,
52 pub traces: Vec<[u8; 16]>,
53 /// Something was dropped on the way here. Carried on the frame rather than
54 /// returned beside it so it survives a walk: three expansions later the
55 /// caller still knows the answer is a sample, which is the difference
56 /// between a wide investigation and a wrong one.
57 pub truncated: bool,
58}
59
60/// How wide a frame is allowed to get.
61///
62/// A frame with half a million traces in it is not a frame, it is a scan with
63/// extra steps. These are deliberately small: an investigation narrows.
64pub const MAX_TRACES: usize = 1000;
65pub const MAX_ENTITIES: usize = 256;
66
67impl Frame {
68 fn add_trace(&mut self, id: &[u8]) {
69 let Ok(id) = <[u8; 16]>::try_from(id) else {
70 return;
71 };
72 if self.traces.contains(&id) {
73 return;
74 }
75 if self.traces.len() >= MAX_TRACES {
76 self.truncated = true;
77 return;
78 }
79 self.traces.push(id);
80 }
81
82 fn add_entity(&mut self, key: u64) {
83 // An entity set containing the sentinel means "every resource nobody
84 // described", which is not an entity.
85 if key == crate::identity::NO_IDENTITY || self.entities.contains(&key) {
86 return;
87 }
88 if self.entities.len() >= MAX_ENTITIES {
89 self.truncated = true;
90 return;
91 }
92 self.entities.push(key);
93 }
94
95 /// The frame as a response body. `names` labels the entity keys — see
96 /// [`names_of`]; an empty map renders every one as `unknown`, which is what
97 /// a caller that did not ask for labels gets.
98 pub fn write_json(&self, j: &mut Json, names: &HashMap<u64, String>) {
99 j.obj(|j| {
100 j.key("from");
101 j.i64_str(self.from);
102 j.key("to");
103 j.i64_str(self.to);
104 j.key("entities");
105 j.arr(|j| {
106 for e in &self.entities {
107 j.obj(|j| {
108 j.key("key");
109 j.u64_str(*e);
110 j.key("name");
111 j.str(names.get(e).map_or(UNKNOWN, String::as_str));
112 });
113 }
114 });
115 j.key("traces");
116 j.arr(|j| {
117 for t in &self.traces {
118 j.hex(t);
119 }
120 });
121 j.key("truncated");
122 j.bool(self.truncated);
123 });
124 }
125}
126
127/// One step of a correlation walk.
128///
129/// Three, not section 7.3's seven. `by_trace` and `by_span` are what a `trace_id`
130/// query already does, `by_link` and `by_exemplar` are edges the row itself
131/// carries out to the caller, and an expander with no caller is an expander
132/// with no test.
133#[derive(Debug, Clone, Copy, PartialEq, Eq)]
134pub enum Expand {
135 /// Widen the window to the full extent of the traces already in the frame.
136 ///
137 /// A trace id says nothing about when, so a window taken from the row that
138 /// matched usually cuts the trace in half — the log line that started the
139 /// investigation is at the end of a request whose first span is 800 ms
140 /// earlier. This is the fix, and it is why `Around` is not a substitute:
141 /// the extent is measured, not guessed at.
142 Traces,
143 /// Widen `entities` to everything that took part in the frame's traces.
144 ///
145 /// The service map, for one investigation rather than the whole store:
146 /// *which other services were involved in the traces this one took part
147 /// in*. Two hops over data already in the block, with no service map to
148 /// maintain and no metrics-generator sidecar. Requires traces — with none,
149 /// "everything that shared a trace with nothing" is every entity there is,
150 /// and returning that would look like an answer.
151 Peers,
152 /// Widen the window by ±d nanoseconds, keeping everything else.
153 Around(i64),
154}
155
156/// Where an investigation starts: the frame around what a search matched.
157///
158/// The predicate is the one [`crate::query::search_open`] runs, evaluated by the
159/// same code — this harvests ids where a search renders rows. That matters for
160/// a reason beyond saving a scan loop: "the frame around what I am looking at"
161/// is only true if *what I am looking at* is decided identically, and two
162/// predicate evaluators would drift.
163///
164/// `q.limit` and `q.after` are ignored. A frame is bounded by [`MAX_TRACES`] and
165/// [`MAX_ENTITIES`]; a page size bounds what is *rendered*, which is a different
166/// question and a different call.
167pub fn anchor(root: &Path, q: &Search, open: &[Arc<Open>]) -> Result<(Frame, Stats)> {
168 let disk = block::scan(root, q.signal.dir())?;
169 let mut refs = block::sources(&disk, open);
170 let mut st = Stats {
171 blocks_total: refs.len(),
172 ..Default::default()
173 };
174 refs.retain(|b| b.overlaps(q.from, q.to));
175 // Newest first, so a store far larger than the caps is sampled from the end
176 // someone is looking at rather than from wherever `readdir` started.
177 refs.sort_by_key(|b| std::cmp::Reverse((b.max_ts, b.seq)));
178
179 let mut f = Frame {
180 from: q.from,
181 to: q.to,
182 ..Default::default()
183 };
184 let scan = query::Scan::new(q);
185 let mut i = 0;
186 // Full width from the first wave, unlike a search: there is no `limit` to
187 // exit on, so there is no cheap case for `search_open`'s ramp to protect.
188 // The wave takes only threads that are spare and answers short when there
189 // are none, so this stays one line rather than becoming a mode.
190 //
191 // Stopping on `truncated` is the other bound, and the honest one: once a
192 // cap has dropped something, reading further blocks only drops more.
193 while i < refs.len() && !f.truncated {
194 let answers = scan.wave(&refs, i, refs.len());
195 i += answers.len();
196 for done in answers {
197 let (Some(b), hits) = done? else { continue };
198 st.blocks_scanned += 1;
199 st.rows_scanned += b.root.num_rows();
200 st.rows_matched += hits.len();
201 // Every hit in this answer came from this block, so one lookup of
202 // the resource table covers all of them.
203 let Some(h0) = hits.first() else { continue };
204 let keys = entity_keys(&refs[h0.block])?;
205 let traces = b.root.column_by_name("trace_id").and_then(binary);
206 let rids = b.root.column_by_name("resource_id");
207 for h in &hits {
208 let row = h.row as usize;
209 if let Some(t) = traces.filter(|t| t.is_valid(row)) {
210 f.add_trace(t.value(row));
211 }
212 if let Some(&k) =
213 rids.and_then(|c| keys.get(c.as_primitive::<UInt16Type>().value(row) as usize))
214 {
215 f.add_entity(k);
216 }
217 }
218 }
219 }
220 Ok((f, st))
221}
222
223/// Apply a walk to a frame, in order.
224///
225/// In order and not as a set, because they do not commute: `Around` after
226/// `Traces` widens the measured extent of the traces, `Around` before it is
227/// overwritten by the measurement.
228pub fn expand(
229 root: &Path,
230 f: &Frame,
231 ops: &[Expand],
232 open: &[Arc<Open>],
233) -> Result<(Frame, Stats)> {
234 let mut f = f.clone();
235 let mut st = Stats::default();
236 for op in ops {
237 match *op {
238 Expand::Around(d) => {
239 f.from = f.from.saturating_sub(d);
240 f.to = f.to.saturating_add(d);
241 }
242 op => walk_spans(root, &mut f, op, open, &mut st)?,
243 }
244 }
245 Ok((f, st))
246}
247
248/// The one pass both span-side expanders need: every span of the frame's
249/// traces, with its start, its duration and its resource.
250///
251/// One function for two operations because they differ in three lines and share
252/// the expensive part — opening trace blocks and matching 16-byte ids. A
253/// `[Traces, Peers]` walk still reads them twice; that is one extra pass per
254/// call, not per row, and splitting the shared shape to save it would cost more
255/// than it returns.
256fn walk_spans(
257 root: &Path,
258 f: &mut Frame,
259 op: Expand,
260 open: &[Arc<Open>],
261 st: &mut Stats,
262) -> Result<()> {
263 if f.traces.is_empty() {
264 return Ok(());
265 }
266 let disk = block::scan(root, "traces")?;
267 let refs = block::sources(&disk, open);
268 st.blocks_total += refs.len();
269 let (mut lo, mut hi) = (i64::MAX, i64::MIN);
270 for bref in &refs {
271 // `Traces` deliberately does not filter on the window: a trace id says
272 // nothing about when, which is the whole reason the expander exists.
273 // The trace sidecar is what keeps that affordable — section 7.4 measures it
274 // pruning most of the store on a miss.
275 if op == Expand::Peers && !bref.overlaps(f.from, f.to) {
276 continue;
277 }
278 if !may_hold(bref, &f.traces) {
279 continue;
280 }
281 let Some(spans) = bref.load("spans")? else {
282 continue;
283 };
284 let Some(ids) = spans.column_by_name("trace_id").and_then(binary) else {
285 continue;
286 };
287 st.blocks_scanned += 1;
288 st.rows_scanned += spans.num_rows();
289 let keys = entity_keys(bref)?;
290 let rids = spans.column_by_name("resource_id");
291 let start = spans
292 .column_by_name("start_time_unix_nano")
293 .map(|c| c.as_primitive::<TimestampNanosecondType>());
294 let dur = spans
295 .column_by_name("duration_nano")
296 .map(|c| c.as_primitive::<UInt64Type>());
297 for row in 0..spans.num_rows() {
298 if !ids.is_valid(row) || !f.traces.iter().any(|t| t[..] == *ids.value(row)) {
299 continue;
300 }
301 st.rows_matched += 1;
302 if op == Expand::Peers {
303 if let Some(&k) =
304 rids.and_then(|c| keys.get(c.as_primitive::<UInt16Type>().value(row) as usize))
305 {
306 f.add_entity(k);
307 }
308 } else {
309 let s = start.map_or(0, |c| c.value(row));
310 lo = lo.min(s);
311 hi = hi.max(s.saturating_add(dur.map_or(0, |c| c.value(row)) as i64));
312 }
313 }
314 }
315 if op == Expand::Traces && lo <= hi {
316 f.from = f.from.min(lo);
317 f.to = f.to.max(hi);
318 }
319 Ok(())
320}
321
322/// A service map over a window: who calls whom, how often, and how badly.
323///
324/// The edge is the one a service map has always been — a span's resource to its
325/// parent span's resource — and the join is `parent_span_id -> span_id`. What
326/// makes it affordable here is that it is a *read*: Tempo answers the same
327/// question with a metrics-generator writing into a separate Prometheus, which
328/// is a second write path, a second store and a second thing to operate.
329///
330/// Bounded by `max_spans`, newest block first. A map is a shape, not a census:
331/// five services do not become six because the window held twenty million spans
332/// instead of one, and a page that waits two seconds for the same picture is a
333/// page nobody leaves open. What was covered is reported in `stats`.
334pub fn map(
335 root: &Path,
336 from: i64,
337 to: i64,
338 max_spans: usize,
339 open: &[Arc<Open>],
340) -> Result<query::Results> {
341 let disk = block::scan(root, "traces")?;
342 let mut refs = block::sources(&disk, open);
343 let mut stats = query::Stats {
344 blocks_total: refs.len(),
345 ..Default::default()
346 };
347 refs.retain(|b| b.overlaps(from, to));
348 refs.sort_by_key(|b| std::cmp::Reverse((b.max_ts, b.seq)));
349
350 let mut nodes: HashMap<u64, Node> = HashMap::new();
351 let mut edges: HashMap<(u64, u64), Edge> = HashMap::new();
352 let mut names: HashMap<u64, String> = HashMap::new();
353 // Spans whose parent was not in the sample. Reported rather than hidden:
354 // "40% unresolved" is how a reader knows to widen `max_spans` before
355 // believing a thin edge is a thin dependency.
356 let mut unresolved = 0u64;
357
358 for bref in &refs {
359 if stats.rows_scanned >= max_spans {
360 break;
361 }
362 let Some(spans) = bref.load("spans")? else {
363 continue;
364 };
365 let (Some(ids), Some(parents), Some(rids)) = (
366 spans.column_by_name("span_id").and_then(binary),
367 spans.column_by_name("parent_span_id").and_then(binary),
368 spans.column_by_name("resource_id"),
369 ) else {
370 continue;
371 };
372 stats.blocks_scanned += 1;
373 stats.rows_scanned += spans.num_rows();
374 let keys = entity_keys(bref)?;
375 resource_names(bref, &keys, &mut names)?;
376 let rids = rids.as_primitive::<UInt16Type>();
377 let time = spans
378 .column_by_name("start_time_unix_nano")
379 .map(|c| c.as_primitive::<TimestampNanosecondType>());
380 let dur = spans
381 .column_by_name("duration_nano")
382 .map(|c| c.as_primitive::<UInt64Type>());
383 let status = spans
384 .column_by_name("status_code")
385 .map(|c| c.as_primitive::<UInt8Type>());
386 let key_of = |row: usize| keys.get(rids.value(row) as usize).copied().unwrap_or(0);
387
388 // Who owns each span id, and which rows are in the window. Built first
389 // because the join below needs the *parent's* resource, and the parent
390 // is an arbitrary row of this same table — one pass to index, one to
391 // walk.
392 //
393 // Every span goes into `owner`, including ones outside the window: a
394 // child inside it whose parent started just before would otherwise
395 // count as unresolved, which is exactly the edge a window boundary
396 // cuts.
397 let mut owner: HashMap<&[u8], u64> = HashMap::with_capacity(spans.num_rows());
398 let mut live: Vec<usize> = Vec::with_capacity(spans.num_rows());
399 for row in 0..spans.num_rows() {
400 if ids.is_valid(row) {
401 owner.insert(ids.value(row), key_of(row));
402 }
403 if time.is_none_or(|t| t.value(row) >= from && t.value(row) <= to) {
404 live.push(row);
405 }
406 }
407
408 for row in live {
409 let key = key_of(row);
410 let bad = status.is_some_and(|s| s.value(row) == STATUS_ERROR);
411 let d = dur.map_or(0, |c| c.value(row));
412 let n = nodes.entry(key).or_default();
413 n.spans += 1;
414 n.errors += u64::from(bad);
415 n.nanos += d;
416
417 // A span with no parent is an entry point, not a missing edge.
418 let caller = if parents.is_valid(row) {
419 owner.get(parents.value(row)).copied()
420 } else {
421 Some(ENTRY)
422 };
423 let Some(caller) = caller else {
424 unresolved += 1;
425 continue;
426 };
427 // A span whose parent is in the same service is internal work, not
428 // a call. Keeping those would make every node a self-loop weighted
429 // by its own span count, which is what `nodes` already says.
430 if caller == key {
431 continue;
432 }
433 let e = edges.entry((caller, key)).or_default();
434 e.calls += 1;
435 e.errors += u64::from(bad);
436 e.nanos += d;
437 e.max_nanos = e.max_nanos.max(d);
438 }
439 }
440 stats.rows_matched = edges.len();
441
442 // Sorted, so two identical requests produce identical bytes. A diff, an
443 // ETag and a graph that does not reshuffle its nodes on every poll all
444 // depend on that, and `HashMap` iteration order provides none of it.
445 let mut ns: Vec<_> = nodes.into_iter().collect();
446 ns.sort_unstable_by_key(|(k, _)| *k);
447 let mut es: Vec<_> = edges.into_iter().collect();
448 es.sort_unstable_by_key(|(k, _)| *k);
449
450 let mut j = Json::new();
451 j.obj(|j| {
452 j.key("nodes");
453 j.arr(|j| {
454 for (key, n) in &ns {
455 j.obj(|j| {
456 j.key("key");
457 j.u64_str(*key);
458 j.key("name");
459 j.str(names.get(key).map_or(UNKNOWN, String::as_str));
460 j.key("spans");
461 j.u64(n.spans);
462 j.key("errors");
463 j.u64(n.errors);
464 j.key("avg_nano");
465 j.u64_str(n.nanos / n.spans.max(1));
466 });
467 }
468 });
469 j.key("edges");
470 j.arr(|j| {
471 for ((a, b), e) in &es {
472 j.obj(|j| {
473 j.key("from");
474 // The synthetic caller every root span hangs off. Named
475 // rather than omitted: a map without its entry points does
476 // not say where the traffic arrives.
477 if *a == ENTRY {
478 j.str("entry");
479 } else {
480 j.u64_str(*a);
481 }
482 j.key("to");
483 j.u64_str(*b);
484 j.key("calls");
485 j.u64(e.calls);
486 j.key("errors");
487 j.u64(e.errors);
488 j.key("avg_nano");
489 j.u64_str(e.nanos / e.calls.max(1));
490 j.key("max_nano");
491 j.u64_str(e.max_nanos);
492 });
493 }
494 });
495 j.key("unresolved");
496 j.u64(unresolved);
497 });
498 Ok(query::Results {
499 json: j.into_string(),
500 stats,
501 next: None,
502 })
503}
504
505/// Every entity present in a window, with its name and how many blocks hold it.
506///
507/// What a "filter by service" control is populated from, and the one place
508/// section 7.2's `resources.key` becomes something a human can pick. Reads only the
509/// `resources` and `resource_attrs` tables — tens of rows a block — so it is a
510/// facet lookup rather than a scan, and it covers all three signals because a
511/// service that only emits metrics still belongs in the list.
512pub fn entities(
513 root: &Path,
514 from: i64,
515 to: i64,
516 open: &[Vec<Arc<Open>>],
517) -> Result<query::Results> {
518 let mut names: HashMap<u64, String> = HashMap::new();
519 let mut seen: HashMap<u64, u64> = HashMap::new();
520 let mut stats = query::Stats::default();
521 for (i, signal) in SIGNALS.iter().enumerate() {
522 let disk = block::scan(root, signal)?;
523 let refs = block::sources(&disk, open_for(open, i));
524 stats.blocks_total += refs.len();
525 for bref in &refs {
526 if !bref.overlaps(from, to) {
527 continue;
528 }
529 stats.blocks_scanned += 1;
530 let keys = entity_keys(bref)?;
531 resource_names(bref, &keys, &mut names)?;
532 for k in keys.iter().filter(|&&k| k != 0) {
533 *seen.entry(*k).or_default() += 1;
534 }
535 }
536 }
537 let mut out: Vec<_> = seen.into_iter().collect();
538 // By name, then key: a picker is read by a human, and the key is a hash.
539 out.sort_unstable_by(|a, b| {
540 let name = |k: &u64| names.get(k).map_or(UNKNOWN, String::as_str);
541 name(&a.0).cmp(name(&b.0)).then(a.0.cmp(&b.0))
542 });
543 stats.rows_matched = out.len();
544 let mut j = Json::new();
545 j.arr(|j| {
546 for (key, blocks) in &out {
547 j.obj(|j| {
548 j.key("key");
549 j.u64_str(*key);
550 j.key("name");
551 j.str(names.get(key).map_or(UNKNOWN, String::as_str));
552 j.key("blocks");
553 j.u64(*blocks);
554 });
555 }
556 });
557 Ok(query::Results {
558 json: j.into_string(),
559 stats,
560 next: None,
561 })
562}
563
564/// `service.name` for the entities of a frame.
565///
566/// A frame carries keys, and a key is a hash. This is what turns one into a
567/// label for the response — the same walk [`entities`] does, without the
568/// counting, and skipping any block that holds none of them.
569pub fn names_of(root: &Path, f: &Frame, open: &[Vec<Arc<Open>>]) -> Result<HashMap<u64, String>> {
570 let mut names = HashMap::new();
571 if f.entities.is_empty() {
572 return Ok(names);
573 }
574 for (i, signal) in SIGNALS.iter().enumerate() {
575 let disk = block::scan(root, signal)?;
576 for bref in block::sources(&disk, open_for(open, i)) {
577 if !bref.overlaps(f.from, f.to) {
578 continue;
579 }
580 let keys = entity_keys(&bref)?;
581 if keys.iter().any(|k| f.entities.contains(k)) {
582 resource_names(&bref, &keys, &mut names)?;
583 }
584 }
585 }
586 names.retain(|k, _| f.entities.contains(k));
587 Ok(names)
588}
589
590/// What a frame walk cost. The same four numbers a search reports, for the same
591/// reason: "how much did that cost" is the first question when it is slow, and
592/// the second is what an agent uses to decide its filter was too broad.
593#[derive(Debug, Default, Clone)]
594pub struct Stats {
595 pub blocks_total: usize,
596 pub blocks_scanned: usize,
597 pub rows_scanned: usize,
598 pub rows_matched: usize,
599}
600
601/// The block directories, which are also the signal names on the wire.
602///
603/// The two functions that walk all three take their open blocks as a slice
604/// parallel to this, because an `Open` does not record which signal produced it
605/// and sequence numbers are per-signal — hand the logs snapshot to the traces
606/// directory and a `(node, seq)` collision silently swaps one block for
607/// another. A short or empty slice means "no open blocks for the rest", which
608/// is what a test and a cold process both want.
609const SIGNALS: [&str; 3] = ["logs", "traces", "metrics"];
610
611fn open_for(open: &[Vec<Arc<Open>>], i: usize) -> &[Arc<Open>] {
612 open.get(i).map_or(&[], Vec::as_slice)
613}
614
615/// OTLP `STATUS_CODE_ERROR`.
616const STATUS_ERROR: u8 = 2;
617
618/// A resource with no `service.name` — which is a resource no SDK described.
619const UNKNOWN: &str = "unknown";
620
621/// The caller of a root span.
622///
623/// A synthetic key in the same space as the real ones, which is a collision at
624/// 2^-64 per distinct resource and not worth a tagged enum on every edge. The
625/// obvious free value is [`crate::identity::NO_IDENTITY`], and it is taken:
626/// zero already means *undescribed resource*, and merging "traffic from
627/// outside" into "resources nobody labelled" is the one confusion a service map
628/// must not have.
629const ENTRY: u64 = u64::MAX;
630
631#[derive(Default)]
632struct Node {
633 spans: u64,
634 errors: u64,
635 nanos: u64,
636}
637
638#[derive(Default)]
639struct Edge {
640 calls: u64,
641 errors: u64,
642 nanos: u64,
643 max_nanos: u64,
644}
645
646/// `resources.key` indexed by `resource_id`, for one block.
647///
648/// A `Vec` and not a map: `resource_id` is a dense `u16` dictionary index, so
649/// the id *is* the slot. Tens of entries — one page, no hashing.
650fn entity_keys(bref: &Src<'_>) -> Result<Vec<u64>> {
651 let Some(r) = bref.load("resources")? else {
652 return Ok(Vec::new());
653 };
654 let (Some(ids), Some(keys)) = (r.column_by_name("id"), r.column_by_name("key")) else {
655 return Ok(Vec::new());
656 };
657 let ids = ids.as_primitive::<UInt16Type>();
658 let keys = keys.as_primitive::<UInt64Type>();
659 // Ids are dense from zero, so the slot count is the row count — but the
660 // rows need not arrive in id order, so this places rather than pushes.
661 let mut out = vec![0u64; r.num_rows()];
662 for row in 0..r.num_rows() {
663 let i = ids.value(row) as usize;
664 if i < out.len() {
665 out[i] = keys.value(row);
666 }
667 }
668 Ok(out)
669}
670
671/// `service.name` per entity key, accumulated across blocks.
672///
673/// Every OTel SDK sets `service.name` and section 7.2's identity ladder is built on
674/// it, so a key almost always has one. `or_insert` and not `insert`: the first
675/// block wins, and callers walk newest first, so a renamed service shows the
676/// name it has now rather than the one it booted with.
677fn resource_names(bref: &Src<'_>, keys: &[u64], out: &mut HashMap<u64, String>) -> Result<()> {
678 let Some(a) = bref.load("resource_attrs")? else {
679 return Ok(());
680 };
681 let (Some(parents), Some(vals)) = (a.column_by_name("parent_id"), a.column_by_name("str"))
682 else {
683 return Ok(());
684 };
685 let parents = parents.as_primitive::<UInt32Type>();
686 let vals = crate::attrs::str_values(vals);
687 for row in 0..a.num_rows() {
688 if query::attr_key(&a, row) != "service.name" || !vals.is_valid(row) {
689 continue;
690 }
691 if let Some(&k) = keys.get(parents.value(row) as usize).filter(|&&k| k != 0) {
692 out.entry(k).or_insert_with(|| vals.value(row).to_owned());
693 }
694 }
695 Ok(())
696}
697
698/// Could this block hold any of these traces? Fails open, like every sidecar:
699/// a snapshot has no directory and a damaged filter reads as "scan me".
700fn may_hold(bref: &Src<'_>, traces: &[[u8; 16]]) -> bool {
701 let Some(dir) = bref.dir else { return true };
702 match std::fs::read(dir.join(crate::bloom::TRACE_IDX)) {
703 Ok(f) => traces.iter().any(|t| crate::bloom::may_contain(&f, t)),
704 Err(_) => true,
705 }
706}
707
708fn binary(c: &ArrayRef) -> Option<&FixedSizeBinaryArray> {
709 c.as_any().downcast_ref::<FixedSizeBinaryArray>()
710}
711
712#[cfg(test)]
713mod tests {
714 use super::*;
715 use mira_proto::collector::trace::v1::ExportTraceServiceRequest;
716 use mira_proto::common::v1::any_value::Value as AnyVal;
717 use mira_proto::common::v1::{AnyValue, KeyValue};
718 use mira_proto::resource::v1::Resource;
719 use mira_proto::trace::v1::{ResourceSpans, ScopeSpans, Span};
720
721 /// Spans start here. The damaged blocks below sit whole hours away, so a
722 /// guard that fails to skip one moves the measured extent by an hour rather
723 /// than by a nanosecond — visible in an assertion instead of plausible.
724 const T0: u64 = 1_000_000_000;
725 const HOUR: u64 = 3_600_000_000_000;
726
727 fn kv(k: &str, v: &str) -> KeyValue {
728 KeyValue {
729 key: k.into(),
730 value: Some(AnyValue {
731 value: Some(AnyVal::StringValue(v.into())),
732 }),
733 }
734 }
735
736 fn span(trace: u8, id: [u8; 8], parent: Option<[u8; 8]>, start: u64, dur: u64) -> Span {
737 Span {
738 trace_id: [trace; 16].to_vec().into(),
739 span_id: id.to_vec().into(),
740 parent_span_id: parent.map(|p| p.to_vec()).unwrap_or_default().into(),
741 name: "GET /checkout".into(),
742 start_time_unix_nano: start,
743 end_time_unix_nano: start + dur,
744 ..Default::default()
745 }
746 }
747
748 /// One traces block per call, and its directory back so a test can damage
749 /// it the way retention or an older writer would.
750 fn traces_block(root: &Path, seq: u64, services: Vec<(&str, Vec<Span>)>) -> std::path::PathBuf {
751 let mut b = crate::traces::TracesBuilder::new();
752 b.append_request(&ExportTraceServiceRequest {
753 resource_spans: services
754 .into_iter()
755 .map(|(name, spans)| ResourceSpans {
756 resource: Some(Resource {
757 attributes: vec![kv("service.name", name)],
758 ..Default::default()
759 }),
760 scope_spans: vec![ScopeSpans {
761 spans,
762 ..Default::default()
763 }],
764 ..Default::default()
765 })
766 .collect(),
767 })
768 .unwrap();
769 let sealed = b.finish().unwrap();
770 block::publish(root, "traces", block::node_id("a"), seq, 0, &sealed)
771 .unwrap()
772 .dir
773 }
774
775 /// Rewrite `table` without `col`, the shape a writer that predates the
776 /// column left behind. Staged and renamed rather than truncated in place,
777 /// because a mapping over a file being shortened is a SIGBUS and not an
778 /// error anything can catch.
779 fn drop_column(dir: &Path, table: &str, col: &str) {
780 let path = dir.join(format!("{table}.arrow"));
781 let mut b = block::open_table_opt(&path).unwrap().unwrap().batches[0].clone();
782 b.remove_column(b.schema().index_of(col).unwrap());
783 let staged = dir.join(format!("{table}.staged"));
784 block::write_table(&staged, &b).unwrap();
785 std::fs::rename(&staged, &path).unwrap();
786 }
787
788 /// Three services, one trace, and three blocks that are each broken in a
789 /// different way — the states a running store actually reaches.
790 ///
791 /// There is no coordinator and no schema version to check against: the
792 /// block directory is the manifest, so a reader meets blocks being unlinked
793 /// under it by retention and blocks written by another build of the binary.
794 /// Every one of those has to contribute nothing and be *visibly* nothing,
795 /// because the alternative is a service map missing an edge, or a frame
796 /// whose window silently grew by an hour, with a 200 on both.
797 #[test]
798 fn a_block_the_reader_cannot_use_contributes_nothing_and_widens_nothing() {
799 use crate::query::{Search, Signal};
800
801 let root = std::env::temp_dir().join(format!("mira-frame-torn-{}", std::process::id()));
802 let _ = std::fs::remove_dir_all(&root);
803
804 // gateway -> api -> db, one trace, the only block that is intact.
805 let sid = |svc: u8| [svc, 1, 0, 0, 0, 0, 0, 0];
806 let chain = |start: u64| {
807 vec![
808 ("gateway", vec![span(1, sid(1), None, start, 900)]),
809 ("api", vec![span(1, sid(2), Some(sid(1)), start, 500)]),
810 ("db", vec![span(1, sid(3), Some(sid(2)), start, 100)]),
811 ]
812 };
813 traces_block(&root, 0, chain(T0 + 1));
814
815 // A block holding a different trace entirely, an hour later. The trace
816 // sidecar answers "no" for it, which is the prune section 7.4 measures — and
817 // its `resources` table has lost the column that carries the identity,
818 // so nothing in it can be named either.
819 let ghost = traces_block(
820 &root,
821 1,
822 vec![(
823 "ghost",
824 // Its parent is a span id nothing in this block owns: the shape
825 // a window boundary or a dropped block leaves behind.
826 vec![span(
827 9,
828 [9, 1, 0, 0, 0, 0, 0, 0],
829 Some([8, 8, 0, 0, 0, 0, 0, 0]),
830 T0 + HOUR,
831 10,
832 )],
833 )],
834 );
835 drop_column(&ghost, "resources", "key");
836 drop_column(&ghost, "resource_attrs", "str");
837
838 // Retention got here mid-query: the span table is gone, and so is the
839 // resource table, but the directory is still listed and the trace
840 // sidecar still claims the trace.
841 let unlinked = traces_block(&root, 2, chain(T0 + 2 * HOUR));
842 std::fs::remove_file(unlinked.join("spans.arrow")).unwrap();
843 std::fs::remove_file(unlinked.join("resources.arrow")).unwrap();
844
845 // Another build's block: no trace sidecar at all, so the filter fails
846 // open, and a span table with neither the id a frame joins on nor the
847 // one a service map joins on.
848 let foreign = traces_block(&root, 3, chain(T0 + 3 * HOUR));
849 std::fs::remove_file(foreign.join(crate::bloom::TRACE_IDX)).unwrap();
850 drop_column(&foreign, "spans", "trace_id");
851 drop_column(&foreign, "spans", "parent_span_id");
852
853 // The frame around the intact block, anchored on a window two
854 // nanoseconds wide so that any widening at all is the expander's.
855 let q = Search {
856 signal: Signal::Traces,
857 from: T0 as i64,
858 to: (T0 + 2) as i64,
859 terms: Vec::new(),
860 limit: 10,
861 after: None,
862 };
863 let (f, _) = anchor(&root, &q, &[]).unwrap();
864 assert_eq!(f.traces, vec![[1u8; 16]], "one trace in the window");
865 assert_eq!(f.entities.len(), 3, "gateway, api and db");
866
867 // `Traces` reads every trace block regardless of the window — that is
868 // the whole point of it — so all four are offered and three are
869 // refused. The extent it measures is the intact block's alone; a guard
870 // that let any of the others through would move `to` by hours.
871 let (g, st) = expand(&root, &f, &[Expand::Traces], &[]).unwrap();
872 assert_eq!(st.blocks_total, 4);
873 assert_eq!(st.blocks_scanned, 1, "three unusable blocks were read");
874 assert_eq!((g.from, g.to), (T0 as i64, (T0 + 901) as i64));
875 assert_eq!(g.entities, f.entities, "`Traces` does not touch entities");
876
877 // The entity facet over the whole store. The two blocks with no
878 // readable `resources` table contribute no entities rather than an
879 // entity called zero, and the intact pair report themselves twice —
880 // same services, same identity, two blocks.
881 let all = entities(&root, 0, i64::MAX, &[]).unwrap();
882 assert_eq!(all.stats.blocks_scanned, 4);
883 assert_eq!(all.json.matches(r#""name""#).count(), 3, "{}", all.json);
884 assert_eq!(all.json.matches(r#""blocks":2"#).count(), 3, "{}", all.json);
885 assert!(!all.json.contains("ghost"), "{}", all.json);
886 for name in ["api", "db", "gateway"] {
887 assert!(all.json.contains(&format!(r#""name":"{name}""#)), "{name}");
888 }
889
890 // Narrowed to the intact block's own window, the other three are never
891 // opened: the directory name is the index.
892 let near = entities(&root, T0 as i64, (T0 + 2_000) as i64, &[]).unwrap();
893 assert_eq!(near.stats.blocks_scanned, 1);
894 assert_eq!(
895 near.json.matches(r#""blocks":1"#).count(),
896 3,
897 "{}",
898 near.json
899 );
900
901 // Labels for the frame, over both windows. The damaged blocks add no
902 // names and remove none, and no key ever comes back as `unknown`.
903 let sorted = |f: &Frame| {
904 let mut v: Vec<String> = names_of(&root, f, &[]).unwrap().into_values().collect();
905 v.sort();
906 v
907 };
908 assert_eq!(sorted(&f), ["api", "db", "gateway"]);
909 let wide = Frame {
910 from: 0,
911 to: i64::MAX,
912 ..f
913 };
914 assert_eq!(sorted(&wide), ["api", "db", "gateway"]);
915
916 // The service map, reconstructed from `parent_span_id` alone. Only two
917 // blocks have a usable span table, and only one of those has parents.
918 let m = map(&root, 0, i64::MAX, 1_000, &[]).unwrap();
919 assert_eq!(m.stats.blocks_scanned, 2, "{}", m.json);
920 assert_eq!(m.stats.rows_matched, 3, "entry->gateway->api->db");
921 assert!(m.json.contains(r#""from":"entry""#), "{}", m.json);
922 // The orphan's parent is owned by nobody, and that is reported rather
923 // than dropped: a thin edge and a missing parent look identical on a
924 // graph, so the count is how a reader tells them apart.
925 assert!(m.json.contains(r#""unresolved":1"#), "{}", m.json);
926 // Its resource cannot be identified, so it is one `unknown` node — not
927 // a fabricated key and not a panic.
928 assert_eq!(
929 m.json.matches(r#""name":"unknown""#).count(),
930 1,
931 "{}",
932 m.json
933 );
934
935 // `max_spans` stops the walk. A map is a shape, not a census: the two
936 // unusable blocks cost nothing to refuse, and the first block that does
937 // scan exhausts the budget before the intact one is reached.
938 let one = map(&root, 0, i64::MAX, 1, &[]).unwrap();
939 assert_eq!(one.stats.blocks_scanned, 1, "{}", one.json);
940 assert!(!one.json.contains(r#""from":"entry""#), "{}", one.json);
941
942 let _ = std::fs::remove_dir_all(&root);
943 }
944
945 #[test]
946 fn the_caps_are_what_makes_a_frame_a_frame() {
947 let mut f = Frame::default();
948 for i in 0..MAX_TRACES as u32 + 5 {
949 f.add_trace(&i.to_be_bytes().repeat(4));
950 }
951 assert_eq!(f.traces.len(), MAX_TRACES);
952 assert!(f.truncated, "a dropped trace has to be visible");
953 // A short id is not a trace id, and is not silently zero-extended.
954 f.add_trace(&[1, 2, 3]);
955 assert_eq!(f.traces.len(), MAX_TRACES);
956
957 let mut f = Frame::default();
958 for i in 0..MAX_ENTITIES as u64 + 5 {
959 // `+ 1`, because zero is the sentinel and never lands in the set.
960 f.add_entity(i + 1);
961 }
962 assert_eq!(f.entities.len(), MAX_ENTITIES);
963 f.add_entity(0);
964 assert!(!f.entities.contains(&0), "the sentinel is not an entity");
965 }
966
967 #[test]
968 fn expansions_are_ordered_because_they_do_not_commute() {
969 let root = std::env::temp_dir().join("mira-frame-empty");
970 let _ = std::fs::remove_dir_all(&root);
971 let f = Frame {
972 from: 1_000,
973 to: 2_000,
974 ..Default::default()
975 };
976 let (g, _) = expand(&root, &f, &[Expand::Around(500), Expand::Around(500)], &[]).unwrap();
977 assert_eq!((g.from, g.to), (0, 3_000));
978 // No traces, so the span-side expanders are a no-op rather than a scan
979 // of everything — the case that would otherwise return every entity in
980 // the store and look like an answer.
981 let (h, st) = expand(&root, &f, &[Expand::Traces, Expand::Peers], &[]).unwrap();
982 assert_eq!(h, f);
983 assert_eq!(st.blocks_scanned, 0);
984 }
985}