Skip to main content

layover_core/
slots.rs

1//! Slots: how many runs may be in the air at once.
2//!
3//! The existing rails each bound a different thing, and none of them bounds this one.
4//!
5//! | Rail | Bounds |
6//! |---|---|
7//! | Hops | How **deep** one chain runs |
8//! | Fuel | What one chain **spends** |
9//! | Run cap | How many runs one chain starts **in total** |
10//! | Reserve | What the **factory** spends over a window |
11//! | **Slots** | How many runs are live **at this instant** |
12//!
13//! The gap was found by writing a real pipeline: a scanner that dispatches one reviewer per pull
14//! request assigned to you. The configuration is byte-identical whether it finds one PR or fifty,
15//! and fifty means fifty headless agent CLIs starting simultaneously on the machine you are also
16//! using. Every existing rail permits it. Hops counts depth and this is width; the run cap is
17//! cumulative, so fifty at once and fifty over an hour are the same number to it; Fuel eventually
18//! halts the chain, but only after the damage, and it halts *arbitrarily* — whichever runs
19//! happened to finish first are the ones that got done.
20//!
21//! # Why queue rather than refuse
22//!
23//! Every other rail refuses, because every other rail is protecting a budget: once the money is
24//! gone, doing the work later does not make it affordable. This one protects a machine, and a
25//! machine that is busy now will not be busy in a minute. Refusing would turn "review twelve pull
26//! requests" into "review four and silently drop eight", which is the worst possible reading of a
27//! concurrency limit.
28
29use std::collections::VecDeque;
30
31use crate::flight::RunId;
32
33/// What happened to a request for a slot.
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub enum Admission {
36    /// Start now.
37    Cleared,
38    /// Wait. The run is queued, and the number is how many are ahead of it.
39    Queued {
40        /// How many runs are waiting in front of this one.
41        ahead: usize,
42    },
43}
44
45impl Admission {
46    /// Returns `true` when the run may start immediately.
47    #[must_use]
48    pub fn is_cleared(self) -> bool {
49        matches!(self, Self::Cleared)
50    }
51}
52
53/// A bound on how many runs may be live at once, with a queue for the rest.
54#[derive(Debug, Clone)]
55pub struct Slots {
56    capacity: usize,
57    live: Vec<RunId>,
58    waiting: VecDeque<RunId>,
59}
60
61impl Slots {
62    /// Creates a flight line with `capacity` slots.
63    ///
64    /// A capacity of zero is treated as one. A factory that cannot start anything is not a safer
65    /// factory, it is a broken one, and the configuration is checked separately.
66    #[must_use]
67    pub fn new(capacity: usize) -> Self {
68        Self {
69            capacity: capacity.max(1),
70            live: Vec::new(),
71            waiting: VecDeque::new(),
72        }
73    }
74
75    /// How many runs may be live at once.
76    #[must_use]
77    pub fn capacity(&self) -> usize {
78        self.capacity
79    }
80
81    /// How many are live now.
82    #[must_use]
83    pub fn live(&self) -> usize {
84        self.live.len()
85    }
86
87    /// How many are waiting.
88    #[must_use]
89    pub fn waiting(&self) -> usize {
90        self.waiting.len()
91    }
92
93    /// Returns `true` when every slot is taken.
94    #[must_use]
95    pub fn is_full(&self) -> bool {
96        self.live.len() >= self.capacity
97    }
98
99    /// Returns `true` when this run is currently occupying a slot.
100    #[must_use]
101    pub fn is_live(&self, run: &RunId) -> bool {
102        self.live.contains(run)
103    }
104
105    /// Asks for a slot.
106    ///
107    /// Asking twice for the same run is not an error and does not take a second slot: the Tower
108    /// may retry, and a rail that leaked a slot per retry would throttle itself to a standstill
109    /// without anything looking wrong.
110    pub fn request(&mut self, run: RunId) -> Admission {
111        if self.live.contains(&run) {
112            return Admission::Cleared;
113        }
114        if let Some(position) = self.waiting.iter().position(|queued| *queued == run) {
115            return Admission::Queued { ahead: position };
116        }
117
118        if self.is_full() {
119            self.waiting.push_back(run);
120            return Admission::Queued {
121                ahead: self.waiting.len() - 1,
122            };
123        }
124
125        self.live.push(run);
126        Admission::Cleared
127    }
128
129    /// Gives a slot back, returning whichever queued run may now start.
130    ///
131    /// Releasing a run that was only queued withdraws it instead — a run cancelled while waiting
132    /// must not take a slot it no longer needs.
133    pub fn release(&mut self, run: &RunId) -> Option<RunId> {
134        if let Some(index) = self.waiting.iter().position(|queued| queued == run) {
135            self.waiting.remove(index);
136            return None;
137        }
138
139        let index = self.live.iter().position(|held| held == run)?;
140        self.live.remove(index);
141
142        let next = self.waiting.pop_front()?;
143        self.live.push(next.clone());
144        Some(next)
145    }
146
147    /// Everything waiting, in the order it will start.
148    pub fn queue(&self) -> impl Iterator<Item = &RunId> {
149        self.waiting.iter()
150    }
151}
152
153#[cfg(test)]
154mod tests {
155    use super::*;
156
157    fn run() -> RunId {
158        RunId::generate()
159    }
160
161    #[test]
162    fn runs_start_immediately_while_there_is_room() {
163        let mut slots = Slots::new(3);
164
165        for _ in 0..3 {
166            assert_eq!(slots.request(run()), Admission::Cleared);
167        }
168        assert_eq!(slots.live(), 3);
169        assert!(slots.is_full());
170    }
171
172    #[test]
173    fn the_rest_wait_rather_than_being_turned_away() {
174        // The distinction that separates this rail from every other one. Fuel refuses because
175        // money spent is gone; a machine that is busy now will not be busy in a minute, so
176        // refusing would turn "review twelve pull requests" into "review two and drop ten".
177        let mut slots = Slots::new(2);
178        slots.request(run());
179        slots.request(run());
180
181        assert_eq!(slots.request(run()), Admission::Queued { ahead: 0 });
182        assert_eq!(slots.request(run()), Admission::Queued { ahead: 1 });
183        assert_eq!(slots.waiting(), 2);
184    }
185
186    #[test]
187    fn finishing_a_run_lets_the_next_one_in() {
188        let mut slots = Slots::new(1);
189        let first = run();
190        let second = run();
191        slots.request(first.clone());
192        slots.request(second.clone());
193
194        let started = slots.release(&first);
195
196        assert_eq!(started, Some(second.clone()));
197        assert!(slots.is_live(&second));
198        assert_eq!(slots.waiting(), 0);
199    }
200
201    #[test]
202    fn the_queue_is_served_in_order() {
203        // Arbitrary order is what makes Fuel exhaustion unsatisfying: which work got done is
204        // whatever happened to finish first. A queue should not repeat that.
205        let mut slots = Slots::new(1);
206        let held = run();
207        slots.request(held.clone());
208
209        let queued: Vec<RunId> = (0..3).map(|_| run()).collect();
210        for run in &queued {
211            slots.request(run.clone());
212        }
213
214        assert_eq!(slots.queue().cloned().collect::<Vec<_>>(), queued);
215        assert_eq!(slots.release(&held), Some(queued[0].clone()));
216    }
217
218    #[test]
219    fn asking_twice_does_not_take_two_slots() {
220        // The Tower may retry. A rail that leaked a slot per retry would throttle itself to a
221        // standstill with nothing looking wrong.
222        let mut slots = Slots::new(2);
223        let run = run();
224
225        assert_eq!(slots.request(run.clone()), Admission::Cleared);
226        assert_eq!(slots.request(run.clone()), Admission::Cleared);
227        assert_eq!(slots.live(), 1);
228    }
229
230    #[test]
231    fn asking_twice_while_queued_reports_the_same_place_in_line() {
232        let mut slots = Slots::new(1);
233        slots.request(run());
234        let waiting = run();
235
236        assert_eq!(
237            slots.request(waiting.clone()),
238            Admission::Queued { ahead: 0 }
239        );
240        assert_eq!(slots.request(waiting), Admission::Queued { ahead: 0 });
241        assert_eq!(slots.waiting(), 1);
242    }
243
244    #[test]
245    fn a_run_cancelled_while_waiting_leaves_the_queue_without_taking_a_slot() {
246        let mut slots = Slots::new(1);
247        let held = run();
248        let abandoned = run();
249        let next = run();
250        slots.request(held.clone());
251        slots.request(abandoned.clone());
252        slots.request(next.clone());
253
254        assert_eq!(slots.release(&abandoned), None, "it never held a slot");
255        assert_eq!(slots.waiting(), 1);
256        assert_eq!(slots.release(&held), Some(next));
257    }
258
259    #[test]
260    fn releasing_something_unknown_changes_nothing() {
261        let mut slots = Slots::new(2);
262        slots.request(run());
263
264        assert_eq!(slots.release(&run()), None);
265        assert_eq!(slots.live(), 1);
266    }
267
268    #[test]
269    fn a_capacity_of_zero_still_runs_one_at_a_time() {
270        // A factory that cannot start anything is not a safer factory, it is a broken one.
271        let mut slots = Slots::new(0);
272
273        assert_eq!(slots.capacity(), 1);
274        assert!(slots.request(run()).is_cleared());
275    }
276
277    #[test]
278    fn fifty_arrivals_at_a_capacity_of_four_leave_four_running_and_none_lost() {
279        // The scenario that exposed the gap: a scanner dispatching one reviewer per assigned
280        // pull request, where the count is not known until it looks.
281        let mut slots = Slots::new(4);
282        let arrivals: Vec<RunId> = (0..50).map(|_| run()).collect();
283
284        for run in &arrivals {
285            slots.request(run.clone());
286        }
287
288        assert_eq!(slots.live(), 4);
289        assert_eq!(slots.waiting(), 46, "the rest are delayed, not dropped");
290
291        // And the whole queue drains rather than stalling: releasing one live run admits exactly
292        // one waiting run, all the way down.
293        let mut admitted = 4;
294        while slots.waiting() > 0 {
295            let holder = arrivals
296                .iter()
297                .find(|run| slots.is_live(run))
298                .cloned()
299                .expect("something is live while anything waits");
300
301            assert!(slots.release(&holder).is_some(), "a release must admit one");
302            admitted += 1;
303            assert!(admitted <= arrivals.len(), "the queue stopped draining");
304        }
305
306        assert_eq!(admitted, arrivals.len(), "every arrival eventually started");
307    }
308}