nmbrs_runtime/wrappers/
rate.rs1use 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
35pub const NAME: WrapperName = WrapperName::new("rate");
37
38fn 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
64pub 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
98pub struct OpRateWrapper {
101 inner: Arc<dyn OpDispenser>,
102 limiter: Arc<RateLimiter>,
103}
104
105impl OpRateWrapper {
106 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 #[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 let e = parse_rate_spec("NaN").unwrap_err();
199 assert!(e.contains("not finite"));
200 }
201
202 #[tokio::test(flavor = "multi_thread")]
210 async fn rate_limiter_paces_iterations() {
211 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 mod proptests {
234 use super::*;
235 use proptest::prelude::*;
236
237 proptest! {
238 #![proptest_config(ProptestConfig::with_cases(200))]
239
240 #[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 #[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 #[test]
262 fn never_panics(s in ".*") {
263 let _ = parse_rate_spec(&s);
264 }
265 }
266 }
267}