Skip to main content

nmbrs_rate/
spec.rs

1// Copyright 2024-2026 Jonathan Shook
2// SPDX-License-Identifier: Apache-2.0
3
4//! Rate limiter configuration.
5//!
6//! Parses a rate spec string like `"1000"`, `"1000,1.5"`, or
7//! `"1000,1.1,restart"` into structured parameters.
8
9/// Time unit for internal tick accounting.
10///
11/// Scaled based on target rate to keep ticks-per-op within u32 range,
12/// matching the nosqlbench time-scaling trick.
13#[derive(Debug, Clone, Copy, PartialEq)]
14pub enum TimeUnit {
15    Nanos,
16    Micros,
17    Millis,
18    Seconds,
19}
20
21impl TimeUnit {
22    /// Select the appropriate time unit for a given rate.
23    pub fn for_rate(ops_per_sec: f64) -> Self {
24        if ops_per_sec > 1.0 {
25            TimeUnit::Nanos
26        } else if ops_per_sec > 0.001 {
27            TimeUnit::Micros
28        } else if ops_per_sec > 0.000001 {
29            TimeUnit::Millis
30        } else {
31            TimeUnit::Seconds
32        }
33    }
34
35    /// Convert nanoseconds to ticks in this unit.
36    pub fn nanos_to_ticks(self, nanos: u64) -> u32 {
37        let ticks = match self {
38            TimeUnit::Nanos => nanos,
39            TimeUnit::Micros => nanos / 1_000,
40            TimeUnit::Millis => nanos / 1_000_000,
41            TimeUnit::Seconds => nanos / 1_000_000_000,
42        };
43        ticks.min(u32::MAX as u64) as u32
44    }
45
46    /// Convert ticks to nanoseconds.
47    pub fn ticks_to_nanos(self, ticks: u32) -> u64 {
48        match self {
49            TimeUnit::Nanos => ticks as u64,
50            TimeUnit::Micros => ticks as u64 * 1_000,
51            TimeUnit::Millis => ticks as u64 * 1_000_000,
52            TimeUnit::Seconds => ticks as u64 * 1_000_000_000,
53        }
54    }
55
56    /// Ticks per operation at the given rate.
57    pub fn ticks_per_op(self, ops_per_sec: f64) -> u32 {
58        let ticks = match self {
59            TimeUnit::Nanos => 1_000_000_000.0 / ops_per_sec,
60            TimeUnit::Micros => 1_000_000.0 / ops_per_sec,
61            TimeUnit::Millis => 1_000.0 / ops_per_sec,
62            TimeUnit::Seconds => 1.0 / ops_per_sec,
63        };
64        (ticks as u64).min(u32::MAX as u64) as u32
65    }
66}
67
68/// Lifecycle verb for rate limiter control.
69#[derive(Debug, Clone, Copy, PartialEq)]
70pub enum Verb {
71    /// Start the rate limiter.
72    Start,
73    /// Configure without affecting running state.
74    Configure,
75    /// Restart (zero all pools and backlog).
76    Restart,
77    /// Stop the refill task.
78    Stop,
79}
80
81/// Parsed rate limiter configuration.
82#[derive(Debug, Clone)]
83pub struct RateSpec {
84    pub ops_per_sec: f64,
85    pub burst_ratio: f64,
86    pub verb: Verb,
87    pub unit: TimeUnit,
88}
89
90impl RateSpec {
91    /// Create a rate spec with defaults.
92    pub fn new(ops_per_sec: f64) -> Self {
93        Self::with_burst(ops_per_sec, 1.1)
94    }
95
96    /// Create with explicit burst ratio.
97    pub fn with_burst(ops_per_sec: f64, burst_ratio: f64) -> Self {
98        Self {
99            ops_per_sec,
100            burst_ratio,
101            verb: Verb::Start,
102            unit: TimeUnit::for_rate(ops_per_sec),
103        }
104    }
105
106    /// Parse a rate spec string.
107    ///
108    /// Formats:
109    /// - `"1000"` — 1000 ops/s, 1.1x burst, start
110    /// - `"1000,1.5"` — 1000 ops/s, 1.5x burst
111    /// - `"1000,1.1,restart"` — with verb
112    pub fn parse(spec: &str) -> Result<Self, String> {
113        let parts: Vec<&str> = spec.split(',').map(|s| s.trim()).collect();
114        if parts.is_empty() {
115            return Err("empty rate spec".into());
116        }
117
118        let ops_per_sec: f64 = parts[0].parse().map_err(|e| format!("invalid rate: {e}"))?;
119        if ops_per_sec <= 0.0 {
120            return Err("rate must be positive".into());
121        }
122
123        let burst_ratio = if parts.len() > 1 {
124            parts[1].parse::<f64>().unwrap_or(1.1)
125        } else {
126            1.1
127        };
128
129        let verb = if parts.len() > 2 {
130            match parts[2].to_lowercase().as_str() {
131                "start" => Verb::Start,
132                "configure" => Verb::Configure,
133                "restart" => Verb::Restart,
134                "stop" => Verb::Stop,
135                other => return Err(format!("unknown verb: {other}")),
136            }
137        } else {
138            Verb::Start
139        };
140
141        Ok(Self {
142            ops_per_sec,
143            burst_ratio,
144            verb,
145            unit: TimeUnit::for_rate(ops_per_sec),
146        })
147    }
148
149    /// Ticks per operation in the configured time unit.
150    pub fn ticks_per_op(&self) -> u32 {
151        self.unit.ticks_per_op(self.ops_per_sec)
152    }
153}
154
155#[cfg(test)]
156mod tests {
157    use super::*;
158
159    #[test]
160    fn time_unit_scaling() {
161        assert_eq!(TimeUnit::for_rate(50.0), TimeUnit::Nanos);
162        assert_eq!(TimeUnit::for_rate(0.5), TimeUnit::Micros);
163        assert_eq!(TimeUnit::for_rate(0.0005), TimeUnit::Millis);
164        assert_eq!(TimeUnit::for_rate(0.0000001), TimeUnit::Seconds);
165    }
166
167    #[test]
168    fn ticks_per_op_fits() {
169        // 50 ops/s in nanos: 20_000_000 — fits in u32
170        let unit = TimeUnit::Nanos;
171        assert_eq!(unit.ticks_per_op(50.0), 20_000_000);
172
173        // 0.5 ops/s in micros: 2_000_000 — fits
174        let unit = TimeUnit::Micros;
175        assert_eq!(unit.ticks_per_op(0.5), 2_000_000);
176    }
177
178    #[test]
179    fn nanos_ticks_roundtrip() {
180        let unit = TimeUnit::Micros;
181        let nanos = 5_000_000u64; // 5ms
182        let ticks = unit.nanos_to_ticks(nanos);
183        assert_eq!(ticks, 5000);
184        assert_eq!(unit.ticks_to_nanos(ticks), nanos);
185    }
186
187    #[test]
188    fn parse_simple() {
189        let spec = RateSpec::parse("1000").unwrap();
190        assert_eq!(spec.ops_per_sec, 1000.0);
191        assert_eq!(spec.burst_ratio, 1.1);
192        assert_eq!(spec.verb, Verb::Start);
193    }
194
195    #[test]
196    fn parse_with_burst() {
197        let spec = RateSpec::parse("500, 1.5").unwrap();
198        assert_eq!(spec.ops_per_sec, 500.0);
199        assert_eq!(spec.burst_ratio, 1.5);
200    }
201
202    #[test]
203    fn parse_with_verb() {
204        let spec = RateSpec::parse("1000, 1.1, restart").unwrap();
205        assert_eq!(spec.verb, Verb::Restart);
206    }
207
208    #[test]
209    fn parse_errors() {
210        assert!(RateSpec::parse("").is_err());
211        assert!(RateSpec::parse("-1").is_err());
212        assert!(RateSpec::parse("abc").is_err());
213    }
214}