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    /// Fire every registered shard token whose key belongs to `run_id` (key
134    /// prefix `{run_id}::`). Returns how many tokens were fired. Drives a
135    /// cross-instance cancel of a sharded run: the claim loop calls this for each
136    /// run id returned by `pending_shard_cancellations` (F10).
137    pub fn cancel_run_shards(&self, run_id: &str) -> usize {
138        let prefix = format!("{run_id}::");
139        let mut fired = 0usize;
140        for entry in self.tokens.iter() {
141            if entry.key().starts_with(&prefix) {
142                entry.value().cancel();
143                fired += 1;
144            }
145        }
146        fired
147    }
148
149    pub fn queued(&self) -> usize {
150        self.queued.load(Ordering::Acquire)
151    }
152
153    pub fn in_flight(&self) -> usize {
154        self.in_flight.load(Ordering::Acquire)
155    }
156
157    pub fn is_full(&self) -> bool {
158        self.queued() >= self.max_queued
159    }
160
161    /// Resolve once no run is queued or in flight. Arms the notification *before*
162    /// re-checking so a transition can't be missed.
163    pub async fn wait_drained(&self) {
164        loop {
165            if self.queued() == 0 && self.in_flight() == 0 {
166                return;
167            }
168            let notified = self.drained.notified();
169            if self.queued() == 0 && self.in_flight() == 0 {
170                return;
171            }
172            notified.await;
173        }
174    }
175}
176
177#[cfg(test)]
178mod tests {
179    use super::*;
180
181    #[test]
182    fn reserve_respects_capacity() {
183        let r = Registry::new(2);
184        assert!(r.try_reserve());
185        assert!(r.try_reserve());
186        assert!(!r.try_reserve());
187        assert!(r.is_full());
188        r.release_reservation();
189        assert!(r.try_reserve());
190    }
191
192    #[test]
193    fn running_transition_moves_counters() {
194        let r = Registry::new(4);
195        r.try_reserve();
196        assert_eq!(r.queued(), 1);
197        r.mark_running();
198        assert_eq!(r.queued(), 0);
199        assert_eq!(r.in_flight(), 1);
200        r.mark_finished("x");
201        assert_eq!(r.in_flight(), 0);
202    }
203
204    #[test]
205    fn mark_running_unqueued_only_bumps_in_flight() {
206        let r = Registry::new(4);
207        // No reservation taken (cluster claim path).
208        r.mark_running_unqueued();
209        assert_eq!(
210            r.queued(),
211            0,
212            "queued must NOT be decremented (no slot was held)"
213        );
214        assert_eq!(r.in_flight(), 1);
215        r.mark_finished("x");
216        assert_eq!(r.in_flight(), 0);
217        assert_eq!(r.queued(), 0);
218    }
219
220    #[test]
221    fn queued_decrement_saturates_at_zero() {
222        let r = Registry::new(4);
223        // A spurious decrement at 0 must NOT wrap to usize::MAX (that would
224        // permanently fail try_reserve and wedge backpressure — #228).
225        r.mark_running(); // dec_queued() at 0 + in_flight++
226        assert_eq!(r.queued(), 0, "saturating: stays 0, never usize::MAX");
227        assert!(
228            r.try_reserve(),
229            "try_reserve still works (queued not wrapped)"
230        );
231    }
232
233    #[test]
234    fn cancel_reports_presence() {
235        let r = Registry::new(4);
236        let token = CancellationToken::new();
237        r.register("run1".into(), token.clone());
238        assert!(r.cancel("run1"));
239        assert!(token.is_cancelled());
240        assert!(!r.cancel("missing"));
241    }
242
243    #[test]
244    fn cancel_run_shards_fires_only_matching_run_tokens() {
245        let r = Registry::new(8);
246        // Two shards of run "A", one shard of run "B", plus a whole-run token for
247        // "A" (registered under the bare run id — must NOT be fired by the shard
248        // sweep, which keys on the "A::" prefix).
249        let a0 = CancellationToken::new();
250        let a1 = CancellationToken::new();
251        let b0 = CancellationToken::new();
252        let a_run = CancellationToken::new();
253        r.register_shard("A", "0", a0.clone());
254        r.register_shard("A", "1", a1.clone());
255        r.register_shard("B", "0", b0.clone());
256        r.register("A".into(), a_run.clone());
257
258        let fired = r.cancel_run_shards("A");
259        assert_eq!(fired, 2, "both A shards fired");
260        assert!(a0.is_cancelled());
261        assert!(a1.is_cancelled());
262        assert!(!b0.is_cancelled(), "B's shard untouched");
263        assert!(
264            !a_run.is_cancelled(),
265            "A's whole-run token (bare id, no '::') untouched"
266        );
267
268        // No shards for a run → fires nothing.
269        assert_eq!(r.cancel_run_shards("C"), 0);
270    }
271
272    #[test]
273    fn deregister_shard_removes_the_token() {
274        let r = Registry::new(4);
275        r.register_shard("A", "0", CancellationToken::new());
276        r.register_shard("A", "1", CancellationToken::new());
277        r.deregister_shard("A", "0");
278        // Only "A::1" remains → exactly one token fired.
279        assert_eq!(r.cancel_run_shards("A"), 1, "deregistered token not fired");
280        r.deregister_shard("A", "1");
281        assert_eq!(r.cancel_run_shards("A"), 0, "all shard tokens removed");
282    }
283
284    #[test]
285    fn is_not_idle_while_queued() {
286        let r = Registry::new(4);
287        r.try_reserve();
288        // queued=1, in_flight=0 — must NOT be considered drained.
289        assert_eq!(r.queued(), 1);
290        assert_eq!(r.in_flight(), 0);
291    }
292
293    #[tokio::test]
294    async fn wait_drained_returns_when_idle() {
295        let r = Registry::new(4);
296        // No in-flight work → resolves immediately.
297        r.wait_drained().await;
298    }
299}