Skip to main content

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}