forge_gateway/
rotation.rs1use std::collections::HashMap;
13use std::time::{Duration, SystemTime};
14
15use parking_lot::Mutex;
16
17use crate::account::AccountKey;
18
19pub const STREAK_COUNT: u32 = 5;
22
23pub const STREAK_WINDOW: Duration = Duration::from_secs(60);
25
26pub const NO_RESET_COOLDOWN: Duration = Duration::from_secs(60);
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub struct RotationNumbers {
35 pub streak_count: u32,
36 pub streak_window: Duration,
37 pub no_reset_cooldown: Duration,
38}
39
40impl Default for RotationNumbers {
41 fn default() -> Self {
42 Self {
43 streak_count: STREAK_COUNT,
44 streak_window: STREAK_WINDOW,
45 no_reset_cooldown: NO_RESET_COOLDOWN,
46 }
47 }
48}
49
50pub fn reset_instant(unix_seconds: u64) -> SystemTime {
52 SystemTime::UNIX_EPOCH + Duration::from_secs(unix_seconds)
53}
54
55pub struct RotationState {
57 streak_count: u32,
59 streak_window: Duration,
60 no_reset_cooldown: Duration,
61 cooldowns: Mutex<HashMap<AccountKey, SystemTime>>,
64 streaks: Mutex<HashMap<AccountKey, (u32, SystemTime)>>,
66}
67
68impl Default for RotationState {
69 fn default() -> Self {
70 Self::with_numbers(RotationNumbers::default())
71 }
72}
73
74impl RotationState {
75 pub fn new() -> Self {
76 Self::default()
77 }
78
79 pub fn with_numbers(numbers: RotationNumbers) -> Self {
83 Self {
84 streak_count: numbers.streak_count,
85 streak_window: numbers.streak_window,
86 no_reset_cooldown: numbers.no_reset_cooldown,
87 cooldowns: Mutex::new(HashMap::new()),
88 streaks: Mutex::new(HashMap::new()),
89 }
90 }
91
92 pub fn no_reset_cooldown(&self) -> Duration {
94 self.no_reset_cooldown
95 }
96
97 pub fn cool_down(&self, key: &AccountKey, until: SystemTime) {
99 self.cooldowns.lock().insert(key.clone(), until);
100 }
101
102 pub fn is_cooling_down(&self, key: &AccountKey, now: SystemTime) -> bool {
104 self.cooldowns.lock().get(key).is_some_and(|until| *until > now)
105 }
106
107 pub fn cooldown_end(&self, key: &AccountKey) -> Option<SystemTime> {
109 self.cooldowns.lock().get(key).copied()
110 }
111
112 pub fn soonest_cooldown_end(&self, now: SystemTime) -> Option<SystemTime> {
115 self.cooldowns.lock().values().copied().filter(|until| *until > now).min()
116 }
117
118 pub fn soonest_reset_among(&self, keys: &[AccountKey], now: SystemTime) -> Option<SystemTime> {
121 let cooldowns = self.cooldowns.lock();
122 keys.iter().filter_map(|key| cooldowns.get(key).copied()).filter(|until| *until > now).min()
123 }
124
125 pub fn record_429(&self, key: &AccountKey, now: SystemTime) -> bool {
134 let mut streaks = self.streaks.lock();
135 let entry = streaks.entry(key.clone()).or_insert((0, now));
136 if now.duration_since(entry.1).unwrap_or(Duration::ZERO) > self.streak_window {
137 *entry = (1, now);
138 } else {
139 entry.0 += 1;
140 entry.1 = now;
141 }
142 entry.0 >= self.streak_count
143 }
144
145 pub fn reset_streak(&self, key: &AccountKey) {
147 self.streaks.lock().remove(key);
148 }
149}
150
151#[cfg(test)]
152mod tests {
153 use super::*;
154
155 #[test]
156 fn the_streak_fires_on_the_fifth_429_inside_the_window() {
157 let state = RotationState::new();
158 let key = AccountKey("A".to_owned());
159 let mut now = SystemTime::now();
160 for _ in 0..4 {
161 assert!(!state.record_429(&key, now), "the streak must not fire early");
162 now += Duration::from_secs(10);
163 }
164 assert!(state.record_429(&key, now), "the fifth 429 inside the window fires");
165 }
166
167 #[test]
168 fn a_gap_outside_the_window_resets_the_streak() {
169 let state = RotationState::new();
170 let key = AccountKey("A".to_owned());
171 let mut now = SystemTime::now();
172 for _ in 0..4 {
173 state.record_429(&key, now);
174 now += Duration::from_secs(10);
175 }
176 now += Duration::from_secs(61);
177 assert!(!state.record_429(&key, now), "a 429 outside the window restarts the count");
178 }
179
180 #[test]
181 fn cooldowns_expire() {
182 let state = RotationState::new();
183 let key = AccountKey("A".to_owned());
184 let now = SystemTime::now();
185 state.cool_down(&key, now + Duration::from_secs(60));
186 assert!(state.is_cooling_down(&key, now));
187 assert!(!state.is_cooling_down(&key, now + Duration::from_secs(61)));
188 }
189
190 #[test]
191 fn the_soonest_reset_among_is_the_earliest_live_cooldown_asked_about() {
192 let state = RotationState::new();
193 let now = SystemTime::now();
194 state.cool_down(&AccountKey("A".to_owned()), now + Duration::from_secs(90));
195 state.cool_down(&AccountKey("B".to_owned()), now + Duration::from_secs(30));
196 state.cool_down(&AccountKey("C".to_owned()), now - Duration::from_secs(1));
197 let asked = [AccountKey("A".to_owned()), AccountKey("C".to_owned())];
198 assert_eq!(
199 state.soonest_reset_among(&asked, now),
200 Some(now + Duration::from_secs(90)),
201 "an account outside the ask and an expired cooldown are both ignored"
202 );
203 assert_eq!(state.soonest_reset_among(&[], now), None);
204 assert_eq!(
205 state
206 .soonest_reset_among(&[AccountKey("B".to_owned())], now + Duration::from_secs(120)),
207 None
208 );
209 }
210
211 #[test]
215 fn the_soonest_cooldown_end_ignores_expired_cooldowns() {
216 let state = RotationState::new();
217 let now = SystemTime::now();
218 state.cool_down(&AccountKey("Expired".to_owned()), now - Duration::from_secs(1));
219 assert_eq!(state.soonest_cooldown_end(now), None, "an expired cooldown is no wake");
220
221 state.cool_down(&AccountKey("Later".to_owned()), now + Duration::from_secs(90));
222 state.cool_down(&AccountKey("Sooner".to_owned()), now + Duration::from_secs(30));
223 assert_eq!(
224 state.soonest_cooldown_end(now),
225 Some(now + Duration::from_secs(30)),
226 "the soonest live cooldown is what a reader sleeps to",
227 );
228 }
229
230 #[test]
231 fn a_unix_reset_time_maps_from_the_epoch() {
232 let reset = reset_instant(1_000_000);
233 assert_eq!(
234 reset.duration_since(SystemTime::UNIX_EPOCH).unwrap(),
235 Duration::from_secs(1_000_000)
236 );
237 }
238
239 #[test]
240 fn configured_numbers_replace_the_defaults() {
241 let state = RotationState::with_numbers(RotationNumbers {
242 streak_count: 2,
243 streak_window: Duration::from_secs(30),
244 no_reset_cooldown: Duration::from_secs(120),
245 });
246 let key = AccountKey("A".to_owned());
247 let mut now = SystemTime::now();
248 assert!(!state.record_429(&key, now), "the configured count must not fire early");
249 now += Duration::from_secs(10);
250 assert!(
251 state.record_429(&key, now),
252 "the second 429 inside the configured window fires at count 2"
253 );
254 assert_eq!(
255 state.no_reset_cooldown(),
256 Duration::from_secs(120),
257 "the configured no-reset cooldown is what the failure paths apply"
258 );
259 let key = AccountKey("B".to_owned());
261 let mut now = SystemTime::now();
262 state.record_429(&key, now);
263 now += Duration::from_secs(31);
264 assert!(
265 !state.record_429(&key, now),
266 "a 429 beyond the configured 30s window restarts the count"
267 );
268 }
269}