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}