Skip to main content

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}