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}