Skip to main content

choir_sequencer/
lag.rs

1//! Production measurement of the merge-decision latency gate.
2//!
3//! The Phase-0 gate ("p99 decision latency < 100 ms") is asserted in the
4//! test suite, on a machine doing nothing else, against a synthetic
5//! workload. That is a check that cannot fail in production: the running
6//! daemon measured nothing, so a node that had drifted past the gate under
7//! real traffic would look exactly like one that had not.
8//!
9//! [`LagMeter`] closes that. The writer thread records every accepted op
10//! at the single point where every accepted op passes, so a new submit
11//! path cannot be added and silently go unmeasured. Two latencies, because
12//! they answer different questions and PHASE0 measured them as an order of
13//! magnitude apart once `sync_data` landed:
14//!
15//! - `decision`: dequeue -> append. The metric the gate is *stated* in.
16//! - `durable`: dequeue -> acknowledgement, which includes the batch's
17//!   durability barrier. The number a submitter actually waits out, and
18//!   the one an fsync stall shows up in. Gating on `decision` alone would
19//!   have been the same non-check in a new place: the dominant cost sits
20//!   entirely outside it.
21//!
22//! A breach is `durable >= gate`, for that reason. `decision` breaches are
23//! counted separately so the gate as literally written stays checkable.
24
25use std::collections::VecDeque;
26use std::sync::Mutex;
27use std::time::{Duration, SystemTime, UNIX_EPOCH};
28
29/// The Phase-0 merge-decision latency gate.
30pub const DEFAULT_GATE: Duration = Duration::from_millis(100);
31
32/// Most breaches held for the daemon to drain before the oldest are
33/// dropped. A breach storm is itself the signal; the count of what was
34/// dropped is reported rather than the ring silently eating it.
35const MAX_PENDING_BREACHES: usize = 256;
36
37/// Number of power-of-two microsecond buckets. Bucket `i` covers
38/// `[2^(i-1), 2^i)` us for `i > 0` and `[0, 1)` for `i = 0`, so bucket 31
39/// tops out above 35 minutes: nothing real falls off the end.
40const BUCKETS: usize = 32;
41
42/// One op that missed the gate, as recorded for the lag log.
43#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct Breach {
45    /// Position in the total order, so the breach can be tied back to the
46    /// op in the log rather than floating free as a timestamp.
47    pub seq: u64,
48    /// Dequeue -> append, microseconds.
49    pub decision_us: u64,
50    /// Dequeue -> acknowledgement including the durability barrier,
51    /// microseconds.
52    pub durable_us: u64,
53    /// Ops sharing this op's durability barrier. A large batch explains a
54    /// large `durable` without implicating the storage.
55    pub batch: usize,
56    /// Wall clock at record time, milliseconds since the epoch. Only for
57    /// correlating with other logs; `seq` is the identity.
58    pub at_unix_ms: u64,
59}
60
61/// A power-of-two-bucketed latency histogram in microseconds.
62#[derive(Debug, Default, Clone)]
63struct Histogram {
64    buckets: [u64; BUCKETS],
65    count: u64,
66    max_us: u64,
67}
68
69impl Histogram {
70    fn record(&mut self, us: u64) {
71        let index = if us == 0 {
72            0
73        } else {
74            // 64 - leading_zeros is the 1-based bit length, i.e. the
75            // exponent of the next power of two above `us`.
76            usize::try_from(u64::BITS - us.leading_zeros()).unwrap_or(BUCKETS - 1)
77        };
78        self.buckets[index.min(BUCKETS - 1)] += 1;
79        self.count += 1;
80        self.max_us = self.max_us.max(us);
81    }
82
83    /// Upper bound of the bucket holding the `percentile`th observation.
84    ///
85    /// Bucketed, so this is an over-estimate bounded by a factor of two,
86    /// never an under-estimate. That direction is deliberate: a gate
87    /// report that errs toward "you are closer to the limit than this"
88    /// cannot quietly pass a node that is actually breaching.
89    fn quantile_us(&self, percentile: f64) -> Option<u64> {
90        if self.count == 0 {
91            return None;
92        }
93        #[allow(
94            clippy::cast_precision_loss,
95            clippy::cast_sign_loss,
96            clippy::cast_possible_truncation
97        )]
98        let target = ((self.count as f64) * percentile / 100.0).ceil().max(1.0) as u64;
99        let mut seen = 0;
100        for (index, hits) in self.buckets.iter().enumerate() {
101            seen += hits;
102            if seen >= target {
103                // Bucket i's exclusive upper bound is 2^i, capped at the
104                // observed maximum so a report never claims more lag than
105                // was measured. The cap cannot understate: every value in
106                // the bucket is <= max_us by definition.
107                let bound = 1u64
108                    .checked_shl(u32::try_from(index).unwrap_or(0))
109                    .unwrap_or(u64::MAX);
110                return Some(bound.min(self.max_us));
111            }
112        }
113        Some(self.max_us)
114    }
115}
116
117#[derive(Debug, Default)]
118struct State {
119    decision: Histogram,
120    durable: Histogram,
121    decision_breaches: u64,
122    durable_breaches: u64,
123    pending: VecDeque<Breach>,
124    dropped: u64,
125}
126
127/// Shared latency record for one sequencer's writer thread.
128///
129/// Written by the writer, read and drained by the daemon. Deliberately a
130/// plain observation surface with no policy of its own: what a node *does*
131/// about a breach (write a file, page someone, refuse traffic) is an
132/// operator decision, the same reasoning that keeps
133/// `SequencerHandle::durability_failed` an observation rather than an
134/// action.
135#[derive(Debug)]
136pub struct LagMeter {
137    state: Mutex<State>,
138    gate: Mutex<Duration>,
139}
140
141impl Default for LagMeter {
142    fn default() -> Self {
143        Self::new()
144    }
145}
146
147impl LagMeter {
148    /// A meter gated at [`DEFAULT_GATE`].
149    #[must_use]
150    pub fn new() -> Self {
151        Self {
152            state: Mutex::new(State::default()),
153            gate: Mutex::new(DEFAULT_GATE),
154        }
155    }
156
157    /// The latency at or above which an op is recorded as a breach.
158    #[must_use]
159    pub fn gate(&self) -> Duration {
160        *self.gate.lock().expect("gate lock")
161    }
162
163    /// Moves the gate. Live rather than construction-time so a gate can be
164    /// tightened against a running node, and so a test can prove the check
165    /// is capable of failing.
166    pub fn set_gate(&self, gate: Duration) {
167        *self.gate.lock().expect("gate lock") = gate;
168    }
169
170    /// Records one accepted op.
171    pub fn record(&self, seq: u64, decision: Duration, durable: Duration, batch: usize) {
172        let gate = self.gate();
173        let decision_us = u64::try_from(decision.as_micros()).unwrap_or(u64::MAX);
174        let durable_us = u64::try_from(durable.as_micros()).unwrap_or(u64::MAX);
175        let mut state = self.state.lock().expect("lag state lock");
176        state.decision.record(decision_us);
177        state.durable.record(durable_us);
178        if decision >= gate {
179            state.decision_breaches += 1;
180        }
181        if durable >= gate {
182            state.durable_breaches += 1;
183            if state.pending.len() == MAX_PENDING_BREACHES {
184                state.pending.pop_front();
185                state.dropped += 1;
186            }
187            state.pending.push_back(Breach {
188                seq,
189                decision_us,
190                durable_us,
191                batch,
192                at_unix_ms: u64::try_from(
193                    SystemTime::now()
194                        .duration_since(UNIX_EPOCH)
195                        .unwrap_or_default()
196                        .as_millis(),
197                )
198                .unwrap_or(u64::MAX),
199            });
200        }
201    }
202
203    /// Takes the breaches recorded since the last drain, and how many were
204    /// dropped from the ring before this drain could reach them.
205    pub fn drain(&self) -> (Vec<Breach>, u64) {
206        let mut state = self.state.lock().expect("lag state lock");
207        let dropped = std::mem::take(&mut state.dropped);
208        (state.pending.drain(..).collect(), dropped)
209    }
210
211    /// A point-in-time summary since process start.
212    #[must_use]
213    pub fn report(&self) -> LagReport {
214        let state = self.state.lock().expect("lag state lock");
215        LagReport {
216            gate_us: u64::try_from(self.gate().as_micros()).unwrap_or(u64::MAX),
217            observed_ops: state.durable.count,
218            decision_p50_us: state.decision.quantile_us(50.0),
219            decision_p99_us: state.decision.quantile_us(99.0),
220            decision_max_us: state.decision.max_us,
221            decision_breaches: state.decision_breaches,
222            durable_p50_us: state.durable.quantile_us(50.0),
223            durable_p99_us: state.durable.quantile_us(99.0),
224            durable_max_us: state.durable.max_us,
225            durable_breaches: state.durable_breaches,
226            pending_breaches: state.pending.len(),
227        }
228    }
229}
230
231/// What the meter has seen since the process started.
232///
233/// Percentiles are bucket upper bounds capped at the observed maximum, so
234/// they over-estimate by at most a factor of two and never under-estimate.
235#[derive(Debug, Clone, PartialEq, Eq)]
236pub struct LagReport {
237    /// The gate these counts are measured against, microseconds.
238    pub gate_us: u64,
239    /// Accepted ops measured. Rejections are not ops and are not counted.
240    pub observed_ops: u64,
241    /// Median dequeue -> append: the gate as literally written.
242    pub decision_p50_us: Option<u64>,
243    /// 99th percentile dequeue -> append.
244    pub decision_p99_us: Option<u64>,
245    /// Slowest dequeue -> append observed.
246    pub decision_max_us: u64,
247    /// Accepted ops whose append alone reached the gate.
248    pub decision_breaches: u64,
249    /// Median dequeue -> acknowledgement, durability barrier included:
250    /// what a submitter waits out.
251    pub durable_p50_us: Option<u64>,
252    /// 99th percentile dequeue -> acknowledgement.
253    pub durable_p99_us: Option<u64>,
254    /// Slowest dequeue -> acknowledgement observed.
255    pub durable_max_us: u64,
256    /// Accepted ops that reached the gate through the barrier. This is
257    /// the breach count the lag log records.
258    pub durable_breaches: u64,
259    /// Breaches recorded but not yet drained to the operator's lag log.
260    pub pending_breaches: usize,
261}
262
263#[cfg(test)]
264mod tests {
265    use super::*;
266
267    #[test]
268    fn quantiles_never_understate_and_stay_within_a_factor_of_two() {
269        let meter = LagMeter::new();
270        // 95 fast ops and 5 slow ones. Nearest-rank p99 over 100 samples
271        // is the 99th ordered value, so it must land on a slow one: a tail
272        // that thick reading clean is the failure mode this guards.
273        for seq in 0..95 {
274            meter.record(seq, Duration::from_micros(50), Duration::from_micros(50), 1);
275        }
276        for seq in 95..100 {
277            meter.record(
278                seq,
279                Duration::from_millis(400),
280                Duration::from_millis(400),
281                1,
282            );
283        }
284        let report = meter.report();
285        assert_eq!(report.observed_ops, 100);
286        let p99 = report.durable_p99_us.expect("observations recorded");
287        assert!(p99 >= 400_000, "p99 must not understate the slow op: {p99}");
288        assert!(
289            p99 <= 800_000,
290            "bucketing may over-estimate at most 2x: {p99}"
291        );
292        let p50 = report.durable_p50_us.expect("observations recorded");
293        assert!((50..=100).contains(&p50), "p50 sits on the fast ops: {p50}");
294        assert_eq!(report.durable_max_us, 400_000);
295    }
296
297    #[test]
298    fn a_breach_is_recorded_and_drained_once() {
299        let meter = LagMeter::new();
300        meter.record(7, Duration::from_millis(1), Duration::from_millis(250), 4);
301        meter.record(8, Duration::from_millis(1), Duration::from_millis(1), 4);
302        let report = meter.report();
303        assert_eq!(report.durable_breaches, 1);
304        // The op was fast to append and slow to make durable. Gating on
305        // the append alone is exactly the blind spot this exists for.
306        assert_eq!(report.decision_breaches, 0);
307
308        let (breaches, dropped) = meter.drain();
309        assert_eq!(dropped, 0);
310        assert_eq!(breaches.len(), 1);
311        assert_eq!(breaches[0].seq, 7);
312        assert_eq!(breaches[0].batch, 4);
313        assert!(breaches[0].durable_us >= 250_000);
314        // Drained means handed over, not forgotten: the totals survive so
315        // a summary is not reset by whoever reads the log.
316        assert_eq!(meter.report().durable_breaches, 1);
317        assert!(meter.drain().0.is_empty(), "a breach is handed over once");
318    }
319
320    #[test]
321    fn overflowing_the_ring_reports_what_it_dropped() {
322        let meter = LagMeter::new();
323        for seq in 0..(MAX_PENDING_BREACHES as u64 + 5) {
324            meter.record(seq, Duration::ZERO, Duration::from_millis(200), 1);
325        }
326        let (breaches, dropped) = meter.drain();
327        assert_eq!(breaches.len(), MAX_PENDING_BREACHES);
328        assert_eq!(dropped, 5, "silently dropping breaches would be the bug");
329        // The oldest went, not the newest: a storm's tail is the part an
330        // operator still has other evidence for.
331        assert_eq!(breaches[0].seq, 5);
332    }
333
334    #[test]
335    fn an_empty_meter_reports_no_percentiles_rather_than_zero() {
336        let report = LagMeter::new().report();
337        assert_eq!(report.observed_ops, 0);
338        // Zero would read as "well inside the gate" on a node that has
339        // measured nothing at all.
340        assert_eq!(report.durable_p99_us, None);
341        assert_eq!(report.decision_p99_us, None);
342        assert_eq!(report.gate_us, 100_000);
343    }
344}