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(®istry);
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(®istry);
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, ®ion);
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}