Skip to main content

nros_log/
throttle.rs

1//! phase-417 W4.d — rate-limited logging, on the `Logger` everything else uses.
2//!
3//! ## Why this lives here and not in `nros-core`
4//!
5//! Before this module there WAS a throttle in the tree —
6//! `nros_core::logger::Logger::{debug,info,warn,error,trace}_throttle` — on a
7//! DIFFERENT `Logger` type: one that forwards to the `log` crate and therefore
8//! never reaches `nros_platform_log_write`. So the capability existed on the
9//! logger nothing dispatches through, and was missing from the logger
10//! everything dispatches through. RFC-0019 makes the Rust API the source of
11//! truth for C and C++, and the type the FFI hands out is
12//! [`crate::Logger`] — so the throttle has to be here for the wrappers to be
13//! thin.
14//!
15//! ## The decision function is shared, deliberately
16//!
17//! Two storages need this rule: a `'static` [`ThrottleState`] (what the Rust
18//! macros declare at the call site) and a caller-owned `uint64_t` (what the C
19//! macros declare, because a C call site cannot name a Rust type without a
20//! hand-mirrored struct — the drift class issue 0160 catalogues). Both call
21//! [`throttle_decide`]. RFC-0089: a second *spelling* is free, a second *code
22//! path that can produce a different answer* is not.
23//!
24//! ## Contract
25//!
26//! - The FIRST record at a call site always emits. `rclcpp`'s
27//!   `RCLCPP_*_THROTTLE` does the same; `nros_core`'s old `should_log_throttled`
28//!   did NOT (it started from `last = 0` and compared `now >= last + interval`,
29//!   so at `now < interval` the first message was dropped).
30//! - The window is measured from the last EMITTED record, not from the last
31//!   attempt.
32//! - The severity threshold is checked BEFORE the window, so a record filtered
33//!   by [`crate::Logger::level`] does not consume the window.
34//! - A clock that does not move is a window that never elapses: a site whose
35//!   clock is stuck (at ANY value, `0` included) emits once and then never
36//!   (issue 1152). A clock that wraps still measures real elapsed time across
37//!   the wrap. Both are tested below.
38
39use core::cell::UnsafeCell;
40
41use portable_atomic::{AtomicBool, Ordering};
42
43/// The state word of a site that has never emitted. `0` because the C macros
44/// declare their storage as a zero-initialised `static uint64_t`, so this is
45/// the one value a fresh C site can carry without an initialiser.
46const NEVER: u64 = 0;
47
48/// Bit 0 of the state word: set once the site has emitted. The sentinel is a
49/// FLAG, not a value in the timestamp domain — see the module docs.
50const ARMED: u64 = 1;
51
52/// Elapsed time is measured in this many bits: the state word keeps 63 bits of
53/// timestamp above the flag, so the clock is reduced mod 2^63 on the way in
54/// and the subtraction is reduced the same way on the way out.
55const ELAPSED_MASK: u64 = u64::MAX >> 1;
56
57/// The whole rule, as a pure function. Both storages call this.
58///
59/// `state` is the site's state word — `NEVER` for a site that has not
60/// emitted, otherwise what an earlier call returned. Returns `Some(next)` iff a
61/// record raised at `now_ns` should be EMITTED, and `next` is the word to store
62/// in its place; `None` means suppressed and the word is left alone. Returning
63/// the encoded word rather than a bare `bool` is what keeps the encoding in
64/// ONE place: a caller cannot admit with this rule and then arm with a
65/// different spelling of "last emitted" (issue 1152 was exactly that — the
66/// arming step stored `1` for a clock reading `0`, and the rule then measured
67/// `u64::MAX` of elapsed time against it).
68///
69/// ## Two clock pathologies, one rule (issue 1152)
70///
71/// * A WRAPPING clock (a platform counter rolling over, or a `u64` derived from
72///   a 32-bit counter) must still measure real elapsed time across the wrap,
73///   so the elapsed time is `now - last` in modular arithmetic, never
74///   `last + interval` (which overflows to a tiny number and admits everything
75///   for the rest of the program).
76/// * A STUCK clock (a port whose clock is broken and reports a constant —
77///   including `0`, which is what a build without the platform clock reports)
78///   must admit ONCE and then never, because a stuck clock is exactly when a
79///   log flood is most likely. That needs `now == last` to read as zero
80///   elapsed, which a sentinel living in the timestamp domain cannot deliver:
81///   some real reading has to be displaced to keep the sentinel unambiguous,
82///   and a clock stuck on that reading then never equals what was stored.
83///
84/// The word is `(last_ns << 1) | ARMED`: the flag is bit 0 and the timestamp
85/// keeps 63 bits above it. A clock that wraps at 2^64 also wraps at 2^63, so
86/// reducing both the stored reading and the subtraction mod 2^63 measures
87/// elapsed time exactly across the wrap — the only thing given up is that a
88/// jump of exactly 2^63 ns (292 years) between two records reads as no time
89/// at all.
90#[must_use]
91pub const fn throttle_decide(state: u64, now_ns: u64, interval_ns: u64) -> Option<u64> {
92    let admit = if state & ARMED == 0 {
93        true
94    } else {
95        let last_ns = state >> 1;
96        let elapsed_ns = now_ns.wrapping_sub(last_ns) & ELAPSED_MASK;
97        elapsed_ns >= interval_ns
98    };
99    if admit {
100        Some((now_ns << 1) | ARMED)
101    } else {
102        None
103    }
104}
105
106/// The timestamp a state word records, if the site has emitted. Reduced mod
107/// 2^63 — see [`throttle_decide`].
108#[must_use]
109const fn recorded_ns(state: u64) -> Option<u64> {
110    if state & ARMED == 0 {
111        None
112    } else {
113        Some(state >> 1)
114    }
115}
116
117/// Milliseconds to nanoseconds, saturating.
118///
119/// Public because the `nros_*_throttle!` macros expand it in user crates, and
120/// because the C shim converts the same way — one conversion, not two.
121#[must_use]
122pub const fn interval_ms_to_ns(interval_ms: u64) -> u64 {
123    interval_ms.saturating_mul(1_000_000)
124}
125
126/// Per-call-site throttle window.
127///
128/// Declare one `static` per site — the `nros_*_throttle!` macros do this for
129/// you.
130///
131/// ## Why not an `AtomicU64`
132///
133/// There isn't one to use. `portable_atomic::AtomicU64` does not exist on
134/// 32-bit bare-metal targets under this crate's dependency spelling
135/// (`default-features = false`, so no lock-based `fallback`), and the features
136/// that would conjure one are exactly the ones CLAUDE.md records as
137/// hard-conflicting with the `critical-section` the `zephyr` crate enables on
138/// thumbv7m. A plain non-atomic `u64` would TEAR on those targets — a half-old,
139/// half-new timestamp is a window of arbitrary length.
140///
141/// So: a one-bit guard around a `u64` — the same state word the C storage
142/// holds, decoded by the same [`throttle_decide`]. The critical section is a load, a
143/// comparison and a store, with no call inside it, and a thread that finds the
144/// guard taken does NOT spin — it treats the record as suppressed and returns.
145/// That is what makes this safe to reach from an ISR on a single core, where
146/// spinning on another context's progress would be a deadlock rather than a
147/// delay.
148///
149/// The cost is that two threads deciding the SAME call site at the same instant
150/// resolve to one record rather than two. `rclcpp`'s state is not synchronised
151/// at all, so this is the tighter of the two.
152pub struct ThrottleState {
153    /// `true` while a decision is in flight. See the type docs.
154    deciding: AtomicBool,
155    /// The state word [`throttle_decide`] reads and returns.
156    word: UnsafeCell<u64>,
157}
158
159// SAFETY: `word` is read and written only between the `Acquire`
160// `compare_exchange` that claims `deciding` and the `Release` store that frees
161// it, so exactly one thread touches it at a time and the release/acquire pair
162// publishes the write to the next claimant.
163unsafe impl Sync for ThrottleState {}
164
165impl ThrottleState {
166    /// A window that has never emitted.
167    #[must_use]
168    pub const fn new() -> Self {
169        Self {
170            deciding: AtomicBool::new(false),
171            word: UnsafeCell::new(NEVER),
172        }
173    }
174
175    /// Whether a record raised at `now_ns` should emit, updating the window if
176    /// it does.
177    pub fn should_log(&self, now_ns: u64, interval_ns: u64) -> bool {
178        if !self.claim() {
179            return false;
180        }
181        // SAFETY: `claim` succeeded, so this thread owns `word` until
182        // `release`.
183        let word = unsafe { *self.word.get() };
184        let next = throttle_decide(word, now_ns, interval_ns);
185        if let Some(next) = next {
186            // SAFETY: as above.
187            unsafe { *self.word.get() = next };
188        }
189        self.release();
190        next.is_some()
191    }
192
193    /// Timestamp of the last emitted record, or `None` if none.
194    ///
195    /// Reduced mod 2^63 (see [`throttle_decide`]), so a reading at or above
196    /// 2^63 ns comes back with its top bit cleared. Also `None` if a decision
197    /// is in flight — this is an observability accessor, and blocking on it
198    /// would give the guard the property the type docs say it must not have.
199    #[must_use]
200    pub fn last_emitted_ns(&self) -> Option<u64> {
201        if !self.claim() {
202            return None;
203        }
204        // SAFETY: `claim` succeeded.
205        let word = unsafe { *self.word.get() };
206        self.release();
207        recorded_ns(word)
208    }
209
210    /// Re-arm: the next record emits whatever the window says.
211    pub fn reset(&self) {
212        if !self.claim() {
213            return;
214        }
215        // SAFETY: `claim` succeeded.
216        unsafe { *self.word.get() = NEVER };
217        self.release();
218    }
219
220    /// Take the guard, or report that someone else has it. Never spins.
221    fn claim(&self) -> bool {
222        self.deciding
223            .compare_exchange(false, true, Ordering::Acquire, Ordering::Acquire)
224            .is_ok()
225    }
226
227    fn release(&self) {
228        self.deciding.store(false, Ordering::Release);
229    }
230}
231
232impl Default for ThrottleState {
233    fn default() -> Self {
234        Self::new()
235    }
236}
237
238#[cfg(test)]
239mod tests {
240    use super::*;
241
242    #[test]
243    fn first_record_always_admits() {
244        // The behaviour `nros_core`'s throttle got wrong: at `now < interval`
245        // its `now >= last + interval` test dropped the very first message.
246        assert!(throttle_decide(NEVER, 0, 1_000).is_some());
247        assert!(throttle_decide(NEVER, 5, 1_000_000_000).is_some());
248        let state = ThrottleState::new();
249        assert!(state.should_log(5, 1_000_000_000));
250    }
251
252    #[test]
253    fn window_suppresses_then_admits() {
254        let state = ThrottleState::new();
255        let interval = interval_ms_to_ns(100); // 100 ms
256        assert!(state.should_log(1_000_000_000, interval), "first emits");
257        assert!(
258            !state.should_log(1_050_000_000, interval),
259            "50 ms into a 100 ms window is suppressed"
260        );
261        assert!(
262            !state.should_log(1_099_999_999, interval),
263            "1 ns short of the window is suppressed"
264        );
265        assert!(
266            state.should_log(1_100_000_000, interval),
267            "exactly at the window admits"
268        );
269        assert!(
270            !state.should_log(1_150_000_000, interval),
271            "the window restarts from the record that emitted, not from the attempt"
272        );
273        assert_eq!(state.last_emitted_ns(), Some(1_100_000_000));
274    }
275
276    #[test]
277    fn zero_interval_admits_every_record() {
278        let state = ThrottleState::new();
279        for now in [10_u64, 10, 11, 11] {
280            assert!(state.should_log(now, 0));
281        }
282    }
283
284    #[test]
285    fn zero_timestamp_is_recorded_as_zero_and_distinct_from_never() {
286        // The pre-1152 code displaced a reading of `0` to `1` to keep a
287        // `0`-sentinel unambiguous; the armed FLAG makes the displacement
288        // unnecessary, so what was emitted is what is recorded.
289        let state = ThrottleState::new();
290        assert_eq!(state.last_emitted_ns(), None, "fresh site");
291        assert!(state.should_log(0, 1_000));
292        assert_eq!(state.last_emitted_ns(), Some(0), "recorded verbatim");
293        assert!(
294            !state.should_log(500, 1_000),
295            "a second record at t=500 must still be inside the window"
296        );
297        assert!(
298            state.should_log(1_000, 1_000),
299            "and t=1000 is exactly the window"
300        );
301    }
302
303    #[test]
304    fn normal_cadence_emits_at_the_interval_and_no_faster() {
305        // A 1 kHz caller against a 100 ms window: exactly one record per 100
306        // attempts, at the attempt where the window elapses.
307        let state = ThrottleState::new();
308        let interval = interval_ms_to_ns(100);
309        let mut emitted = 0;
310        for tick in 0..1_000_u64 {
311            let now = 7 + tick * interval_ms_to_ns(1);
312            if state.should_log(now, interval) {
313                emitted += 1;
314                assert_eq!(tick % 100, 0, "emits on the tick where 100 ms elapsed");
315            }
316        }
317        assert_eq!(emitted, 10);
318    }
319
320    #[test]
321    fn reset_rearms_the_window() {
322        let state = ThrottleState::new();
323        let interval = interval_ms_to_ns(1_000);
324        assert!(state.should_log(1_000_000_000, interval));
325        assert!(!state.should_log(1_000_000_001, interval));
326        state.reset();
327        assert!(state.should_log(1_000_000_002, interval));
328    }
329
330    #[test]
331    fn a_wrapped_clock_still_measures_real_elapsed_time() {
332        // `last_ns + interval_ns` would overflow here and (in release) wrap to
333        // a tiny number, admitting every record for the rest of the program.
334        // `now.wrapping_sub(last)` is the elapsed time and stays right.
335        let armed = throttle_decide(NEVER, u64::MAX - 5, 0).expect("first record");
336        assert!(
337            throttle_decide(armed, 10, interval_ms_to_ns(1)).is_none(),
338            "16 ns after the last record is inside a 1 ms window, wrap or no wrap"
339        );
340        assert!(
341            throttle_decide(armed, 2_000_000, interval_ms_to_ns(1)).is_some(),
342            "2 ms after the last record admits, across the wrap"
343        );
344        // And the same through the stateful form.
345        let state = ThrottleState::new();
346        assert!(state.should_log(u64::MAX - 5, interval_ms_to_ns(1)));
347        assert!(!state.should_log(10, interval_ms_to_ns(1)));
348        assert!(state.should_log(2_000_000, interval_ms_to_ns(1)));
349    }
350
351    #[test]
352    fn a_wrapping_clock_keeps_cadence_through_the_wrap() {
353        // Issue 1152 asks for both properties at once: a real wrap must not
354        // look like a stuck clock, and a stuck clock must not look like a
355        // wrap. Walk a 1 kHz clock through u64::MAX with a 100 ms window and
356        // count — the wrap must cost neither a lost nor an extra record.
357        let state = ThrottleState::new();
358        let interval = interval_ms_to_ns(100);
359        let start = u64::MAX - 350 * interval_ms_to_ns(1);
360        let mut emitted = 0;
361        for tick in 0..1_000_u64 {
362            let now = start.wrapping_add(tick * interval_ms_to_ns(1));
363            if state.should_log(now, interval) {
364                emitted += 1;
365                assert_eq!(tick % 100, 0, "tick {tick} (now={now})");
366            }
367        }
368        assert_eq!(emitted, 10);
369        // The 2^63 boundary is a wrap of the REDUCED clock; same property.
370        let state = ThrottleState::new();
371        let start = (1_u64 << 63) - 350 * interval_ms_to_ns(1);
372        let mut emitted = 0;
373        for tick in 0..1_000_u64 {
374            let now = start + tick * interval_ms_to_ns(1);
375            if state.should_log(now, interval) {
376                emitted += 1;
377                assert_eq!(tick % 100, 0, "tick {tick} (now={now})");
378            }
379        }
380        assert_eq!(emitted, 10);
381    }
382
383    #[test]
384    fn a_stuck_clock_at_the_word_boundaries_stays_shut() {
385        // Readings whose top bit is lost to the flag, or which are the
386        // sentinel itself: the flag, not the value, says whether the site is
387        // armed, so none of them re-arm it.
388        for stuck in [0_u64, 1, 1 << 63, u64::MAX >> 1, u64::MAX] {
389            let armed = throttle_decide(NEVER, stuck, 1).expect("first");
390            assert_ne!(armed, NEVER, "an armed site never reads as fresh");
391            assert!(
392                throttle_decide(armed, stuck, 1).is_none(),
393                "stuck at {stuck}"
394            );
395        }
396    }
397
398    /// Issue 1152 — the negative control. A port whose clock is broken reports
399    /// a constant; the throttle must then admit ONCE and never again, because
400    /// a stuck clock is when a log flood is most likely. Before the fix the
401    /// `0 -> 1` storage skew made `now.wrapping_sub(last)` read `u64::MAX` and
402    /// every record after the first was admitted.
403    #[test]
404    fn a_clock_stuck_at_zero_admits_once_then_never() {
405        let state = ThrottleState::new();
406        let interval = interval_ms_to_ns(100);
407        assert!(
408            state.should_log(0, interval),
409            "the first record always emits"
410        );
411        for _ in 0..1_000 {
412            assert!(
413                !state.should_log(0, interval),
414                "a clock that never moves is a window that never elapses"
415            );
416        }
417    }
418
419    #[test]
420    fn a_clock_stuck_at_a_nonzero_value_admits_once_then_never() {
421        for stuck in [1_u64, 42, 1_000_000_000, u64::MAX - 1, u64::MAX] {
422            let state = ThrottleState::new();
423            let interval = interval_ms_to_ns(100);
424            assert!(state.should_log(stuck, interval), "first record at {stuck}");
425            for _ in 0..1_000 {
426                assert!(!state.should_log(stuck, interval), "stuck at {stuck}");
427            }
428        }
429    }
430
431    #[test]
432    fn interval_conversion_saturates_rather_than_wrapping() {
433        assert_eq!(interval_ms_to_ns(1), 1_000_000);
434        assert_eq!(interval_ms_to_ns(u64::MAX), u64::MAX);
435    }
436}