Skip to main content

aoc_runtime/aoc/
throttle.rs

1//! Keeping outbound requests to a polite rate.
2//!
3//! The Advent of Code [automation guidelines] ask that automated tools do not
4//! hammer the site. This tool never polls and never runs on a schedule, so
5//! every request it makes is one a person asked for; what needs controlling is
6//! bursts. A single `aoc run --submit` would otherwise fetch an input and post
7//! two answers back to back, and nothing would stop a shell loop from repeating
8//! that as fast as the network allows.
9//!
10//! [`Throttle`] enforces a minimum gap between requests. The moment of the last
11//! request is remembered in the state directory as well as in memory, so the
12//! gap survives across separate invocations of the binary instead of only
13//! holding within one.
14//!
15//! [automation guidelines]: https://www.reddit.com/r/adventofcode/wiki/faqs/automation
16
17use std::{
18    cell::Cell,
19    fs,
20    path::PathBuf,
21    thread,
22    time::{Duration, SystemTime, UNIX_EPOCH},
23};
24
25/// The smallest gap allowed between two outbound requests.
26///
27/// Long enough to break up bursts and to make an accidental loop self
28/// limiting, short enough that submitting both parts of a puzzle in one run
29/// stays comfortable. A person cannot solve a puzzle faster than this, so in
30/// ordinary use the throttle never actually waits.
31pub const MIN_INTERVAL: Duration = Duration::from_secs(5);
32
33/// The file, inside the state directory, holding the last request's timestamp.
34const LAST_REQUEST_FILE: &str = "last-request";
35
36/// Wall-clock time, and the ability to wait for some of it.
37///
38/// Deliberately not [`crate::env::Clock`]: that answers "what day is it" when
39/// resolving which puzzle to work on, whereas throttling needs instants and
40/// sleeps.
41pub trait Timer {
42    /// The current wall-clock time.
43    fn now(&self) -> SystemTime;
44
45    /// Blocks the calling thread for the given duration.
46    fn wait(&self, duration: Duration);
47}
48
49/// A timer backed by the system clock.
50#[derive(Debug, Default, Clone, Copy)]
51pub struct SystemTimer;
52
53impl Timer for SystemTimer {
54    fn now(&self) -> SystemTime {
55        SystemTime::now()
56    }
57
58    fn wait(&self, duration: Duration) {
59        thread::sleep(duration);
60    }
61}
62
63/// Enforces a minimum gap between outbound requests.
64#[derive(Debug)]
65pub struct Throttle<T = SystemTimer> {
66    path: PathBuf,
67    timer: T,
68    interval: Duration,
69    last: Cell<Option<SystemTime>>,
70}
71
72impl Throttle {
73    /// Creates a throttle keeping [`MIN_INTERVAL`] between requests, recording
74    /// its state in the given state directory.
75    #[must_use]
76    pub fn new(state_dir: impl Into<PathBuf>) -> Self {
77        Self::with_timer(state_dir, SystemTimer, MIN_INTERVAL)
78    }
79}
80
81impl<T: Timer> Throttle<T> {
82    /// Creates a throttle with an explicit timer and interval.
83    #[must_use]
84    pub fn with_timer(state_dir: impl Into<PathBuf>, timer: T, interval: Duration) -> Self {
85        Self {
86            path: state_dir.into().join(LAST_REQUEST_FILE),
87            timer,
88            interval,
89            last: Cell::new(None),
90        }
91    }
92
93    /// Blocks until another request may be sent, then records that one was.
94    pub fn acquire(&self) {
95        let now = self.timer.now();
96        let wait = self.remaining(now);
97
98        if !wait.is_zero() {
99            self.timer.wait(wait);
100        }
101
102        self.record(now.checked_add(wait).unwrap_or(now));
103    }
104
105    /// How much longer a caller must wait before sending, as of `now`.
106    fn remaining(&self, now: SystemTime) -> Duration {
107        self.last_request()
108            .and_then(|last| last.checked_add(self.interval))
109            .and_then(|earliest| earliest.duration_since(now).ok())
110            // A timestamp in the future - a corrupt file, or a clock that
111            // moved - must not wedge the tool for longer than one interval.
112            .map_or(Duration::ZERO, |wait| wait.min(self.interval))
113    }
114
115    /// When the most recent request was sent, by this process or an earlier
116    /// one. An unreadable or malformed record simply means "no idea".
117    fn last_request(&self) -> Option<SystemTime> {
118        let persisted = fs::read_to_string(&self.path)
119            .ok()
120            .and_then(|text| text.trim().parse::<u64>().ok())
121            .and_then(|millis| UNIX_EPOCH.checked_add(Duration::from_millis(millis)));
122
123        match (self.last.get(), persisted) {
124            (Some(memory), Some(disk)) => Some(memory.max(disk)),
125            (memory, disk) => memory.or(disk),
126        }
127    }
128
129    /// Remembers when a request was sent.
130    ///
131    /// Persisting is best effort, matching the answer cache: a state directory
132    /// that cannot be written costs the gap between separate invocations, but
133    /// requests within this process are still spaced out.
134    fn record(&self, at: SystemTime) {
135        self.last.set(Some(at));
136
137        let Ok(since_epoch) = at.duration_since(UNIX_EPOCH) else {
138            return;
139        };
140        let Ok(millis) = u64::try_from(since_epoch.as_millis()) else {
141            return;
142        };
143        let Some(parent) = self.path.parent() else {
144            return;
145        };
146
147        if fs::create_dir_all(parent).is_ok() {
148            let _ = fs::write(&self.path, millis.to_string());
149        }
150    }
151}
152
153#[cfg(test)]
154pub(crate) mod fake {
155    use super::{Duration, SystemTime, Timer};
156    use std::cell::{Cell, RefCell};
157
158    /// A timer that never really sleeps: waiting just moves it forward.
159    #[derive(Debug)]
160    pub(crate) struct FakeTimer {
161        now: Cell<SystemTime>,
162        waits: RefCell<Vec<Duration>>,
163    }
164
165    impl FakeTimer {
166        pub(crate) fn new(now: SystemTime) -> Self {
167            Self {
168                now: Cell::new(now),
169                waits: RefCell::new(Vec::new()),
170            }
171        }
172
173        pub(crate) fn advance(&self, by: Duration) {
174            self.now.set(self.now.get() + by);
175        }
176
177        /// Every duration the throttle asked to wait for, in order.
178        pub(crate) fn waits(&self) -> Vec<Duration> {
179            self.waits.borrow().clone()
180        }
181    }
182
183    impl Timer for FakeTimer {
184        fn now(&self) -> SystemTime {
185            self.now.get()
186        }
187
188        fn wait(&self, duration: Duration) {
189            self.waits.borrow_mut().push(duration);
190            self.advance(duration);
191        }
192    }
193}
194
195#[cfg(test)]
196mod tests {
197    use super::{fake::FakeTimer, *};
198
199    const INTERVAL: Duration = Duration::from_secs(5);
200
201    fn at(seconds: u64) -> SystemTime {
202        UNIX_EPOCH + Duration::from_secs(seconds)
203    }
204
205    fn throttle(state_dir: impl Into<PathBuf>, now: u64) -> Throttle<FakeTimer> {
206        Throttle::with_timer(state_dir, FakeTimer::new(at(now)), INTERVAL)
207    }
208
209    #[test]
210    fn the_first_request_is_not_delayed() {
211        let dir = tempfile::tempdir().expect("temp dir");
212        let throttle = throttle(dir.path(), 1000);
213
214        throttle.acquire();
215
216        assert!(throttle.timer.waits().is_empty());
217    }
218
219    #[test]
220    fn a_burst_is_spaced_out() {
221        let dir = tempfile::tempdir().expect("temp dir");
222        let throttle = throttle(dir.path(), 1000);
223
224        throttle.acquire();
225        throttle.acquire();
226        throttle.acquire();
227
228        assert_eq!(throttle.timer.waits(), [INTERVAL, INTERVAL]);
229    }
230
231    #[test]
232    fn waiting_out_the_interval_costs_nothing() {
233        let dir = tempfile::tempdir().expect("temp dir");
234        let throttle = throttle(dir.path(), 1000);
235
236        throttle.acquire();
237        throttle.timer.advance(INTERVAL);
238        throttle.acquire();
239
240        assert!(throttle.timer.waits().is_empty());
241    }
242
243    #[test]
244    fn the_gap_is_honoured_across_invocations() {
245        let dir = tempfile::tempdir().expect("temp dir");
246        throttle(dir.path(), 1000).acquire();
247
248        // A second process, two seconds later, reading the same state
249        // directory: three of the five seconds are still outstanding.
250        let next = throttle(dir.path(), 1002);
251        next.acquire();
252
253        assert_eq!(next.timer.waits(), [Duration::from_secs(3)]);
254    }
255
256    #[test]
257    fn an_unwritable_state_directory_still_throttles_in_process() {
258        let throttle = throttle("/proc/definitely-not-writable", 1000);
259
260        throttle.acquire();
261        throttle.acquire();
262
263        assert_eq!(throttle.timer.waits(), [INTERVAL]);
264    }
265
266    #[test]
267    fn a_timestamp_from_the_future_waits_at_most_one_interval() {
268        let dir = tempfile::tempdir().expect("temp dir");
269        fs::write(dir.path().join(LAST_REQUEST_FILE), "99000000000000").expect("seed timestamp");
270
271        let throttle = throttle(dir.path(), 1000);
272        throttle.acquire();
273
274        assert_eq!(throttle.timer.waits(), [INTERVAL]);
275    }
276
277    #[test]
278    fn an_unreadable_record_does_not_block_the_first_request() {
279        let dir = tempfile::tempdir().expect("temp dir");
280        fs::write(dir.path().join(LAST_REQUEST_FILE), "not a timestamp").expect("seed timestamp");
281
282        let throttle = throttle(dir.path(), 1000);
283        throttle.acquire();
284
285        assert!(throttle.timer.waits().is_empty());
286    }
287}