Skip to main content

backbeat_cli/
convert.rs

1// Copyright (c) 2026 Cameron Bytheway
2// SPDX-License-Identifier: MIT
3
4//! `backbeat convert`: decode a dump to sparse-wide Parquet, driven entirely by its embedded schema.
5//!
6//! The output is one table (the layout settled in the project design):
7//!
8//! * **Dense common columns** present on every row: `seq`, `ts_nanos`, `event`, `event_id`,
9//!   `instance_id`, plus every `#[event(key)]` field promoted to a top-level (nullable) column.
10//! * **Per-event struct columns**: one nullable `Struct` column per event type holding that event's
11//!   remaining (non-key) fields. A row carries a value only in its own event's struct; all other
12//!   struct columns are null. Parquet's columnar encoding makes that sparsity essentially free.
13//!
14//! Every field is decoded from its raw bytes using only the registry's `FieldType`/offset/width, so
15//! the converter has no compiled-in knowledge of any event. Enums render as their label, interned
16//! ids resolve against the intern table, byte arrays render as hex. Each column carries its
17//! backbeat semantics (role, unit, span phase, description) as Arrow *field* metadata, and the
18//! dump's `instance_id`/`host` go in the footer key-value metadata — so the Parquet is
19//! self-describing without copying the (potentially large) raw dump into it.
20
21use crate::model::Loaded;
22use anyhow::{Context, Result};
23use arrow::{
24    array::{ArrayRef, BooleanBuilder, Int64Builder, StringBuilder, StructArray, UInt64Builder},
25    buffer::NullBuffer,
26    datatypes::{DataType, Field, Fields, Schema},
27    record_batch::RecordBatch,
28};
29use backbeat::{
30    schema::{FieldRole, FieldType, Phase},
31    wire::{OwnedField, OwnedSchema},
32};
33use parquet::{
34    arrow::ArrowWriter,
35    file::{metadata::KeyValue, properties::WriterProperties},
36};
37use std::{
38    collections::{HashMap, HashSet},
39    fs::File,
40    path::Path,
41    sync::Arc,
42};
43
44/// Writes the records of one or more loaded dumps to `output` as one sparse-wide Parquet table.
45/// Returns the total row count. `host` overrides the dumps' host label in the footer when non-empty.
46/// `zstd_level` is the zstd compression level (1–22).
47pub fn to_parquet(dumps: &[Loaded], output: &Path, host: &str, zstd_level: i32) -> Result<usize> {
48    // Union the registries across all dumps by event id (schemas with the same id are identical
49    // since the id is the fnv1a64 of the layout's qualified name). Sorted for stable column order.
50    let mut schemas: Vec<OwnedSchema> = Vec::new();
51    let mut seen = HashSet::new();
52    for d in dumps {
53        for s in &d.schemas {
54            if seen.insert(s.id.get()) {
55                schemas.push(s.clone());
56            }
57        }
58    }
59    schemas.sort_by(|a, b| a.qualified_name.cmp(&b.qualified_name));
60    let by_id: HashMap<u64, usize> = schemas
61        .iter()
62        .enumerate()
63        .map(|(i, s)| (s.id.get(), i))
64        .collect();
65
66    // Flatten every dump's records into merged rows, tagged with their source instance_id, and sort
67    // into the global order `(ts_nanos, instance_id, shard_id, local_seq)`.
68    let mut rows: Vec<Row> = Vec::new();
69    for d in dumps {
70        for r in &d.records {
71            let id = d.schemas[r.schema_idx].id.get();
72            rows.push(Row {
73                ts_nanos: r.ts_nanos,
74                instance_id: d.instance_id,
75                shard_id: r.shard_id,
76                local_seq: r.local_seq,
77                schema_idx: by_id[&id],
78                fields: &r.fields,
79                intern: &d.intern,
80            });
81        }
82    }
83    rows.sort_by_key(|r| (r.ts_nanos, r.instance_id, r.shard_id, r.local_seq));
84
85    let batch = build_batch(&schemas, &rows)?;
86
87    // Footer metadata: a host override wins; else the first dump's host. instance_id is per-row, so
88    // it is a column, not footer metadata, once dumps are merged.
89    let footer_host = if !host.is_empty() {
90        host
91    } else {
92        dumps.first().map(|d| d.host.as_str()).unwrap_or("")
93    };
94    write_parquet(output, &batch, footer_host, zstd_level)?;
95    Ok(rows.len())
96}
97
98/// One merged row across all input dumps, borrowing its field bytes and intern table from the
99/// owning [`Loaded`].
100struct Row<'a> {
101    ts_nanos: u64,
102    instance_id: u64,
103    shard_id: u32,
104    local_seq: u64,
105    /// Index into the unioned `schemas`.
106    schema_idx: usize,
107    fields: &'a [u8],
108    intern: &'a HashMap<u32, String>,
109}
110
111/// A decoded field value, mapped onto one of Parquet's four column kinds.
112enum Value {
113    U64(u64),
114    I64(i64),
115    Bool(bool),
116    Str(String),
117}
118
119/// Decodes a field from a record's field bytes, or `None` if the bytes are too short.
120fn decode_field(field: &OwnedField, bytes: &[u8], intern: &HashMap<u32, String>) -> Option<Value> {
121    let start = field.offset as usize;
122    let end = start + field.width as usize;
123    let slice = bytes.get(start..end)?;
124    Some(match field.ty {
125        FieldType::U8 => Value::U64(slice[0] as u64),
126        FieldType::U16 => Value::U64(u16::from_le_bytes(slice.try_into().ok()?) as u64),
127        FieldType::U32 => Value::U64(u32::from_le_bytes(slice.try_into().ok()?) as u64),
128        FieldType::U64 => Value::U64(u64::from_le_bytes(slice.try_into().ok()?)),
129        FieldType::I8 => Value::I64(slice[0] as i8 as i64),
130        FieldType::I16 => Value::I64(i16::from_le_bytes(slice.try_into().ok()?) as i64),
131        FieldType::I32 => Value::I64(i32::from_le_bytes(slice.try_into().ok()?) as i64),
132        FieldType::I64 => Value::I64(i64::from_le_bytes(slice.try_into().ok()?)),
133        FieldType::Bool => Value::Bool(slice[0] != 0),
134        FieldType::Bytes => Value::Str(hex(slice)),
135        FieldType::Enum { repr } => {
136            let raw = read_uint(slice, repr as usize)?;
137            let label = field
138                .enum_labels
139                .iter()
140                .find(|l| l.value == raw)
141                .map(|l| l.label.clone())
142                .unwrap_or_else(|| raw.to_string());
143            Value::Str(label)
144        }
145        FieldType::Interned { .. } => {
146            let id = u32::from_le_bytes(slice.get(..4)?.try_into().ok()?);
147            Value::Str(intern.get(&id).cloned().unwrap_or_else(|| format!("#{id}")))
148        }
149        // FieldType is #[non_exhaustive]; a future kind we don't understand renders as hex bytes.
150        _ => Value::Str(hex(slice)),
151    })
152}
153
154/// Reads a little-endian unsigned integer of `width` (1, 2, 4, or 8) bytes.
155fn read_uint(slice: &[u8], width: usize) -> Option<u64> {
156    let mut buf = [0u8; 8];
157    buf.get_mut(..width)?.copy_from_slice(slice.get(..width)?);
158    Some(u64::from_le_bytes(buf))
159}
160
161fn hex(bytes: &[u8]) -> String {
162    let mut s = String::with_capacity(bytes.len() * 2);
163    for b in bytes {
164        s.push_str(&format!("{b:02x}"));
165    }
166    s
167}
168
169/// The Parquet column kind a field decodes to.
170fn arrow_type(ty: &FieldType) -> DataType {
171    match ty {
172        FieldType::U8 | FieldType::U16 | FieldType::U32 | FieldType::U64 => DataType::UInt64,
173        FieldType::I8 | FieldType::I16 | FieldType::I32 | FieldType::I64 => DataType::Int64,
174        FieldType::Bool => DataType::Boolean,
175        FieldType::Bytes | FieldType::Enum { .. } | FieldType::Interned { .. } => DataType::Utf8,
176        // FieldType is #[non_exhaustive]; unknown future kinds render as text (see decode_field).
177        _ => DataType::Utf8,
178    }
179}
180
181/// A display name per schema index: the `qualified_name`, suffixed with `#<id-hex>` only when more
182/// than one schema shares that name (distinct content-addressed ids → genuinely distinct event
183/// types). Used for both the `event` column value and the per-event struct column name, so a merged
184/// dump with two versions of an event produces two unambiguous, separately-queryable columns.
185fn display_names(schemas: &[OwnedSchema]) -> Vec<String> {
186    let mut counts: HashMap<&str, usize> = HashMap::new();
187    for s in schemas {
188        *counts.entry(s.qualified_name.as_str()).or_default() += 1;
189    }
190    schemas
191        .iter()
192        .map(|s| {
193            if counts[s.qualified_name.as_str()] > 1 {
194                format!("{}#{:016x}", s.qualified_name, s.id.get())
195            } else {
196                s.qualified_name.clone()
197            }
198        })
199        .collect()
200}
201
202/// Whether a field's role makes it a top-level promoted column (keys and span ids) versus a field
203/// nested under its per-event struct.
204fn is_promoted(role: FieldRole) -> bool {
205    matches!(
206        role,
207        FieldRole::Key | FieldRole::SpanId | FieldRole::ParentSpanId
208    )
209}
210
211/// Arrow field-level metadata mirroring a field's backbeat semantics (role, unit, description), so
212/// the Parquet schema is self-describing without the original dump.
213fn field_metadata(f: &OwnedField) -> HashMap<String, String> {
214    let mut m = HashMap::new();
215    let role = match f.role {
216        FieldRole::None => None,
217        FieldRole::Key => Some("key"),
218        FieldRole::SpanId => Some("span_id"),
219        FieldRole::ParentSpanId => Some("parent_span_id"),
220        _ => None,
221    };
222    if let Some(role) = role {
223        m.insert("backbeat.role".to_string(), role.to_string());
224    }
225    if let Some(unit) = &f.unit {
226        m.insert("backbeat.unit".to_string(), unit.clone());
227    }
228    if let Some(desc) = &f.description {
229        m.insert("backbeat.description".to_string(), desc.clone());
230    }
231    m
232}
233
234/// Arrow metadata for a per-event struct column: the span phase and the event's description.
235fn event_metadata(s: &OwnedSchema) -> HashMap<String, String> {
236    let mut m = HashMap::new();
237    let phase = match s.phase {
238        Phase::None => None,
239        Phase::Enter => Some("enter"),
240        Phase::Exit => Some("exit"),
241        _ => None,
242    };
243    if let Some(phase) = phase {
244        m.insert("backbeat.span".to_string(), phase.to_string());
245    }
246    if let Some(desc) = &s.description {
247        m.insert("backbeat.description".to_string(), desc.clone());
248    }
249    m
250}
251
252/// A typed column builder that can append a decoded [`Value`] or a null.
253enum Col {
254    U64(UInt64Builder),
255    I64(Int64Builder),
256    Bool(BooleanBuilder),
257    Str(StringBuilder),
258}
259
260impl Col {
261    fn new(dt: &DataType) -> Self {
262        match dt {
263            DataType::UInt64 => Col::U64(UInt64Builder::new()),
264            DataType::Int64 => Col::I64(Int64Builder::new()),
265            DataType::Boolean => Col::Bool(BooleanBuilder::new()),
266            DataType::Utf8 => Col::Str(StringBuilder::new()),
267            other => unreachable!("unexpected column type {other:?}"),
268        }
269    }
270
271    fn append(&mut self, v: Value) {
272        match (self, v) {
273            (Col::U64(b), Value::U64(x)) => b.append_value(x),
274            (Col::I64(b), Value::I64(x)) => b.append_value(x),
275            (Col::Bool(b), Value::Bool(x)) => b.append_value(x),
276            (Col::Str(b), Value::Str(x)) => b.append_value(x),
277            // Type drift between schema and value: record a null rather than panicking.
278            (c, _) => c.append_null(),
279        }
280    }
281
282    fn append_null(&mut self) {
283        match self {
284            Col::U64(b) => b.append_null(),
285            Col::I64(b) => b.append_null(),
286            Col::Bool(b) => b.append_null(),
287            Col::Str(b) => b.append_null(),
288        }
289    }
290
291    fn finish(&mut self) -> ArrayRef {
292        match self {
293            Col::U64(b) => Arc::new(b.finish()),
294            Col::I64(b) => Arc::new(b.finish()),
295            Col::Bool(b) => Arc::new(b.finish()),
296            Col::Str(b) => Arc::new(b.finish()),
297        }
298    }
299}
300
301/// Builds the sparse-wide [`RecordBatch`] from the merged rows.
302fn build_batch(schemas: &[OwnedSchema], rows: &[Row]) -> Result<RecordBatch> {
303    // Per-schema display name. Two schemas can share a `qualified_name` (same event, different
304    // builds → different content-addressed ids); they are genuinely distinct event types, so we
305    // disambiguate the name with a `#<id>` suffix *only* when it collides. Unique names stay clean.
306    let names = display_names(schemas);
307
308    // --- Plan the columns. ---
309    // Promoted columns, unioned by name across all events (first declaration wins the type). Keys
310    // and span ids are all promoted to the top level so they are queryable/join-able directly; the
311    // rest of each event's fields nest under its per-event struct.
312    let mut key_names: Vec<String> = Vec::new();
313    let mut key_type: HashMap<String, DataType> = HashMap::new();
314    for s in schemas {
315        for f in s.fields.iter().filter(|f| is_promoted(f.role)) {
316            if !key_type.contains_key(&f.name) {
317                key_names.push(f.name.clone());
318                key_type.insert(f.name.clone(), arrow_type(&f.ty));
319            }
320        }
321    }
322
323    // Dense common builders.
324    let mut seq = UInt64Builder::new();
325    let mut ts = UInt64Builder::new();
326    let mut event = StringBuilder::new();
327    let mut event_id = UInt64Builder::new();
328    let mut instance_id = UInt64Builder::new();
329    let mut key_cols: Vec<Col> = key_names.iter().map(|n| Col::new(&key_type[n])).collect();
330
331    // Per-event struct child builders + the struct's own row validity.
332    struct EventCols {
333        /// (field, child builder) for each non-promoted field of this event.
334        children: Vec<(OwnedField, Col)>,
335        /// Whether each row belongs to this event (the struct's null mask).
336        valid: Vec<bool>,
337    }
338    let mut event_cols: Vec<EventCols> = schemas
339        .iter()
340        .map(|s| EventCols {
341            children: s
342                .fields
343                .iter()
344                .filter(|f| !is_promoted(f.role))
345                .map(|f| (f.clone(), Col::new(&arrow_type(&f.ty))))
346                .collect(),
347            valid: Vec::with_capacity(rows.len()),
348        })
349        .collect();
350
351    // --- Fill row by row. ---
352    for (i, row) in rows.iter().enumerate() {
353        let s = &schemas[row.schema_idx];
354        seq.append_value(i as u64);
355        ts.append_value(row.ts_nanos);
356        event.append_value(&names[row.schema_idx]);
357        event_id.append_value(s.id.get());
358        instance_id.append_value(row.instance_id);
359
360        // Top-level promoted columns: value if this event declares the column, else null.
361        for (name, col) in key_names.iter().zip(key_cols.iter_mut()) {
362            match s
363                .fields
364                .iter()
365                .find(|f| is_promoted(f.role) && &f.name == name)
366            {
367                Some(f) => match decode_field(f, row.fields, row.intern) {
368                    Some(v) => col.append(v),
369                    None => col.append_null(),
370                },
371                None => col.append_null(),
372            }
373        }
374
375        // Per-event structs: fill this event's struct, null the rest.
376        for (idx, ec) in event_cols.iter_mut().enumerate() {
377            let mine = idx == row.schema_idx;
378            ec.valid.push(mine);
379            for (f, col) in ec.children.iter_mut() {
380                if mine {
381                    match decode_field(f, row.fields, row.intern) {
382                        Some(v) => col.append(v),
383                        None => col.append_null(),
384                    }
385                } else {
386                    col.append_null();
387                }
388            }
389        }
390    }
391
392    // --- Assemble arrays + arrow schema fields. ---
393    let mut fields: Vec<Field> = Vec::new();
394    let mut arrays: Vec<ArrayRef> = Vec::new();
395
396    fields.push(Field::new("seq", DataType::UInt64, false));
397    arrays.push(Arc::new(seq.finish()));
398    fields.push(Field::new("ts_nanos", DataType::UInt64, false));
399    arrays.push(Arc::new(ts.finish()));
400    fields.push(Field::new("event", DataType::Utf8, false));
401    arrays.push(Arc::new(event.finish()));
402    fields.push(Field::new("event_id", DataType::UInt64, false));
403    arrays.push(Arc::new(event_id.finish()));
404    fields.push(Field::new("instance_id", DataType::UInt64, false));
405    arrays.push(Arc::new(instance_id.finish()));
406
407    for (name, mut col) in key_names.iter().zip(key_cols.into_iter()) {
408        // A promoted column may be declared by several events; describe it from the first.
409        let decl = schemas
410            .iter()
411            .find_map(|s| s.fields.iter().find(|f| &f.name == name));
412        let mut field = Field::new(name, key_type[name].clone(), true);
413        if let Some(f) = decl {
414            field = field.with_metadata(field_metadata(f));
415        }
416        fields.push(field);
417        arrays.push(col.finish());
418    }
419
420    for (idx, mut ec) in event_cols.into_iter().enumerate() {
421        let s = &schemas[idx];
422        // An event whose fields are all promoted (e.g. a span enter with only span/parent ids) or a
423        // marker event with no fields has nothing left to nest. Parquet can't write an empty struct,
424        // and there is nothing to put in one — the dense `event` column already marks which rows are
425        // this event — so we simply omit its struct column.
426        if ec.children.is_empty() {
427            continue;
428        }
429        let child_fields: Fields = ec
430            .children
431            .iter()
432            .map(|(f, _)| {
433                Field::new(&f.name, arrow_type(&f.ty), true).with_metadata(field_metadata(f))
434            })
435            .collect::<Vec<_>>()
436            .into();
437        let child_arrays: Vec<ArrayRef> = ec.children.iter_mut().map(|(_, c)| c.finish()).collect();
438        let nulls = NullBuffer::from(ec.valid);
439        let struct_array = StructArray::new(child_fields.clone(), child_arrays, Some(nulls));
440        let dt = DataType::Struct(child_fields);
441        // Carry the event's span phase + description as struct-column metadata, so the Parquet is
442        // self-describing without the original dump. Use the (collision-disambiguated) display name.
443        let mut field = Field::new(&names[idx], dt, true);
444        field = field.with_metadata(event_metadata(s));
445        fields.push(field);
446        arrays.push(Arc::new(struct_array));
447    }
448
449    let schema = Arc::new(Schema::new(fields));
450    RecordBatch::try_new(schema, arrays).context("assembling record batch")
451}
452
453/// Writes `batch` to `output` as Parquet, recording `host` in the footer key-value metadata.
454///
455/// The per-row `instance_id` is a column (dumps may be merged), not footer metadata. We deliberately
456/// do *not* embed the original dump: the records already are the Parquet rows, and the per-column
457/// semantics (role, unit, span phase, description) ride along as Arrow *field* metadata on the
458/// schema (see [`build_batch`]). Copying the raw shard rings into the footer would roughly double
459/// the file for no gain.
460fn write_parquet(output: &Path, batch: &RecordBatch, host: &str, zstd_level: i32) -> Result<()> {
461    let mut kv = vec![KeyValue::new(
462        "backbeat.format".to_string(),
463        "1".to_string(),
464    )];
465    if !host.is_empty() {
466        kv.push(KeyValue::new("backbeat.host".to_string(), host.to_string()));
467    }
468    // Trace data is highly repetitive (dictionary-friendly event names, low-cardinality ids,
469    // sorted timestamps), so zstd compresses it dramatically.
470    let level = parquet::basic::ZstdLevel::try_new(zstd_level)
471        .with_context(|| format!("invalid zstd level {zstd_level} (valid range is 1–22)"))?;
472    let props = WriterProperties::builder()
473        .set_compression(parquet::basic::Compression::ZSTD(level))
474        .set_key_value_metadata(Some(kv))
475        .build();
476    let file = File::create(output).with_context(|| format!("creating {}", output.display()))?;
477    let mut writer = ArrowWriter::try_new(file, batch.schema(), Some(props))?;
478    writer.write(batch)?;
479    writer.close()?;
480    Ok(())
481}