Skip to main content

nmbrs_runtime/wrappers/
rate.rs

1// Copyright 2024-2026 Jonathan Shook
2// SPDX-License-Identifier: Apache-2.0
3
4//! Per-op rate limiter. Independent of the activity-level
5//! rate limiter and of every other op's per-op limiter — each
6//! `OpRateWrapper` instance owns its own [`RateLimiter`] and
7//! acquires from it on every dispatch.
8//!
9//! Typical use: pacing a `while:` loop on a daemon op so the
10//! loop iterates at a controlled rate without coupling to the
11//! activity's ops-per-second ceiling or to sibling daemons'
12//! loops. See SRD-79 (forthcoming) for the design rationale.
13//!
14//! Composition: sits inner of `while:` so the rate limiter
15//! acquire happens once per loop iteration. Sits outer of
16//! `result`/`metrics`/`traverse` so the inner adapter call
17//! lands inside the rate budget.
18//!
19//! Spec format (parsed in [`parse_rate_spec`]):
20//! - `"<N>/s"` — N ops per second
21//! - `"<N>/m"` — N ops per minute (= N/60 per second)
22//! - `"<N>/h"` — N ops per hour (= N/3600 per second)
23//! - `"<N>"` — bare numeric, interpreted as ops per second
24//!
25//! The numeric portion may be integer or floating-point; any
26//! valid Rust `f64` literal works.
27
28use std::sync::Arc;
29
30use crate::adapter::WrappingDispenser;
31use crate::adapter::{ExecutionError, OpDispenser, OpResult};
32use crate::wrapper_registry::{WrapperName, WrapperRegistration, WrapperSubject};
33use nmbrs_rate::{RateLimiter, RateSpec};
34
35/// Wrapper name.
36pub const NAME: WrapperName = WrapperName::new("rate");
37
38/// Trigger: op declares a `rate:` field.
39fn triggers(s: WrapperSubject) -> bool {
40    let Some(template) = s.op() else {
41        return false;
42    };
43    template.rate.is_some()
44}
45
46fn describe_assignment(s: WrapperSubject) -> Option<String> {
47    let template = s.op()?;
48    template.rate.as_ref().map(|spec| format!("rate: {spec}"))
49}
50
51inventory::submit! {
52    WrapperRegistration {
53        name: NAME,
54        owned_fields: &["rate"],
55        triggers,
56        requires_inner: &[],
57        forbids_outer: &[],
58        mutually_exclusive_with: &[],
59        describe_assignment,
60        levels: &[crate::wrapper_registry::WrapperLevel::Op],
61    }
62}
63
64/// Parse a `rate:` spec string into an ops-per-second f64.
65/// See module docs for the accepted formats.
66///
67/// Errors with a descriptive message naming the malformed input
68/// — the workload author needs enough context to fix the typo.
69pub fn parse_rate_spec(s: &str) -> Result<f64, String> {
70    let s = s.trim();
71    if s.is_empty() {
72        return Err("rate: spec is empty".into());
73    }
74    let (num_str, divisor): (&str, f64) = if let Some(rest) = s.strip_suffix("/s") {
75        (rest.trim(), 1.0)
76    } else if let Some(rest) = s.strip_suffix("/m") {
77        (rest.trim(), 60.0)
78    } else if let Some(rest) = s.strip_suffix("/h") {
79        (rest.trim(), 3600.0)
80    } else {
81        (s, 1.0)
82    };
83    if num_str.is_empty() {
84        return Err(format!("rate: spec `{s}` has empty numeric part"));
85    }
86    let n: f64 = num_str.parse().map_err(|e| {
87        format!("rate: spec `{s}` numeric part `{num_str}` is not a valid number: {e}")
88    })?;
89    if !n.is_finite() {
90        return Err(format!("rate: spec `{s}` numeric part is not finite"));
91    }
92    if n <= 0.0 {
93        return Err(format!("rate: spec `{s}` must be > 0 (got {n})"));
94    }
95    Ok(n / divisor)
96}
97
98/// Wraps an inner OpDispenser with an independent per-op
99/// rate limiter.
100pub struct OpRateWrapper {
101    inner: Arc<dyn OpDispenser>,
102    limiter: Arc<RateLimiter>,
103}
104
105impl OpRateWrapper {
106    /// Wrap an inner dispenser. `rate_spec` is the workload-
107    /// declared spec string (see [`parse_rate_spec`]).
108    pub fn wrap(
109        inner: Arc<dyn OpDispenser>,
110        rate_spec: &str,
111    ) -> Result<Arc<dyn OpDispenser>, String> {
112        let ops_per_sec = parse_rate_spec(rate_spec)?;
113        let limiter = Arc::new(RateLimiter::start(RateSpec::new(ops_per_sec)));
114        Ok(Arc::new(Self { inner, limiter }))
115    }
116}
117
118impl WrappingDispenser for OpRateWrapper {}
119
120impl OpDispenser for OpRateWrapper {
121    fn execute<'a>(
122        &'a self,
123        cycle: u64,
124        ctx: &'a crate::fixture::ExecCtx<'a>,
125    ) -> std::pin::Pin<
126        Box<dyn std::future::Future<Output = Result<OpResult, ExecutionError>> + Send + 'a>,
127    > {
128        Box::pin(async move {
129            let _wait = self.limiter.acquire().await;
130            self.inner.execute(cycle, ctx).await
131        })
132    }
133    fn inner_dispenser(&self) -> Option<&dyn OpDispenser> {
134        Some(self.inner.as_ref())
135    }
136}
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141
142    // -------- spec parsing --------
143
144    #[test]
145    fn parse_bare_integer_is_per_second() {
146        assert_eq!(parse_rate_spec("100").unwrap(), 100.0);
147    }
148
149    #[test]
150    fn parse_bare_float_is_per_second() {
151        assert!((parse_rate_spec("12.5").unwrap() - 12.5).abs() < 1e-9);
152    }
153
154    #[test]
155    fn parse_per_second_suffix() {
156        assert_eq!(parse_rate_spec("100/s").unwrap(), 100.0);
157        assert_eq!(parse_rate_spec("100 /s").unwrap(), 100.0);
158        assert!((parse_rate_spec(" 0.5/s ").unwrap() - 0.5).abs() < 1e-9);
159    }
160
161    #[test]
162    fn parse_per_minute_suffix() {
163        assert!((parse_rate_spec("60/m").unwrap() - 1.0).abs() < 1e-9);
164        assert!((parse_rate_spec("1/m").unwrap() - (1.0 / 60.0)).abs() < 1e-9);
165    }
166
167    #[test]
168    fn parse_per_hour_suffix() {
169        assert!((parse_rate_spec("3600/h").unwrap() - 1.0).abs() < 1e-9);
170    }
171
172    #[test]
173    fn parse_rejects_empty() {
174        assert!(parse_rate_spec("").is_err());
175        assert!(parse_rate_spec("   ").is_err());
176        assert!(parse_rate_spec("/s").is_err());
177    }
178
179    #[test]
180    fn parse_rejects_zero_or_negative() {
181        let e = parse_rate_spec("0").unwrap_err();
182        assert!(e.contains("must be > 0"));
183        let e = parse_rate_spec("-1/s").unwrap_err();
184        assert!(e.contains("must be > 0"));
185    }
186
187    #[test]
188    fn parse_rejects_non_numeric() {
189        assert!(parse_rate_spec("abc").is_err());
190        assert!(parse_rate_spec("twelve/s").is_err());
191    }
192
193    #[test]
194    fn parse_rejects_non_finite() {
195        let e = parse_rate_spec("inf").unwrap_err();
196        assert!(e.contains("not finite"));
197        // NaN parses but is rejected.
198        let e = parse_rate_spec("NaN").unwrap_err();
199        assert!(e.contains("not finite"));
200    }
201
202    // -------- F5: pacing accuracy --------
203    //
204    // Sustained acquire from a 1000/s limiter over 200 ops
205    // should take ~200ms. Tolerate ±50% to absorb scheduler
206    // jitter in CI; the load-bearing assertion is "the limiter
207    // does pace, not no-op."
208
209    #[tokio::test(flavor = "multi_thread")]
210    async fn rate_limiter_paces_iterations() {
211        // Use a slower limiter to make the lower-bound assertion
212        // robust under CI load: 100/s × 50 ops ≈ 500ms ideal.
213        // The first acquire is free (the limiter primes with one
214        // op), so 50 acquires consume 49 ticks ≈ 490ms.
215        let limiter = Arc::new(RateLimiter::start(RateSpec::new(100.0)));
216        let start = std::time::Instant::now();
217        for _ in 0..50 {
218            let _ = limiter.acquire().await;
219        }
220        let elapsed = start.elapsed();
221        assert!(
222            elapsed >= std::time::Duration::from_millis(200),
223            "limiter did not pace at all (elapsed={elapsed:?})"
224        );
225        assert!(
226            elapsed <= std::time::Duration::from_millis(2000),
227            "limiter paced far slower than configured (elapsed={elapsed:?})"
228        );
229    }
230
231    // -------- F2: parse robustness proptest --------
232
233    mod proptests {
234        use super::*;
235        use proptest::prelude::*;
236
237        proptest! {
238            #![proptest_config(ProptestConfig::with_cases(200))]
239
240            // Any positive finite f64 with a /s suffix round-
241            // trips to its own value within FP precision.
242            #[test]
243            fn per_second_round_trip(n in 0.0001f64..=1_000_000.0) {
244                let s = format!("{n}/s");
245                let parsed = parse_rate_spec(&s).unwrap();
246                prop_assert!((parsed - n).abs() / n < 1e-12,
247                    "round-trip drift > 1e-12 for {n}: parsed {parsed}");
248            }
249
250            // /m halves -> sixtieths the per-second rate.
251            #[test]
252            fn per_minute_scales(n in 0.0001f64..=1_000_000.0) {
253                let s = format!("{n}/m");
254                let parsed = parse_rate_spec(&s).unwrap();
255                let want = n / 60.0;
256                prop_assert!((parsed - want).abs() / want < 1e-12);
257            }
258
259            // Random garbage doesn't panic; parse_rate_spec is
260            // a total function on &str.
261            #[test]
262            fn never_panics(s in ".*") {
263                let _ = parse_rate_spec(&s);
264            }
265        }
266    }
267}