mira_core/signal.rs
1//! What logs, traces and metrics have in common on the write path.
2//!
3//! Very little, as it turns out, and that is the point of this being a small
4//! trait rather than a framework. The three signals share no columns and no
5//! tables; what they share is the *lifecycle* — accumulate rows across many
6//! export requests, answer "would the next request overflow a dictionary",
7//! answer "how big are you", and seal into a set of named Arrow tables plus a
8//! time range.
9//!
10//! That lifecycle is the flusher's state machine in `mira::pipeline`, which is
11//! about a hundred lines of genuinely subtle code: deferred jobs carried into
12//! the next block, a deadline that starts at the first row rather than the last
13//! flush, ordering preserved across a mid-batch seal, a builder replaced rather
14//! than reused after a failed `finish`. Three hand-copied versions of that would
15//! be three places for the next bug in it to be fixed once and missed twice.
16//! One trait and one generic flusher is the cheaper of the two.
17//!
18//! Deliberately *not* in here: anything about columns, attributes or schemas.
19//! Every signal's layout is its own, because OTLP's are.
20
21use arrow_array::RecordBatch;
22
23use crate::Result;
24
25/// A block's worth of rows, sealed and ready for [`crate::block::publish`].
26///
27/// The table names are the file stems inside the published directory
28/// (`logs.arrow`, `log_attrs.arrow`, …), so they are part of the on-disk format
29/// and not a display detail.
30pub struct Sealed {
31 pub tables: Vec<(&'static str, RecordBatch)>,
32 /// Opaque files published alongside the tables, by name.
33 ///
34 /// Indexes over what a block contains, for the questions the block name
35 /// cannot answer — see [`crate::bloom`]. The read path treats a missing
36 /// sidecar as "no information", so an old block or a signal that publishes
37 /// none costs nothing but a scan.
38 pub sidecars: Vec<(&'static str, Vec<u8>)>,
39 /// Oldest and newest row. These become the block's directory name, which is
40 /// how the read path prunes without opening a file.
41 pub min_ts: i64,
42 pub max_ts: i64,
43 /// Rows in the root table. Reported, not derived from `tables`, because
44 /// "how many spans" is not "how many rows in the biggest table".
45 pub num_rows: usize,
46}
47
48impl Sealed {
49 /// Assemble a sealed block, deriving the sidecars that every signal gets.
50 ///
51 /// The attribute filter and the zone map are built here rather than in each
52 /// signal's `seal` for one reason: a signal that forgets to build one is
53 /// merely slow, but a signal that grows a new attribute table and forgets to
54 /// *include* it publishes an index that omits real values, and the read path
55 /// then skips blocks that hold matching rows. Deriving them from the tables,
56 /// once, makes that class of mistake unavailable — and the zone map needs it
57 /// even more than the filter does, since a key missing from it is read as
58 /// "this block has no such value" rather than as a false negative in a
59 /// probabilistic structure.
60 ///
61 /// Also where the empty-block timestamp sentinels are normalized, which was
62 /// three copies of the same pair of `if`s, and where the range is clamped
63 /// non-negative. That clamp is the second half of `logs::nanos` — not a
64 /// link, because it is `pub(crate)` and rustdoc will not resolve one:
65 /// the encoders keep an out-of-range OTLP timestamp from ever becoming a
66 /// negative one, and this is the single funnel all three of them seal
67 /// through, so it is the cheapest place to guarantee that
68 /// [`crate::block::publish`] and `block::scan` cannot disagree. They would:
69 /// `dir_name` formats a negative with a leading `-` and `parse_dir_name`
70 /// splits on `-`, so a block with a negative `min_ts` is published, acked
71 /// as durable, and then invisible to every query and to retention forever.
72 pub fn new(
73 num_rows: usize,
74 tables: Vec<(&'static str, RecordBatch)>,
75 min_ts: i64,
76 max_ts: i64,
77 ) -> Sealed {
78 Sealed::with(Sidecars::Build, num_rows, tables, min_ts, max_ts)
79 }
80
81 /// As [`Sealed::new`], but `Sidecars::Skip` leaves them out.
82 ///
83 /// Only [`SignalBuilder::snapshot`] skips them, and only because they are
84 /// the expensive half of a seal — `attrs::index` and `zone::index` walk
85 /// every attribute row, which the encode bench measures at roughly three
86 /// times the cost of appending that row in the first place. A snapshot has
87 /// no directory to publish them into and is always scanned, so building
88 /// them would be pure waste repeated on every idle tick.
89 pub fn with(
90 sidecars: Sidecars,
91 num_rows: usize,
92 tables: Vec<(&'static str, RecordBatch)>,
93 min_ts: i64,
94 max_ts: i64,
95 ) -> Sealed {
96 let mut built = Vec::new();
97 if sidecars == Sidecars::Build {
98 if let Some(b) = crate::attrs::index(&tables) {
99 built.push((crate::bloom::ATTR_IDX, b));
100 }
101 if let Some(b) = crate::zone::index(&tables) {
102 built.push((crate::zone::ZONE_IDX, b));
103 }
104 }
105 let sidecars = built;
106 Sealed {
107 num_rows,
108 tables,
109 sidecars,
110 min_ts: if min_ts == i64::MAX { 0 } else { min_ts.max(0) },
111 max_ts: if max_ts == i64::MIN { 0 } else { max_ts.max(0) },
112 }
113 }
114
115 /// Attach a signal-specific sidecar. `None` writes nothing, which the reader
116 /// reads as "no information about this block" — which is also how
117 /// [`Sidecars::Skip`] gets away with omitting all of them.
118 pub fn with_sidecar(mut self, name: &'static str, bytes: Option<Vec<u8>>) -> Sealed {
119 if let Some(b) = bytes {
120 self.sidecars.push((name, b));
121 }
122 self
123 }
124
125 pub fn table(&self, name: &str) -> Option<&RecordBatch> {
126 self.tables.iter().find(|(n, _)| *n == name).map(|(_, b)| b)
127 }
128}
129
130/// Whether a seal derives the pruning sidecars, or skips them.
131#[derive(Clone, Copy, PartialEq, Eq)]
132pub enum Sidecars {
133 Build,
134 Skip,
135}
136
137/// A snapshot of the open, unsealed block: rows that have been acknowledged but
138/// not yet published, in the same Arrow shape the read path already knows.
139///
140/// This is the read-your-writes repair the write-ahead log made necessary
141/// (ARCHITECTURE section 4). With the log on, an export is acknowledged when its bytes
142/// reach the page cache, which is well before the block that will hold them is
143/// sealed and renamed into place — so without this, a client that just got a
144/// `200` and queried immediately would see nothing for up to `max_block_age`.
145///
146/// `seq` is the sequence the block *will* publish under, and that is what makes
147/// the whole thing cheap. A snapshot holds the builder's rows from row zero, in
148/// the order they will be written, so row `n` of the snapshot is row `n` of the
149/// eventual block. A cursor handed out over open data is therefore still exactly
150/// correct after the seal, and the read path can drop the snapshot the moment a
151/// real block turns up under the same `(node, seq)` — no rebasing, no dedupe by
152/// content, no second identity for a row.
153pub struct Open {
154 pub node: u32,
155 pub seq: u64,
156 pub sealed: Sealed,
157}
158
159/// One signal's accumulator, from the flusher's point of view.
160pub trait SignalBuilder: Default + Send + 'static {
161 /// The decoded OTLP export request this signal accepts.
162 type Request: Send + 'static;
163
164 /// Directory component under the data dir. Also the block name prefix, so
165 /// changing it is an on-disk format change.
166 const SIGNAL: &'static str;
167
168 /// Whether `req` is guaranteed to fit without overflowing a `UInt16`
169 /// dictionary or id space.
170 ///
171 /// Must be conservative: an Arrow builder cannot be rolled back, so the
172 /// flusher relies on a `true` here meaning [`Self::append_request`] will not
173 /// fail on capacity. Answering `false` unnecessarily costs a slightly small
174 /// block; answering `true` wrongly wedges the node.
175 fn has_headroom_for(&self, req: &Self::Request) -> bool;
176
177 /// Absorb one export request, returning the number of root-table rows added.
178 fn append_request(&mut self, req: &Self::Request) -> Result<usize>;
179
180 /// Rough resident cost, with variable-width heaps measured rather than
181 /// estimated from row counts — a 32 KB GenAI prompt must not weigh the same
182 /// as a 20-byte one.
183 fn approx_bytes(&self) -> usize;
184
185 fn is_empty(&self) -> bool;
186
187 /// Seal and reset. After this returns, `self` is a fresh builder — including
188 /// on the error path, where the caller has no way to know how far through
189 /// the column-by-column finish it got.
190 fn finish(&mut self) -> Result<Sealed>;
191
192 /// Materialise the rows accumulated so far *without* resetting, for
193 /// [`Open`]. Sidecar-free; see [`Sealed::with`].
194 ///
195 /// Costs one buffer copy per column, so the flusher only calls it when its
196 /// channel has drained — under sustained ingest the channel never empties,
197 /// the snapshot never runs, and the read-your-writes window closes on its
198 /// own because a busy block seals in well under `max_block_age`.
199 fn snapshot(&self) -> Result<Sealed>;
200}
201
202#[cfg(test)]
203mod tests {
204 use super::*;
205
206 /// The invariant the block directory name cannot express. Every encoder
207 /// clamps per row already; this asserts the funnel underneath them, because
208 /// a negative that gets this far is a block nothing can read or delete.
209 #[test]
210 fn the_sealed_range_is_never_negative() {
211 let s = Sealed::new(1, vec![], -1, -1);
212 assert_eq!((s.min_ts, s.max_ts), (0, 0));
213 let s = Sealed::new(1, vec![], i64::MIN, 5_000);
214 assert_eq!((s.min_ts, s.max_ts), (0, 5_000));
215 // ...and the empty-block sentinels still normalize to a zero range.
216 let s = Sealed::new(0, vec![], i64::MAX, i64::MIN);
217 assert_eq!((s.min_ts, s.max_ts), (0, 0));
218 assert!(s.sidecars.is_empty(), "no attribute table, no filter");
219 }
220}