Skip to main content

backbeat_cli/
merge.rs

1// Copyright (c) 2026 Cameron Bytheway
2// SPDX-License-Identifier: MIT
3
4//! `backbeat merge`: combine several `.bb` dumps into one multi-instance `.bb`.
5//!
6//! The dump format is inherently multi-instance — Meta, Intern, and Shard sections are each tagged
7//! with the `instance_id` of the process that produced them, and the schema registry is content
8//! addressed by a stable [`EventId`](backbeat::id::EventId). Merging therefore has two modes:
9//!
10//! * **Default (dedup + trim).** Decode each input's records, drop duplicates — the same logged
11//!   event re-captured by overlapping dumps (e.g. one process's successive ring snapshots) — and
12//!   re-pack the survivors into fresh, compact shards. The output is the smallest faithful dump:
13//!   one row per distinct event. This is what you want before analysis, and it avoids paying to
14//!   store/transfer redundant ring data.
15//!
16//! * **`--no-dedup` (raw splice).** Copy every input's Meta/Intern/Shard bodies through verbatim and
17//!   union the registries. Nothing is decoded, so it is cheap and lossless, but overlapping dumps
18//!   keep their duplicates. Use this to quickly concatenate a host's dumps for upload; `convert`
19//!   always dedups on the way out, so the duplicates never reach the final table.
20//!
21//! Either way the registry is unioned by id (identical event types collapse to one entry) and
22//! `instance_id`s are preserved, so converting the merged file yields exactly what converting the
23//! inputs together would.
24
25use crate::model::{self, Loaded, Rec};
26use anyhow::{Context, Result};
27use backbeat::{
28    format::SectionKind,
29    record::{FIELDS_OFFSET, ID_OFFSET, TS_OFFSET},
30    ring::{LEN_SUFFIX, MAX_RECORD},
31    wire::{DumpReader, DumpWriter, OwnedSchema},
32};
33use rayon::prelude::*;
34use std::{
35    collections::{BTreeMap, HashSet},
36    fs,
37    path::{Path, PathBuf},
38};
39
40/// Merges `inputs` into one multi-instance dump written to `output`.
41///
42/// When `dedup` is true (the default), records are decoded, de-duplicated, and re-packed into
43/// compact shards. When false, the inputs' sections are spliced through verbatim (a cheap raw
44/// concat). Returns the number of distinct event schemas in the unified registry.
45pub fn merge(inputs: &[PathBuf], output: &Path, dedup: bool) -> Result<usize> {
46    if dedup {
47        merge_dedup(inputs, output)
48    } else {
49        merge_splice(inputs, output)
50    }
51}
52
53// ---------------------------------------------------------------------------
54// Raw splice (`--no-dedup`): copy section bodies through verbatim.
55// ---------------------------------------------------------------------------
56
57/// One input dump's spliceable contents: its raw, undecoded section bodies plus the schemas it
58/// contributes (decoded only so the registry can be unioned by id).
59struct Spliceable {
60    schemas: Vec<OwnedSchema>,
61    metas: Vec<Vec<u8>>,
62    interns: Vec<Vec<u8>>,
63    shards: Vec<Vec<u8>>,
64    /// Query-DDL view sets (verbatim text). Dump-level like the registry — unioned by content
65    /// across inputs (a fleet of one binary carries identical copies), never per-instance.
66    views: Vec<String>,
67}
68
69/// Reads one dump into its [`Spliceable`] form. The Meta/Intern/Shard bodies are taken verbatim
70/// (never re-decoded); only the registry and views are parsed, so they can be unioned.
71fn read_spliceable(path: &Path, bytes: bytes::Bytes) -> Result<Spliceable> {
72    let reader = DumpReader::new(bytes)
73        .map_err(|e| anyhow::anyhow!("{e}"))
74        .with_context(|| format!("reading dump {}", path.display()))?;
75    let schemas = reader.schemas().map_err(|e| anyhow::anyhow!("{e}"))?;
76    let views = reader.views().map_err(|e| anyhow::anyhow!("{e}"))?;
77    Ok(Spliceable {
78        schemas,
79        metas: reader.raw_bodies(SectionKind::Meta),
80        interns: reader.raw_bodies(SectionKind::Intern),
81        shards: reader.raw_bodies(SectionKind::Shard),
82        views,
83    })
84}
85
86/// Raw concat: union registries, copy every instance-tagged section through verbatim.
87fn merge_splice(inputs: &[PathBuf], output: &Path) -> Result<usize> {
88    let spliceables: Vec<Spliceable> = inputs
89        .par_iter()
90        .map(|p| {
91            let bytes = fs::read(p).with_context(|| format!("reading dump {}", p.display()))?;
92            read_spliceable(p, bytes.into())
93        })
94        .collect::<Result<_>>()?;
95
96    let mut writer = DumpWriter::new();
97
98    let mut seen = HashSet::new();
99    let mut registry: Vec<OwnedSchema> = Vec::new();
100    for s in &spliceables {
101        for schema in &s.schemas {
102            if seen.insert(schema.id.get()) {
103                registry.push(schema.clone());
104            }
105        }
106    }
107    writer.schema_registry_owned(&registry);
108
109    // Union view sets by content, first-seen order — a fleet of one binary contributes identical
110    // copies, so dedup keeps the merged dump from carrying N redundant copies of the same DDL.
111    let mut seen_views = HashSet::new();
112    for s in &spliceables {
113        for sql in &s.views {
114            if seen_views.insert(sql.clone()) {
115                writer.views(sql);
116            }
117        }
118    }
119
120    for s in spliceables {
121        for body in s.metas {
122            writer.raw_section(SectionKind::Meta, body);
123        }
124        for body in s.interns {
125            writer.raw_section(SectionKind::Intern, body);
126        }
127        for body in s.shards {
128            writer.raw_section(SectionKind::Shard, body);
129        }
130    }
131
132    let bytes = writer.finish();
133    fs::write(output, &bytes).with_context(|| format!("writing {}", output.display()))?;
134    Ok(registry.len())
135}
136
137// ---------------------------------------------------------------------------
138// Dedup + trim (default): decode, drop duplicates, re-pack compact shards.
139// ---------------------------------------------------------------------------
140
141/// Decode every input, drop duplicate records, and re-encode compact per-(instance, shard) rings.
142fn merge_dedup(inputs: &[PathBuf], output: &Path) -> Result<usize> {
143    // Decode all inputs to their per-instance `Loaded` form (records are zero-copy slices of the
144    // file buffers). `load_many` already flattens each file's instances.
145    let dumps = model::load_many(inputs)?;
146
147    let mut writer = DumpWriter::new();
148
149    // Union the registries by content-addressed id, preserving first-seen order.
150    let mut seen = HashSet::new();
151    let mut registry: Vec<OwnedSchema> = Vec::new();
152    for d in &dumps {
153        for schema in &d.schemas {
154            if seen.insert(schema.id.get()) {
155                registry.push(schema.clone());
156            }
157        }
158    }
159    writer.schema_registry_owned(&registry);
160
161    // Union view sets across inputs by content (first-seen order), from the already-loaded dumps —
162    // `Loaded` carries the dump's views, so no second read of the files (the splice path unions
163    // from `Spliceable.views` the same way).
164    let mut seen_views = HashSet::new();
165    for sql in dumps.iter().flat_map(|d| &d.views) {
166        if seen_views.insert(sql.clone()) {
167            writer.views(sql);
168        }
169    }
170
171    // Group the de-duplicated records by their owning (instance_id, shard_id). `unique_records`
172    // drops any record re-captured by an overlapping dump, so each survivor is emitted once.
173    // BTreeMap keeps a deterministic instance/shard order in the output. We keep the `(Loaded, Rec)`
174    // pair so re-packing can resolve each record's event id via its schema.
175    let mut by_shard: BTreeMap<(u64, u32), Vec<(&Loaded, &Rec)>> = BTreeMap::new();
176    let mut hosts: BTreeMap<u64, String> = BTreeMap::new();
177    let mut interns: BTreeMap<u64, Vec<(u32, Vec<u8>)>> = BTreeMap::new();
178    let mut instance_ids: HashSet<u64> = HashSet::new();
179    for (d, r) in model::unique_records(&dumps) {
180        by_shard
181            .entry((d.instance_id, r.shard_id))
182            .or_default()
183            .push((d, r));
184        instance_ids.insert(d.instance_id);
185    }
186    // Carry each instance's host label and intern table through. Every instance that appeared in
187    // any input keeps its metadata even if dedup left it with no records, matching what loading the
188    // sources separately would surface. Intern entries dedupe by id (an id maps to one string within
189    // an instance, so any input's copy is fine).
190    for d in &dumps {
191        instance_ids.insert(d.instance_id);
192        hosts.entry(d.instance_id).or_insert_with(|| d.host.clone());
193        let table = interns.entry(d.instance_id).or_default();
194        let present: HashSet<u32> = table.iter().map(|(id, _)| *id).collect();
195        for (id, s) in &d.intern {
196            if !present.contains(id) {
197                table.push((*id, s.clone().into_bytes()));
198            }
199        }
200    }
201
202    // Emit one Meta + Intern per instance (deterministic order), then the compact shards. Each
203    // rebuilt ring is sized to exactly hold its surviving records (rounded to a power of two, as
204    // `Ring` requires), so the output carries no dead ring space.
205    let mut ordered: Vec<u64> = instance_ids.into_iter().collect();
206    ordered.sort_unstable();
207    for instance_id in ordered {
208        writer.meta(
209            instance_id,
210            hosts.get(&instance_id).map_or("", |h| h.as_str()),
211        );
212        if let Some(table) = interns.get(&instance_id) {
213            if !table.is_empty() {
214                let entries = table.iter().map(|(id, b)| (*id, b.as_slice()));
215                writer.intern_table(instance_id, entries);
216            }
217        }
218    }
219    for ((instance_id, shard_id), recs) in &by_shard {
220        let (region, head) = repack_shard(recs);
221        writer.shard(*instance_id, *shard_id, head, &region);
222    }
223
224    let bytes = writer.finish();
225    fs::write(output, &bytes).with_context(|| format!("writing {}", output.display()))?;
226    Ok(registry.len())
227}
228
229/// Re-packs a shard's surviving records into a fresh, compactly-sized ring region.
230///
231/// Records are written oldest-first as `[ts][event_id][fields]` payloads each followed by the
232/// little-endian length suffix — exactly the layout [`crate::model::load`] walks back out. The
233/// region is the next power of two large enough to hold them all (a `Ring` capacity must be a power
234/// of two), and `head` is the total bytes written, so the walk recovers every record and nothing
235/// else. Each record's event id comes from its owning [`Loaded`]'s schema. Returns `(region, head)`.
236///
237/// `recs` are passed oldest-first (`Loaded::records` is sorted ascending by `(ts, shard, seq)`), so
238/// we write them in order — oldest at offset 0, newest nearest `head` — and a re-walk from `head`
239/// recovers them newest-first, exactly as the original ring did.
240fn repack_shard(recs: &[(&Loaded, &Rec)]) -> (Vec<u8>, u64) {
241    // Each record occupies prefix(16) + fields + suffix(2). Sum to size the ring exactly.
242    let needed: usize = recs
243        .iter()
244        .map(|(_, r)| FIELDS_OFFSET + r.fields.len() + LEN_SUFFIX)
245        .sum();
246    let capacity = needed.max(1).next_power_of_two();
247    let mut region = vec![0u8; capacity];
248
249    let mut at = 0usize;
250    for (d, r) in recs {
251        let event_id = d.schemas[r.schema_idx].id.get();
252        at += write_record(&mut region[at..], r.ts_nanos, event_id, &r.fields);
253    }
254    (region, at as u64)
255}
256
257/// Writes one record payload + length suffix at the front of `out`, returning the bytes consumed.
258/// Layout matches [`backbeat::record`]: `[ts][event_id][fields]` then the LE length suffix.
259fn write_record(out: &mut [u8], ts_nanos: u64, event_id: u64, fields: &[u8]) -> usize {
260    let payload_len = FIELDS_OFFSET + fields.len();
261    debug_assert!(
262        payload_len <= MAX_RECORD,
263        "re-packed record exceeds MAX_RECORD"
264    );
265    out[TS_OFFSET..ID_OFFSET].copy_from_slice(&ts_nanos.to_le_bytes());
266    out[ID_OFFSET..FIELDS_OFFSET].copy_from_slice(&event_id.to_le_bytes());
267    out[FIELDS_OFFSET..payload_len].copy_from_slice(fields);
268    out[payload_len..payload_len + LEN_SUFFIX].copy_from_slice(&(payload_len as u16).to_le_bytes());
269    payload_len + LEN_SUFFIX
270}