Skip to main content

backbeat_cli/
model.rs

1// Copyright (c) 2026 Cameron Bytheway
2// SPDX-License-Identifier: MIT
3
4//! Shared dump-loading model behind both output formats.
5//!
6//! A dump is decoded into one [`Loaded`] **per instance** it contains — its `instance_id`, the
7//! shared schema registry, that instance's intern table, and the flat list of records recovered from
8//! its shards (sorted into the global `(ts_nanos, shard_id, local_seq)` order). A single-process
9//! dump yields one `Loaded`; a merged dump yields one per process it bundles. The Parquet
10//! ([`crate::convert`]) and Chrome-trace ([`crate::trace`]) writers both build on `&[Loaded]`, so a
11//! merged file decodes to exactly the same slice that loading its source dumps separately would —
12//! `convert merged.bb` is byte-identical to `convert a.bb b.bb`.
13
14use anyhow::{Context, Result};
15use backbeat::{
16    record::RecordView,
17    ring::walk,
18    wire::{DumpReader, OwnedSchema},
19};
20use bytes::Bytes;
21use rayon::prelude::*;
22use std::{
23    collections::{HashMap, HashSet},
24    fs,
25    path::{Path, PathBuf},
26};
27
28/// One decoded record, attributed to a schema within its [`Loaded`] dump.
29pub struct Rec {
30    pub ts_nanos: u64,
31    pub shard_id: u32,
32    /// Position within the shard, oldest-first — the per-shard `local_seq`.
33    pub local_seq: u64,
34    /// Index into the owning [`Loaded::schemas`].
35    pub schema_idx: usize,
36    /// The event's raw field bytes (length equals the schema's `record_size`). A cheap refcounted
37    /// slice into the dump's reconstructed-payload arena — cloning it bumps a count, not the heap.
38    pub fields: Bytes,
39}
40
41/// A single decoded instance: its identity, the (shared) registry, its intern table, and its
42/// recovered records. A dump file decodes to one of these per instance it contains.
43pub struct Loaded {
44    /// Where it was read from (for error messages).
45    pub path: PathBuf,
46    /// The producing process's id; `(instance_id, span_id)` keys spans across merged dumps.
47    pub instance_id: u64,
48    /// Host label from this instance's metadata (empty if unset).
49    pub host: String,
50    /// The dump's schema registry, sorted by `qualified_name` for deterministic output. Shared by
51    /// every instance in the same file (the registry is unified, not per-instance).
52    pub schemas: Vec<OwnedSchema>,
53    /// The dump's registered query-DDL view sets (verbatim text), in file order. Dump-level like the
54    /// registry — every instance decoded from one file carries the same list (convert dedups by
55    /// content across files).
56    pub views: Vec<String>,
57    /// This instance's interned `id → string` for `Interned` fields.
58    pub intern: HashMap<u32, String>,
59    /// Every valid record from this instance's shards, in global `(ts_nanos, shard_id, local_seq)`
60    /// order.
61    pub records: Vec<Rec>,
62}
63
64/// The identity of a logged record, used both to order records globally and to drop duplicates when
65/// dumps overlap. The tuple is `(ts_nanos, instance_id, shard_id, event_id, fields)`.
66///
67/// Successive dumps of one process share a ring, so the newer dump re-contains records the older one
68/// already captured; merging or converting them together would otherwise double-count. Two records
69/// that match on this whole key are the *same* logged event — the recorder writes monotonic
70/// timestamps, so a genuine pair of distinct events on one shard differs in at least one component —
71/// so collapsing them to one is correct.
72///
73/// Time leads the tuple so sorting by it yields the global order the converters want *and* makes
74/// byte-identical records adjacent, letting [`unique_records`] dedup with a single sort + scan
75/// rather than a hash set. `local_seq` is deliberately absent: it is assigned per-walk, so the same
76/// event gets different seqs in two dumps and could never match — it is only a sort tiebreaker, and
77/// `(event_id, fields)` already breaks ties deterministically. The fields are borrowed (a zero-copy
78/// slice of the dump buffer), so a key costs no allocation.
79pub type RecordKey<'a> = (u64, u64, u32, u64, &'a [u8]);
80
81/// Computes the [`RecordKey`] for a record within its owning [`Loaded`].
82pub fn record_key<'a>(d: &Loaded, r: &'a Rec) -> RecordKey<'a> {
83    (
84        r.ts_nanos,
85        d.instance_id,
86        r.shard_id,
87        d.schemas[r.schema_idx].id.get(),
88        &r.fields,
89    )
90}
91
92/// Loads and decodes a dump from `bytes`, returning one [`Loaded`] per instance it contains.
93/// `path` is carried for diagnostics. A single-process dump yields a one-element vec; a merged dump
94/// yields one entry per process it bundles, each with its own intern table, host, and records.
95pub fn load(path: &Path, bytes: Bytes) -> Result<Vec<Loaded>> {
96    let reader = DumpReader::new(bytes).map_err(|e| anyhow::anyhow!("{e}"))?;
97    let mut schemas = reader.schemas().map_err(|e| anyhow::anyhow!("{e}"))?;
98    let intern_tables = reader.intern_tables().map_err(|e| anyhow::anyhow!("{e}"))?;
99    let metas = reader.metas().map_err(|e| anyhow::anyhow!("{e}"))?;
100    let views = reader.views().map_err(|e| anyhow::anyhow!("{e}"))?;
101    let shards = reader.shards().map_err(|e| anyhow::anyhow!("{e}"))?;
102
103    // Deterministic registry order regardless of how the producer's inventory was linked. Shared by
104    // every instance in this file.
105    schemas.sort_by(|a, b| a.qualified_name.cmp(&b.qualified_name));
106    let by_id: HashMap<u64, usize> = schemas
107        .iter()
108        .enumerate()
109        .map(|(i, s)| (s.id.get(), i))
110        .collect();
111
112    // Per-instance intern table: id → string, keyed by the owning instance.
113    let mut intern_by_instance: HashMap<u64, HashMap<u32, String>> = HashMap::new();
114    for table in intern_tables {
115        let map = intern_by_instance.entry(table.instance_id).or_default();
116        for (id, bytes) in table.entries {
117            map.insert(id, String::from_utf8_lossy(&bytes).into_owned());
118        }
119    }
120
121    // Walk every shard, attributing each record to a schema and bucketing by the shard's instance.
122    // Shards are independent (no shared state in the walk), so we parse them in parallel with rayon.
123    // The closure is walk's validator (see backbeat::ring::walk): accept a candidate only if its
124    // event_id is registered and its declared record_size matches the field bytes, so walk
125    // resynchronizes past torn data.
126    //
127    // `walk` hands us each payload as a `Bytes` — a zero-copy refcounted slice of the shard region
128    // (itself a slice of the file buffer), except for the one record per shard that wraps the ring
129    // boundary. So storing a record's fields is a refcount bump, not a per-record copy.
130    let walked: Vec<(u64, Vec<Rec>)> = shards
131        .par_iter()
132        .map(|shard| {
133            let mut shard_recs: Vec<Rec> = Vec::new();
134            // walk yields newest-first; assign `local_seq` descending from `u64::MAX` so the global
135            // ascending sort by `(ts, shard, local_seq)` orders this shard oldest-first — no second
136            // pass. (Only the relative order matters; the absolute value is just a sort tiebreaker.)
137            let mut seq = u64::MAX;
138            walk(
139                &shard.region,
140                shard.head as usize,
141                shard.capacity as usize,
142                |payload| {
143                    let Some(rec) = RecordView::parse(&payload[..]) else {
144                        return false;
145                    };
146                    match by_id.get(&rec.event_id.get()) {
147                        Some(&idx) if rec.fields.len() == schemas[idx].record_size as usize => {
148                            // The fields follow the fixed `[ts][event_id]` prefix; slice them out of
149                            // the owned `Bytes` zero-copy (RecordView only borrowed it to validate).
150                            let fields_off = payload.len() - rec.fields.len();
151                            shard_recs.push(Rec {
152                                ts_nanos: rec.ts_nanos,
153                                shard_id: shard.shard_id,
154                                local_seq: seq,
155                                schema_idx: idx,
156                                fields: payload.slice(fields_off..),
157                            });
158                            seq -= 1;
159                            true
160                        }
161                        _ => false,
162                    }
163                },
164            );
165            (shard.instance_id, shard_recs)
166        })
167        .collect();
168
169    // Group records by instance. The set of instances is the union of those that have metadata,
170    // an intern table, or any shards — so an instance with shards but no Meta still surfaces (with
171    // id 0 / empty host, matching a metadata-less single-process dump's old behavior).
172    let mut records_by_instance: HashMap<u64, Vec<Rec>> = HashMap::new();
173    for (instance_id, recs) in walked {
174        records_by_instance
175            .entry(instance_id)
176            .or_default()
177            .extend(recs);
178    }
179
180    let mut instance_ids: Vec<u64> = Vec::new();
181    let mut seen = HashSet::new();
182    for m in &metas {
183        if seen.insert(m.instance_id) {
184            instance_ids.push(m.instance_id);
185        }
186    }
187    for &id in records_by_instance.keys() {
188        if seen.insert(id) {
189            instance_ids.push(id);
190        }
191    }
192    // A dump with neither metadata nor shards still loads as one empty instance.
193    if instance_ids.is_empty() {
194        instance_ids.push(0);
195    }
196    // Deterministic instance order regardless of HashMap iteration.
197    instance_ids.sort_unstable();
198
199    let host_of: HashMap<u64, String> =
200        metas.into_iter().map(|m| (m.instance_id, m.host)).collect();
201
202    let loaded = instance_ids
203        .into_iter()
204        .map(|instance_id| {
205            let mut records = records_by_instance.remove(&instance_id).unwrap_or_default();
206            records.sort_by_key(|r| (r.ts_nanos, r.shard_id, r.local_seq));
207            Loaded {
208                path: path.to_path_buf(),
209                instance_id,
210                host: host_of.get(&instance_id).cloned().unwrap_or_default(),
211                schemas: schemas.clone(),
212                views: views.clone(),
213                intern: intern_by_instance.remove(&instance_id).unwrap_or_default(),
214                records,
215            }
216        })
217        .collect();
218    Ok(loaded)
219}
220
221/// Returns a reference to every record across `dumps`, sorted into the global order
222/// `(ts_nanos, instance_id, shard_id, event_id, fields)`, with duplicates removed.
223///
224/// A duplicate is any record sharing another's full [`RecordKey`] — which happens whenever
225/// overlapping dumps are combined (successive dumps of one process re-capture shared ring contents).
226/// Because the key leads with the fields the converters sort by and ends with the bytes that
227/// distinguish records, sorting on it both produces the output order *and* lands byte-identical
228/// records side by side, so a single linear `dedup` scan removes them. No hash set: the only extra
229/// memory is the `Vec` of borrowed `(&Loaded, &Rec)` pairs (the field bytes stay in the dump
230/// buffer), which the caller needs anyway. Callers therefore receive already-sorted, de-duplicated
231/// records and need no further sort.
232pub fn unique_records(dumps: &[Loaded]) -> Vec<(&Loaded, &Rec)> {
233    let total: usize = dumps.iter().map(|d| d.records.len()).sum();
234    let mut out: Vec<(&Loaded, &Rec)> = Vec::with_capacity(total);
235    for d in dumps {
236        for r in &d.records {
237            out.push((d, r));
238        }
239    }
240    out.sort_by(|a, b| record_key(a.0, a.1).cmp(&record_key(b.0, b.1)));
241    out.dedup_by(|a, b| record_key(a.0, a.1) == record_key(b.0, b.1));
242    out
243}
244
245/// Loads several dumps in parallel (rayon), flattening each file's instances. Files are returned in
246/// input order; instances within a file in ascending `instance_id` order.
247pub fn load_many(paths: &[PathBuf]) -> Result<Vec<Loaded>> {
248    let per_file: Vec<Vec<Loaded>> = paths
249        .par_iter()
250        .map(|p| {
251            let bytes = fs::read(p).with_context(|| format!("reading dump {}", p.display()))?;
252            load(p, bytes.into()).with_context(|| format!("decoding dump {}", p.display()))
253        })
254        .collect::<Result<_>>()?;
255    Ok(per_file.into_iter().flatten().collect())
256}