Skip to main content

mira_core/
schema.rs

1//! On-disk Arrow schemas. These follow the OTAP star schema (otap-spec.md
2//! sections 5.4 and 6.3) rather than a flattened one-row-per-span layout:
3//! a root table per signal carrying `resource_id`/`scope_id` foreign keys, plus
4//! entity-attribute-value side tables keyed by `parent_id`.
5//!
6//! Two deliberate deviations from the OTAP wire spec, both documented in
7//! docs/architecture.md:
8//!
9//! * `parent_id` and the root `id` are UInt32 and **block-local**. On the wire
10//!   OTAP ids are only unique within one `BatchArrowRecords`; persisting them
11//!   verbatim and joining across batches silently produces a cross product.
12//!   We rebase to a dense per-block id at ingest, so a join inside a block is
13//!   unconditionally correct and needs no partition discriminant column.
14//! * Attribute *values* are plain, not dictionary-encoded. Dictionary encoding
15//!   is a low-cardinality technique with a hard ceiling (`DictionaryKeyOverflow`
16//!   at 2^16 for UInt16 keys); attribute values are the highest-cardinality data
17//!   in the system. Only enumerable columns (attribute keys, severity text) are
18//!   dictionaries.
19
20use std::sync::{Arc, LazyLock};
21
22use arrow_schema::{DataType, Field, Schema, SchemaRef, TimeUnit};
23
24/// OTAP attribute value discriminant (`type` column). Matches otap-spec.md 5.4.
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26#[repr(u8)]
27pub enum AttrType {
28    Empty = 0,
29    Str = 1,
30    Int = 2,
31    Double = 3,
32    Bool = 4,
33    Bytes = 5,
34    Slice = 6,
35    Map = 7,
36}
37
38fn dict_u16_utf8() -> DataType {
39    DataType::Dictionary(Box::new(DataType::UInt16), Box::new(DataType::Utf8))
40}
41
42/// A dictionary wide enough that a block cannot fill it.
43///
44/// Every other enumerable string in the schema is `u16` and seals the block on
45/// overflow (see [`DICT_CAP`]). Attribute *values* are the one string here that
46/// is genuinely unbounded — a trace id, a URL, a GenAI prompt — so a `u16` would
47/// turn a high-cardinality tenant into a block sealed every few thousand rows.
48/// `u32` costs about one percent of the compressed size over `u16` and removes
49/// the failure mode: 4 billion distinct values do not fit in a 32 MiB block.
50fn dict_u32_utf8() -> DataType {
51    DataType::Dictionary(Box::new(DataType::UInt32), Box::new(DataType::Utf8))
52}
53
54/// How many distinct values a `UInt16` dictionary can hold. Lives here because
55/// it is a consequence of the key width chosen above, not of any one builder.
56/// Reaching it is a signal to seal the block, never to fail an export.
57pub const DICT_CAP: usize = u16::MAX as usize + 1;
58
59fn ts() -> DataType {
60    DataType::Timestamp(TimeUnit::Nanosecond, None)
61}
62
63/// Shared shape for LOG_ATTRS / RESOURCE_ATTRS / SCOPE_ATTRS.
64///
65/// One schema for all three so a single builder, a single reader and a single
66/// semi-join helper cover every attribute level.
67pub static ATTRS: LazyLock<SchemaRef> = LazyLock::new(|| {
68    Arc::new(Schema::new(vec![
69        Field::new("parent_id", DataType::UInt32, false),
70        Field::new("key", dict_u16_utf8(), false),
71        Field::new("type", DataType::UInt8, false),
72        // Dictionary-encoded, not plain `Utf8`, and it is the only value column
73        // that is: measured on one real 393,216-row block it takes the
74        // cold-block ratio from 12.9x to 66.2x on its own, because attribute
75        // values are where the repetition in telemetry lives — the same
76        // `http.route`, the same pod name, the same status text, a few hundred
77        // distinct strings across a few hundred thousand rows. `u32` rather
78        // than `key`'s `u16` because values are unbounded in principle and the
79        // wider index costs 0.7% of compressed bytes. Dictionary-encoding the
80        // *other* string columns on top of this one was measured too and makes
81        // logs *worse* (5.23x to 4.72x), so they stay plain. See
82        // docs/architecture.md, "What compresses and what does not".
83        Field::new("str", dict_u32_utf8(), true),
84        Field::new("int", DataType::Int64, true),
85        Field::new("double", DataType::Float64, true),
86        Field::new("bool", DataType::Boolean, true),
87        // `bytes` holds AnyValue::BytesValue. `ser` holds Slice/Map values,
88        // serialized. ponytail: OTAP specifies CBOR here; we write the protobuf
89        // encoding of the AnyValue instead because we own both ends and it costs
90        // zero dependencies. Switch to CBOR when a third party needs to read it.
91        Field::new("bytes", DataType::Binary, true),
92        Field::new("ser", DataType::Binary, true),
93    ]))
94});
95
96/// LOGS root table.
97///
98/// `time_unix_nano` is a plain nanosecond timestamp. Delta-of-delta was in the
99/// original brief and is dropped: Arrow IPC has no such encoding (the whole
100/// surface is LZ4/ZSTD whole-buffer compression plus Dictionary and RunEndEncoded
101/// layouts), and Gorilla's 12x rests on samples landing on exact interval
102/// boundaries, which OTLP wall-clock reads do not. See docs/architecture.md.
103pub static LOGS: LazyLock<SchemaRef> = LazyLock::new(|| {
104    Arc::new(Schema::new(vec![
105        Field::new("id", DataType::UInt32, false),
106        Field::new("time_unix_nano", ts(), false),
107        Field::new("observed_time_unix_nano", ts(), true),
108        Field::new("severity_number", DataType::Int32, true),
109        Field::new("severity_text", dict_u16_utf8(), true),
110        // What makes a record an OTel Event rather than a log line, and the key
111        // the event's attribute schema is defined against. A dictionary because
112        // the whole point of an event name is that it is enumerable.
113        //
114        // Added after the first blocks were written, and a published block is
115        // never rewritten. That is safe only because the reader never touches a
116        // root table positionally: it filters through `column_by_name` and
117        // materializes by walking the *file's* own schema, so a block from
118        // before this line reads back exactly as it did — minus the field.
119        Field::new("event_name", dict_u16_utf8(), true),
120        // String bodies, the overwhelmingly common case, land in `body`.
121        // Anything else is protobuf-encoded into `body_ser` so nothing is lost.
122        Field::new("body", DataType::Utf8, true),
123        Field::new("body_ser", DataType::Binary, true),
124        Field::new("trace_id", DataType::FixedSizeBinary(16), true),
125        Field::new("span_id", DataType::FixedSizeBinary(8), true),
126        Field::new("flags", DataType::UInt32, true),
127        Field::new("dropped_attributes_count", DataType::UInt32, false),
128        Field::new("resource_id", DataType::UInt16, false),
129        Field::new("scope_id", DataType::UInt16, false),
130    ]))
131});
132
133/// RESOURCES table — one row per distinct resource in the block.
134///
135/// Tiny: tens of rows against hundreds of thousands in the root table. It exists
136/// for `key`, the stable cross-block entity identity from [`crate::identity`],
137/// which is the join key correlation is built on. `id` is block-local and
138/// meaningless outside the block; `key` is neither.
139///
140/// Note that `id` and `key` are not one-to-one. Two resources whose attribute
141/// sets differ only in a non-identifying attribute get two `id`s and one `key` —
142/// which is the entire point.
143pub static RESOURCES: LazyLock<SchemaRef> = LazyLock::new(|| {
144    Arc::new(Schema::new(vec![
145        Field::new("id", DataType::UInt16, false),
146        Field::new("key", DataType::UInt64, false),
147        Field::new("dropped_attributes_count", DataType::UInt32, false),
148    ]))
149});
150
151/// SPANS root table.
152///
153/// One deviation from the wire format, and it is the only one: OTLP sends
154/// `start_time_unix_nano` and `end_time_unix_nano`; we store the start and a
155/// `duration_nano`. Two reasons, and end time is recoverable exactly from the
156/// pair either way.
157///
158/// Duration is what trace search actually filters on — "spans slower than
159/// 500ms" is the query every tracing UI opens with — so it deserves to be a
160/// column rather than a subtraction across two others. And it compresses:
161/// durations are small integers clustered near zero, absolute nanosecond
162/// timestamps are 19-digit numbers that share only their high bytes.
163///
164/// `name` is a dictionary because the semantic conventions require span names
165/// to be low-cardinality; if an instrumentation library violates that badly
166/// enough to fill 65536 slots, the block seals early and nothing is lost.
167pub static SPANS: LazyLock<SchemaRef> = LazyLock::new(|| {
168    Arc::new(Schema::new(vec![
169        Field::new("id", DataType::UInt32, false),
170        Field::new("trace_id", DataType::FixedSizeBinary(16), true),
171        Field::new("span_id", DataType::FixedSizeBinary(8), true),
172        Field::new("parent_span_id", DataType::FixedSizeBinary(8), true),
173        Field::new("trace_state", DataType::Utf8, true),
174        Field::new("flags", DataType::UInt32, true),
175        Field::new("name", dict_u16_utf8(), true),
176        // SpanKind is 0..=5 on the wire in an Int32 field. UInt8 with the
177        // out-of-range case clamped to UNSPECIFIED costs 3 bytes a span.
178        Field::new("kind", DataType::UInt8, false),
179        Field::new("start_time_unix_nano", ts(), false),
180        Field::new("duration_nano", DataType::UInt64, false),
181        // Status. Split out of the nested message because `code` is the second
182        // most-filtered column in the table ("show me the errors") and burying
183        // it in a struct costs a child-array indirection on every scan.
184        Field::new("status_code", DataType::UInt8, false),
185        Field::new("status_message", DataType::Utf8, true),
186        Field::new("dropped_attributes_count", DataType::UInt32, false),
187        Field::new("dropped_events_count", DataType::UInt32, false),
188        Field::new("dropped_links_count", DataType::UInt32, false),
189        Field::new("resource_id", DataType::UInt16, false),
190        Field::new("scope_id", DataType::UInt16, false),
191    ]))
192});
193
194/// SPAN_EVENTS — a child table, not a `List<Struct>` column.
195///
196/// Events carry attributes, and attributes already live in their own EAV table
197/// keyed by `parent_id`. A list-of-struct column would need a second, different
198/// mechanism to hang attributes off list *elements*; a child table with its own
199/// dense `id` reuses the one that exists.
200///
201/// `id` is this table's own block-local id, distinct from `parent_id`, which
202/// points at the span. `span_event_attrs.parent_id` refers to `id` here.
203pub static SPAN_EVENTS: LazyLock<SchemaRef> = LazyLock::new(|| {
204    Arc::new(Schema::new(vec![
205        Field::new("id", DataType::UInt32, false),
206        Field::new("parent_id", DataType::UInt32, false),
207        Field::new("time_unix_nano", ts(), false),
208        Field::new("name", dict_u16_utf8(), true),
209        Field::new("dropped_attributes_count", DataType::UInt32, false),
210    ]))
211});
212
213/// SPAN_LINKS — same child-table reasoning as [`SPAN_EVENTS`].
214///
215/// A link's `trace_id`/`span_id` point *out* of this block, usually out of this
216/// node entirely, so they stay raw ids and are not rebased. That is the
217/// distinction the whole id scheme rests on: `parent_id` is block-local because
218/// it names a row here, `trace_id` is not because it names something elsewhere.
219pub static SPAN_LINKS: LazyLock<SchemaRef> = LazyLock::new(|| {
220    Arc::new(Schema::new(vec![
221        Field::new("id", DataType::UInt32, false),
222        Field::new("parent_id", DataType::UInt32, false),
223        Field::new("trace_id", DataType::FixedSizeBinary(16), true),
224        Field::new("span_id", DataType::FixedSizeBinary(8), true),
225        Field::new("trace_state", DataType::Utf8, true),
226        Field::new("flags", DataType::UInt32, true),
227        Field::new("dropped_attributes_count", DataType::UInt32, false),
228    ]))
229});
230
231/// Which `Metric.data` variant a descriptor row carries.
232#[derive(Debug, Clone, Copy, PartialEq, Eq)]
233#[repr(u8)]
234pub enum MetricKind {
235    /// No `data` oneof set. The descriptor is kept — it still names a metric
236    /// somebody's exporter believes in — but it owns no points.
237    Unset = 0,
238    Gauge = 1,
239    Sum = 2,
240    Histogram = 3,
241    ExponentialHistogram = 4,
242    Summary = 5,
243}
244
245/// METRICS descriptor table — one row per `Metric` message, not per point.
246///
247/// Name, unit, kind, temporality and monotonicity are properties of the metric,
248/// repeated on every single point by OTLP's nesting. Hoisting them into a
249/// descriptor table that a hundred thousand points point at is most of why the
250/// split layout below measures 2.34x smaller than one wide point table.
251pub static METRICS: LazyLock<SchemaRef> = LazyLock::new(|| {
252    Arc::new(Schema::new(vec![
253        Field::new("id", DataType::UInt32, false),
254        Field::new("name", dict_u16_utf8(), true),
255        Field::new("description", DataType::Utf8, true),
256        Field::new("unit", dict_u16_utf8(), true),
257        Field::new("kind", DataType::UInt8, false),
258        Field::new("temporality", DataType::UInt8, false),
259        Field::new("is_monotonic", DataType::Boolean, false),
260        Field::new("resource_id", DataType::UInt16, false),
261        Field::new("scope_id", DataType::UInt16, false),
262    ]))
263});
264
265fn list_of(t: DataType) -> DataType {
266    DataType::List(Arc::new(Field::new_list_field(t, true)))
267}
268
269/// Columns every data point table has, in the same order, so that a temporal
270/// filter is the same code against any of the four.
271///
272/// `start_time_unix_nano` is nullable and, importantly, does **not** contribute
273/// to the block's time range. For a cumulative metric it is process start, which
274/// can be hours or days before the point; folding it in would make every block
275/// claim to cover that whole span and destroy time-based pruning for the one
276/// signal that needs it most.
277fn dp_head(parent: &str) -> Vec<Field> {
278    vec![
279        Field::new("id", DataType::UInt32, false),
280        Field::new(parent, DataType::UInt32, false),
281        Field::new("start_time_unix_nano", ts(), true),
282        Field::new("time_unix_nano", ts(), false),
283        Field::new("flags", DataType::UInt32, true),
284    ]
285}
286
287/// NUMBER_DP — gauge and sum points.
288///
289/// `int` and `double` are separate nullable columns rather than one Float64,
290/// because OTLP's `as_int` is `sfixed64` and a counter past 2^53 would silently
291/// lose its low bits on the way through an f64. Exactly one is set per row.
292pub static NUMBER_DP: LazyLock<SchemaRef> = LazyLock::new(|| {
293    let mut f = dp_head("metric_id");
294    f.push(Field::new("int", DataType::Int64, true));
295    f.push(Field::new("double", DataType::Float64, true));
296    Arc::new(Schema::new(f))
297});
298
299/// HIST_DP — explicit-bucket histogram points.
300///
301/// `bucket_counts` stays a `List<UInt64>` rather than being flattened into a
302/// child table: measured, the flat child table is 1.47x the size of the list
303/// column, because a child table pays a 4-byte parent id per bucket where the
304/// list pays one 4-byte offset per point.
305///
306/// `bounds_id` points at [`HIST_BOUNDS`]. Every point of a histogram repeats the
307/// same bucket boundaries — that is what makes it the same histogram — and
308/// interning them measured 1.67x smaller on the point table (410 -> 246 B/row).
309pub static HIST_DP: LazyLock<SchemaRef> = LazyLock::new(|| {
310    let mut f = dp_head("metric_id");
311    f.extend([
312        Field::new("count", DataType::UInt64, false),
313        Field::new("sum", DataType::Float64, true),
314        Field::new("min", DataType::Float64, true),
315        Field::new("max", DataType::Float64, true),
316        Field::new("bucket_counts", list_of(DataType::UInt64), true),
317        Field::new("bounds_id", DataType::UInt32, true),
318    ]);
319    Arc::new(Schema::new(f))
320});
321
322/// HIST_BOUNDS — the interned `explicit_bounds` arrays of this block.
323///
324/// Tens of rows against hundreds of thousands of points, and the reason
325/// [`HIST_DP`] is 1.67x smaller than it would be inline.
326pub static HIST_BOUNDS: LazyLock<SchemaRef> = LazyLock::new(|| {
327    Arc::new(Schema::new(vec![
328        Field::new("id", DataType::UInt32, false),
329        Field::new("bounds", list_of(DataType::Float64), false),
330    ]))
331});
332
333/// EXP_HIST_DP — exponential histogram points.
334///
335/// No bounds to intern: the buckets are defined by `scale` and `offset`, which
336/// is the whole point of the representation.
337pub static EXP_HIST_DP: LazyLock<SchemaRef> = LazyLock::new(|| {
338    let mut f = dp_head("metric_id");
339    f.extend([
340        Field::new("count", DataType::UInt64, false),
341        Field::new("sum", DataType::Float64, true),
342        Field::new("min", DataType::Float64, true),
343        Field::new("max", DataType::Float64, true),
344        // Spec-bounded to [-10, 20], but stored as sent: silently clamping a
345        // malformed scale would misplace every bucket in the point rather than
346        // making the point visibly wrong.
347        Field::new("scale", DataType::Int32, false),
348        Field::new("zero_count", DataType::UInt64, false),
349        Field::new("zero_threshold", DataType::Float64, true),
350        Field::new("positive_offset", DataType::Int32, false),
351        Field::new("positive_counts", list_of(DataType::UInt64), true),
352        Field::new("negative_offset", DataType::Int32, false),
353        Field::new("negative_counts", list_of(DataType::UInt64), true),
354    ]);
355    Arc::new(Schema::new(f))
356});
357
358/// SUMMARY_DP — the legacy quantile representation, kept because OTLP still
359/// carries it out of Prometheus.
360pub static SUMMARY_DP: LazyLock<SchemaRef> = LazyLock::new(|| {
361    let mut f = dp_head("metric_id");
362    f.extend([
363        Field::new("count", DataType::UInt64, false),
364        Field::new("sum", DataType::Float64, true),
365        // Two parallel lists rather than a List<Struct>: every access is
366        // "the 0.99 value", which is a lookup in one and an index into the other.
367        Field::new("quantile", list_of(DataType::Float64), true),
368        Field::new("value", list_of(DataType::Float64), true),
369    ]);
370    Arc::new(Schema::new(f))
371});
372
373/// EXEMPLARS — the bridge from a metric point to the trace that produced it.
374///
375/// `parent_id` is a data point id, and data point ids are one shared space
376/// across all four point tables precisely so that this column needs no
377/// discriminant saying which one to look in.
378pub static EXEMPLARS: LazyLock<SchemaRef> = LazyLock::new(|| {
379    Arc::new(Schema::new(vec![
380        Field::new("id", DataType::UInt32, false),
381        Field::new("parent_id", DataType::UInt32, false),
382        Field::new("time_unix_nano", ts(), false),
383        Field::new("int", DataType::Int64, true),
384        Field::new("double", DataType::Float64, true),
385        Field::new("trace_id", DataType::FixedSizeBinary(16), true),
386        Field::new("span_id", DataType::FixedSizeBinary(8), true),
387    ]))
388});
389
390/// The tables a logs block is made of, in publish order.
391pub const LOGS_BLOCK_TABLES: [&str; 5] = [
392    "logs",
393    "log_attrs",
394    "resources",
395    "resource_attrs",
396    "scope_attrs",
397];
398
399/// The tables a traces block is made of, in publish order.
400pub const TRACES_BLOCK_TABLES: [&str; 9] = [
401    "spans",
402    "span_attrs",
403    "span_events",
404    "span_event_attrs",
405    "span_links",
406    "span_link_attrs",
407    "resources",
408    "resource_attrs",
409    "scope_attrs",
410];
411
412/// The tables a metrics block is made of, in publish order.
413///
414/// Thirteen, which sounds like a lot until you notice that `publish` skips the
415/// empty ones: a service exporting only counters writes five of them.
416pub const METRICS_BLOCK_TABLES: [&str; 13] = [
417    "metrics",
418    "metric_attrs",
419    "number_dp",
420    "hist_dp",
421    "hist_bounds",
422    "exp_hist_dp",
423    "summary_dp",
424    "dp_attrs",
425    "exemplars",
426    "exemplar_attrs",
427    "resources",
428    "resource_attrs",
429    "scope_attrs",
430];