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 once into a [`Loaded`] — its `instance_id`, schema registry, intern table, and
7//! the flat list of records recovered from every shard (sorted into the global
8//! `(ts_nanos, shard_id, local_seq)` order). The Parquet ([`crate::convert`]) and Chrome-trace
9//! ([`crate::trace`]) writers both build on `&[Loaded]`, so multi-dump merge is uniform: load each
10//! input (in parallel), then hand the slice to the writer.
11
12use anyhow::{Context, Result};
13use backbeat::{
14    record::RecordView,
15    ring::walk,
16    wire::{DumpReader, OwnedSchema},
17};
18use bytes::Bytes;
19use rayon::prelude::*;
20use std::{
21    collections::HashMap,
22    fs,
23    path::{Path, PathBuf},
24};
25
26/// One decoded record, attributed to a schema within its [`Loaded`] dump.
27pub struct Rec {
28    pub ts_nanos: u64,
29    pub shard_id: u32,
30    /// Position within the shard, oldest-first — the per-shard `local_seq`.
31    pub local_seq: u64,
32    /// Index into the owning [`Loaded::schemas`].
33    pub schema_idx: usize,
34    /// The event's raw field bytes (length equals the schema's `record_size`). A cheap refcounted
35    /// slice into the dump's reconstructed-payload arena — cloning it bumps a count, not the heap.
36    pub fields: Bytes,
37}
38
39/// A single decoded dump: its identity, registry, intern table, and recovered records.
40pub struct Loaded {
41    /// Where it was read from (for error messages).
42    pub path: PathBuf,
43    /// The producing process's id; `(instance_id, span_id)` keys spans across merged dumps.
44    pub instance_id: u64,
45    /// Host label from the dump's metadata (empty if unset).
46    pub host: String,
47    /// The schema registry, sorted by `qualified_name` for deterministic output.
48    pub schemas: Vec<OwnedSchema>,
49    /// Interned `id → string` for `Interned` fields.
50    pub intern: HashMap<u32, String>,
51    /// Every valid record, in global `(ts_nanos, shard_id, local_seq)` order.
52    pub records: Vec<Rec>,
53}
54
55/// Loads and decodes a single dump from `bytes`. `path` is carried for diagnostics.
56pub fn load(path: &Path, bytes: Bytes) -> Result<Loaded> {
57    let reader = DumpReader::new(bytes).map_err(|e| anyhow::anyhow!("{e}"))?;
58    let mut schemas = reader.schemas().map_err(|e| anyhow::anyhow!("{e}"))?;
59    let intern_pairs = reader.intern_table().map_err(|e| anyhow::anyhow!("{e}"))?;
60    let meta = reader.meta().map_err(|e| anyhow::anyhow!("{e}"))?;
61    let shards = reader.shards().map_err(|e| anyhow::anyhow!("{e}"))?;
62
63    // Deterministic registry order regardless of how the producer's inventory was linked.
64    schemas.sort_by(|a, b| a.qualified_name.cmp(&b.qualified_name));
65
66    let intern: HashMap<u32, String> = intern_pairs
67        .into_iter()
68        .map(|(id, bytes)| (id, String::from_utf8_lossy(&bytes).into_owned()))
69        .collect();
70    let by_id: HashMap<u64, usize> = schemas
71        .iter()
72        .enumerate()
73        .map(|(i, s)| (s.id.get(), i))
74        .collect();
75
76    // Walk every shard, attributing each record to a schema. Shards are independent (no shared
77    // state in the walk), so we parse them in parallel with rayon and concatenate. The closure is
78    // walk's validator (see backbeat::ring::walk): accept a candidate only if its event_id is
79    // registered and its declared record_size matches the field bytes, so walk resynchronizes past
80    // torn data.
81    //
82    // `walk` hands us each payload as a `Bytes` — a zero-copy refcounted slice of the shard region
83    // (itself a slice of the file buffer), except for the one record per shard that wraps the ring
84    // boundary. So storing a record's fields is a refcount bump, not a per-record copy.
85    let mut records: Vec<Rec> = shards
86        .par_iter()
87        .flat_map(|shard| {
88            let mut shard_recs: Vec<Rec> = Vec::new();
89            // walk yields newest-first; assign `local_seq` descending from `u64::MAX` so the global
90            // ascending sort by `(ts, shard, local_seq)` orders this shard oldest-first — no second
91            // pass. (Only the relative order matters; the absolute value is just a sort tiebreaker.)
92            let mut seq = u64::MAX;
93            walk(
94                &shard.region,
95                shard.head as usize,
96                shard.capacity as usize,
97                |payload| {
98                    let Some(rec) = RecordView::parse(&payload[..]) else {
99                        return false;
100                    };
101                    match by_id.get(&rec.event_id.get()) {
102                        Some(&idx) if rec.fields.len() == schemas[idx].record_size as usize => {
103                            // The fields follow the fixed `[ts][event_id]` prefix; slice them out of
104                            // the owned `Bytes` zero-copy (RecordView only borrowed it to validate).
105                            let fields_off = payload.len() - rec.fields.len();
106                            shard_recs.push(Rec {
107                                ts_nanos: rec.ts_nanos,
108                                shard_id: shard.shard_id,
109                                local_seq: seq,
110                                schema_idx: idx,
111                                fields: payload.slice(fields_off..),
112                            });
113                            seq -= 1;
114                            true
115                        }
116                        _ => false,
117                    }
118                },
119            );
120            shard_recs
121        })
122        .collect();
123    records.sort_by_key(|r| (r.ts_nanos, r.shard_id, r.local_seq));
124
125    let (instance_id, host) = meta
126        .map(|m| (m.instance_id, m.host))
127        .unwrap_or((0, String::new()));
128    Ok(Loaded {
129        path: path.to_path_buf(),
130        instance_id,
131        host,
132        schemas,
133        intern,
134        records,
135    })
136}
137
138/// Loads several dumps in parallel (rayon). Returns them in input order.
139pub fn load_many(paths: &[PathBuf]) -> Result<Vec<Loaded>> {
140    paths
141        .par_iter()
142        .map(|p| {
143            let bytes = fs::read(p).with_context(|| format!("reading dump {}", p.display()))?;
144            load(p, bytes.into()).with_context(|| format!("decoding dump {}", p.display()))
145        })
146        .collect()
147}