Skip to main content

faucet_cli/serve/
registry.rs

1//! In-flight run registry. Tracks per-run cancellation tokens and the queue /
2//! in-flight counters that drive backpressure (429), the `faucet_serve_runs_*`
3//! gauges, `/readyz`, and the shutdown drain. A "queued" run is one that has been
4//! spawned but has not yet acquired an execution permit.
5
6use dashmap::DashMap;
7use std::sync::atomic::{AtomicUsize, Ordering};
8use tokio::sync::Notify;
9use tokio_util::sync::CancellationToken;
10
11/// The unique registry key for a shard's cancel token. Keeping it distinct from
12/// the parent run's key (which is the bare run id) lets `cancel_run_shards` fire
13/// all of a run's shard tokens via a `{run_id}::` prefix scan without colliding
14/// with the run's own token.
15fn shard_key(run_id: &str, shard_id: &str) -> String {
16    format!("{run_id}::{shard_id}")
17}
18
19pub struct Registry {
20    tokens: DashMap<String, CancellationToken>,
21    queued: AtomicUsize,
22    in_flight: AtomicUsize,
23    max_queued: usize,
24    drained: Notify,
25}
26
27impl Registry {
28    pub fn new(max_queued: usize) -> Self {
29        Self {
30            tokens: DashMap::new(),
31            queued: AtomicUsize::new(0),
32            in_flight: AtomicUsize::new(0),
33            max_queued: max_queued.max(1),
34            drained: Notify::new(),
35        }
36    }
37
38    /// Reserve a queue slot. Returns `true` if a slot was reserved, or `false`
39    /// if the queue is full. Atomic against concurrent submits via a CAS loop
40    /// (`AtomicUsize::try_update`, stabilized in Rust 1.95).
41    pub fn try_reserve(&self) -> bool {
42        self.queued
43            .try_update(Ordering::AcqRel, Ordering::Acquire, |cur| {
44                (cur < self.max_queued).then_some(cur + 1)
45            })
46            .is_ok()
47    }
48
49    /// Release a slot reserved by `try_reserve` that will not be spawned
50    /// (idempotency replay/conflict).
51    pub fn release_reservation(&self) {
52        self.dec_queued();
53    }
54
55    pub fn register(&self, run_id: String, token: CancellationToken) {
56        self.tokens.insert(run_id, token);
57    }
58
59    /// Queued → running: the run acquired its execution permit.
60    pub fn mark_running(&self) {
61        self.dec_queued();
62        self.in_flight.fetch_add(1, Ordering::AcqRel);
63    }
64
65    /// Running transition for a run that never occupied a local queue slot (the
66    /// cluster claim path: `submit` writes Pending + releases its reservation, so
67    /// no queued slot exists to consume). Only bumps `in_flight`.
68    pub fn mark_running_unqueued(&self) {
69        self.in_flight.fetch_add(1, Ordering::AcqRel);
70    }
71
72    /// Running → terminal: drop the token and wake any shutdown drain waiter.
73    pub fn mark_finished(&self, run_id: &str) {
74        self.dec_in_flight();
75        self.tokens.remove(run_id);
76        self.drained.notify_waiters();
77    }
78
79    /// Queued → terminal: a run was cancelled (or the server shut down) while
80    /// still waiting for an execution permit, so it never became in-flight.
81    /// Release its **queue** slot (not `in_flight`), drop the token, and wake any
82    /// drain waiter. Distinct from [`Self::mark_finished`], which decrements
83    /// `in_flight` (#146 R: a cancel on a queued run now takes effect at once).
84    pub fn mark_queued_cancelled(&self, run_id: &str) {
85        self.dec_queued();
86        self.tokens.remove(run_id);
87        self.drained.notify_waiters();
88    }
89
90    /// Saturating decrement of `queued` (never wraps below 0 — a wrap to
91    /// usize::MAX would permanently fail `try_reserve` and wedge the server).
92    fn dec_queued(&self) {
93        let _ = self
94            .queued
95            .fetch_update(Ordering::AcqRel, Ordering::Acquire, |q| {
96                Some(q.saturating_sub(1))
97            });
98    }
99
100    /// Saturating decrement of `in_flight`.
101    fn dec_in_flight(&self) {
102        let _ = self
103            .in_flight
104            .fetch_update(Ordering::AcqRel, Ordering::Acquire, |n| {
105                Some(n.saturating_sub(1))
106            });
107    }
108
109    /// Cancel a live run. Returns `true` if a live token existed.
110    pub fn cancel(&self, run_id: &str) -> bool {
111        if let Some(t) = self.tokens.get(run_id) {
112            t.cancel();
113            true
114        } else {
115            false
116        }
117    }
118
119    /// Register a shard's cancel token under a per-shard key (`{run_id}::{shard_id}`).
120    /// Separate from a run's token so a sharded run's shards each get their own
121    /// cooperative-cancel signal (Mode B, #230 / F10).
122    pub fn register_shard(&self, run_id: &str, shard_id: &str, token: CancellationToken) {
123        self.tokens.insert(shard_key(run_id, shard_id), token);
124    }
125
126    /// Drop a token by key without touching the queue/in-flight counters. Used to
127    /// remove a finished shard's token (shard accounting is separate from the
128    /// parent run's `in_flight`, so [`Self::mark_finished`] is not appropriate).
129    pub fn deregister_shard(&self, run_id: &str, shard_id: &str) {
130        self.tokens.remove(&shard_key(run_id, shard_id));
131    }
132
133    /// A claimed shard began executing: bump `in_flight` so the shutdown drain
134    /// (`wait_drained` + `shutdown.cancel()`) accounts for it. Without this,
135    /// running Mode-B shards are invisible to `wait_drained`, so SIGTERM sees
136    /// `in_flight == 0`, `shutdown.cancel()` never fires, and the detached shard
137    /// tasks are hard-dropped mid-write with no cooperative flush (audit #321
138    /// H5). Pairs with [`Self::mark_shard_finished`].
139    pub fn mark_shard_running(&self) {
140        self.in_flight.fetch_add(1, Ordering::AcqRel);
141    }
142
143    /// A shard reached a terminal state: decrement `in_flight`, drop its token,
144    /// and wake any drain waiter. The shard-specific analogue of
145    /// [`Self::mark_finished`].
146    pub fn mark_shard_finished(&self, run_id: &str, shard_id: &str) {
147        self.dec_in_flight();
148        self.deregister_shard(run_id, shard_id);
149        self.drained.notify_waiters();
150    }
151
152    /// Fire every registered shard token whose key belongs to `run_id` (key
153    /// prefix `{run_id}::`). Returns how many tokens were fired. Drives a
154    /// cross-instance cancel of a sharded run: the claim loop calls this for each
155    /// run id returned by `pending_shard_cancellations` (F10).
156    pub fn cancel_run_shards(&self, run_id: &str) -> usize {
157        let prefix = format!("{run_id}::");
158        let mut fired = 0usize;
159        for entry in self.tokens.iter() {
160            if entry.key().starts_with(&prefix) {
161                entry.value().cancel();
162                fired += 1;
163            }
164        }
165        fired
166    }
167
168    pub fn queued(&self) -> usize {
169        self.queued.load(Ordering::Acquire)
170    }
171
172    pub fn in_flight(&self) -> usize {
173        self.in_flight.load(Ordering::Acquire)
174    }
175
176    pub fn is_full(&self) -> bool {
177        self.queued() >= self.max_queued
178    }
179
180    /// Resolve once no run is queued or in flight. Arms the notification *before*
181    /// re-checking so a transition can't be missed.
182    pub async fn wait_drained(&self) {
183        loop {
184            if self.queued() == 0 && self.in_flight() == 0 {
185                return;
186            }
187            let notified = self.drained.notified();
188            if self.queued() == 0 && self.in_flight() == 0 {
189                return;
190            }
191            notified.await;
192        }
193    }
194}
195
196#[cfg(test)]
197mod tests {
198    use super::*;
199
200    #[test]
201    fn reserve_respects_capacity() {
202        let r = Registry::new(2);
203        assert!(r.try_reserve());
204        assert!(r.try_reserve());
205        assert!(!r.try_reserve());
206        assert!(r.is_full());
207        r.release_reservation();
208        assert!(r.try_reserve());
209    }
210
211    #[test]
212    fn running_transition_moves_counters() {
213        let r = Registry::new(4);
214        r.try_reserve();
215        assert_eq!(r.queued(), 1);
216        r.mark_running();
217        assert_eq!(r.queued(), 0);
218        assert_eq!(r.in_flight(), 1);
219        r.mark_finished("x");
220        assert_eq!(r.in_flight(), 0);
221    }
222
223    #[test]
224    fn mark_running_unqueued_only_bumps_in_flight() {
225        let r = Registry::new(4);
226        // No reservation taken (cluster claim path).
227        r.mark_running_unqueued();
228        assert_eq!(
229            r.queued(),
230            0,
231            "queued must NOT be decremented (no slot was held)"
232        );
233        assert_eq!(r.in_flight(), 1);
234        r.mark_finished("x");
235        assert_eq!(r.in_flight(), 0);
236        assert_eq!(r.queued(), 0);
237    }
238
239    #[test]
240    fn queued_decrement_saturates_at_zero() {
241        let r = Registry::new(4);
242        // A spurious decrement at 0 must NOT wrap to usize::MAX (that would
243        // permanently fail try_reserve and wedge backpressure — #228).
244        r.mark_running(); // dec_queued() at 0 + in_flight++
245        assert_eq!(r.queued(), 0, "saturating: stays 0, never usize::MAX");
246        assert!(
247            r.try_reserve(),
248            "try_reserve still works (queued not wrapped)"
249        );
250    }
251
252    #[test]
253    fn cancel_reports_presence() {
254        let r = Registry::new(4);
255        let token = CancellationToken::new();
256        r.register("run1".into(), token.clone());
257        assert!(r.cancel("run1"));
258        assert!(token.is_cancelled());
259        assert!(!r.cancel("missing"));
260    }
261
262    #[test]
263    fn cancel_run_shards_fires_only_matching_run_tokens() {
264        let r = Registry::new(8);
265        // Two shards of run "A", one shard of run "B", plus a whole-run token for
266        // "A" (registered under the bare run id — must NOT be fired by the shard
267        // sweep, which keys on the "A::" prefix).
268        let a0 = CancellationToken::new();
269        let a1 = CancellationToken::new();
270        let b0 = CancellationToken::new();
271        let a_run = CancellationToken::new();
272        r.register_shard("A", "0", a0.clone());
273        r.register_shard("A", "1", a1.clone());
274        r.register_shard("B", "0", b0.clone());
275        r.register("A".into(), a_run.clone());
276
277        let fired = r.cancel_run_shards("A");
278        assert_eq!(fired, 2, "both A shards fired");
279        assert!(a0.is_cancelled());
280        assert!(a1.is_cancelled());
281        assert!(!b0.is_cancelled(), "B's shard untouched");
282        assert!(
283            !a_run.is_cancelled(),
284            "A's whole-run token (bare id, no '::') untouched"
285        );
286
287        // No shards for a run → fires nothing.
288        assert_eq!(r.cancel_run_shards("C"), 0);
289    }
290
291    #[test]
292    fn shard_running_counts_toward_in_flight_and_drain() {
293        // #321 H5: a running shard must be visible to the shutdown drain so
294        // `wait_drained` blocks on it and `shutdown.cancel()` gets a chance to
295        // fire the cooperative flush.
296        let r = Registry::new(4);
297        r.register_shard("A", "0", CancellationToken::new());
298        r.mark_shard_running();
299        assert_eq!(r.in_flight(), 1, "a running shard bumps in_flight");
300        // Finishing drops the token, decrements in_flight, and wakes the drain.
301        r.mark_shard_finished("A", "0");
302        assert_eq!(r.in_flight(), 0);
303        assert_eq!(
304            r.cancel_run_shards("A"),
305            0,
306            "the shard token was removed on finish"
307        );
308    }
309
310    #[test]
311    fn deregister_shard_removes_the_token() {
312        let r = Registry::new(4);
313        r.register_shard("A", "0", CancellationToken::new());
314        r.register_shard("A", "1", CancellationToken::new());
315        r.deregister_shard("A", "0");
316        // Only "A::1" remains → exactly one token fired.
317        assert_eq!(r.cancel_run_shards("A"), 1, "deregistered token not fired");
318        r.deregister_shard("A", "1");
319        assert_eq!(r.cancel_run_shards("A"), 0, "all shard tokens removed");
320    }
321
322    #[test]
323    fn is_not_idle_while_queued() {
324        let r = Registry::new(4);
325        r.try_reserve();
326        // queued=1, in_flight=0 — must NOT be considered drained.
327        assert_eq!(r.queued(), 1);
328        assert_eq!(r.in_flight(), 0);
329    }
330
331    #[tokio::test]
332    async fn wait_drained_returns_when_idle() {
333        let r = Registry::new(4);
334        // No in-flight work → resolves immediately.
335        r.wait_drained().await;
336    }
337}