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];