Skip to main content

choir_sequencer/
fairness.rs

1//! Per-actor admission quotas in front of the single writer.
2//!
3//! The sequencer is one thread serving everyone, so the cost of one
4//! actor's burst is paid by every other actor's latency. Before this,
5//! nothing bounded that: an agent in a retry loop could put ten thousand
6//! ops in the channel and the human waiting behind them had no recourse
7//! but to wait.
8//!
9//! A quota is a ceiling on how many of one actor's ops can be *awaiting
10//! a decision* at once. That is the number the fairness bound is stated
11//! in: with a limit of `Q`, an op arriving from anyone else waits behind
12//! at most `Q` ops from any single other actor, whatever that actor is
13//! doing. Exceeding the quota is answered immediately with a rejection
14//! naming it — never by blocking, which would move the queue from the
15//! sequencer into the submitter's thread and hide it.
16//!
17//! # What "actor" means here, and what it does not
18//!
19//! The key is [`Witness::key_id`] as *claimed* by the submission, taken
20//! without verifying the signature. It has to be: verification happens
21//! inside `SubmitPolicy::check` on the writer thread, which is the whole
22//! point of that design (authorization is evaluated at apply time), and
23//! verifying again at intake would double the cost of the one genuinely
24//! expensive step to answer a question about scheduling.
25//!
26//! So this is fairness, not security, and the difference is worth being
27//! precise about:
28//!
29//! - **An honest actor flooding** is bounded. This is the case that
30//!   actually happens, and the one the quota exists for.
31//! - **An adversary claiming many identities** evades the quota by
32//!   spreading across them. Bounding *that* is the node's rate limiter
33//!   (`--rate-limit-api`), which counts requests rather than trusting
34//!   what they claim.
35//! - **An adversary claiming someone else's key id** can occupy that
36//!   actor's slots, which is a denial of service against one actor. It
37//!   is bounded but not prevented: slots free the moment the writer
38//!   decides, and a forged signature is rejected in one verification, so
39//!   holding another actor's quota costs sustained request volume — the
40//!   rate limiter's department again.
41//!
42//! Naming a bucket is therefore not a claim about who anyone is. Nothing
43//! here is load-bearing for authorization, and nothing here may become
44//! so: the moment a decision about *permission* keys off this string, an
45//! unverified field has been promoted to an identity.
46//!
47//! [`Witness::key_id`]: choir_oplog::Witness::key_id
48
49use std::collections::BTreeMap;
50use std::sync::atomic::{AtomicUsize, Ordering};
51use std::sync::{Arc, Mutex};
52
53use crate::Submission;
54
55/// Default ceiling on one actor's ops awaiting a decision.
56///
57/// Deliberately generous, at twice the sequencer's 256-op batch. The
58/// quota's job
59/// is to stop a runaway, not to shape traffic, and `try_submit_many`
60/// exists precisely so a client can offer a large group at once — a
61/// ceiling that made the batch endpoint reject its own documented usage
62/// would be a bug wearing a policy's clothes. An operator who wants
63/// sharper fairness lowers it with [`Quotas::set_limit`].
64pub const DEFAULT_QUOTA: usize = 512;
65
66/// A limit meaning "do not enforce one".
67pub const UNLIMITED: usize = usize::MAX;
68
69/// Distinct actors tracked before idle ones are forgotten.
70///
71/// Buckets are kept after they empty so a busy actor's key is interned
72/// rather than reallocated per op. That would grow without bound under
73/// invented key ids, so once the table is over this size an emptied
74/// bucket is dropped instead of kept. Steady-state actors stay interned;
75/// one-shot identities do not accumulate.
76const MAX_TRACKED: usize = 1024;
77
78/// Shared per-actor in-flight counts. Cloneable; every clone refers to
79/// the same table.
80#[derive(Clone)]
81pub struct Quotas {
82    inflight: Arc<Mutex<BTreeMap<Arc<str>, usize>>>,
83    limit: Arc<AtomicUsize>,
84}
85
86impl Default for Quotas {
87    fn default() -> Self {
88        Self::new(DEFAULT_QUOTA)
89    }
90}
91
92impl Quotas {
93    /// A table enforcing `limit` ops in flight per actor.
94    #[must_use]
95    pub fn new(limit: usize) -> Self {
96        Self {
97            inflight: Arc::new(Mutex::new(BTreeMap::new())),
98            limit: Arc::new(AtomicUsize::new(limit)),
99        }
100    }
101
102    /// Changes the ceiling for subsequent admissions.
103    ///
104    /// Lowering it never revokes an admission already granted: ops in
105    /// flight are already the writer's business, and refusing them
106    /// retroactively would break the promise that a submission is either
107    /// answered or rejected, not abandoned.
108    pub fn set_limit(&self, limit: usize) {
109        self.limit.store(limit, Ordering::Relaxed);
110    }
111
112    /// The current ceiling.
113    #[must_use]
114    pub fn limit(&self) -> usize {
115        self.limit.load(Ordering::Relaxed)
116    }
117
118    /// The bucket a submission counts against.
119    ///
120    /// Signed ops are keyed by their claimed key id. Unsigned ones fall
121    /// back to the channel, which keeps the pre-L8 and dev paths from
122    /// collapsing into one shared bucket where every workspace would
123    /// throttle every other.
124    #[must_use]
125    pub fn actor_of(sub: &Submission) -> &str {
126        match &sub.author_sig {
127            Some(witness) => &witness.key_id,
128            None => &sub.channel,
129        }
130    }
131
132    /// Takes a slot for `actor`, returning the interned key to release it
133    /// with.
134    ///
135    /// The interning is not a micro-optimization for its own sake: this
136    /// runs on every submission, and `alloc_budget` asserts a ceiling on
137    /// allocator calls per op. Handing back a shared `Arc<str>` means the
138    /// per-op cost after an actor's first op is a refcount rather than a
139    /// string copy.
140    ///
141    /// # Errors
142    ///
143    /// A rejection naming the quota, when `actor` already has `limit` ops
144    /// awaiting a decision.
145    pub fn admit(&self, actor: &str) -> Result<Arc<str>, String> {
146        let limit = self.limit.load(Ordering::Relaxed);
147        // A poisoned lock means some other thread panicked while holding
148        // it. The table is a counter, not an invariant anyone reasons
149        // about across a panic, so the recovery is to carry on rather
150        // than to spread the panic into every submitter.
151        let mut inflight = self
152            .inflight
153            .lock()
154            .unwrap_or_else(std::sync::PoisonError::into_inner);
155        let key = match inflight.get_key_value(actor) {
156            Some((key, count)) => {
157                if *count >= limit {
158                    return Err(format!(
159                        "quota exhausted: {count} ops from this actor are already \
160                         awaiting a decision (limit {limit}); retry when one completes"
161                    ));
162                }
163                key.clone()
164            }
165            None => {
166                if limit == 0 {
167                    return Err("quota exhausted: this sequencer is admitting nothing \
168                                (limit 0)"
169                        .to_string());
170                }
171                let key: Arc<str> = Arc::from(actor);
172                inflight.insert(key.clone(), 0);
173                key
174            }
175        };
176        *inflight.get_mut(&key).expect("just inserted or just found") += 1;
177        Ok(key)
178    }
179
180    /// Returns a slot, once the writer has decided that op.
181    ///
182    /// Released at the decision rather than at the acknowledgement: the
183    /// quota bounds how much work can be queued *ahead* of someone else,
184    /// and once an op is appended it is no longer ahead of anything. Ops
185    /// waiting on a shared durability barrier have already had their
186    /// ordering cost paid by whoever was behind them.
187    pub fn release(&self, actor: &Arc<str>) {
188        let mut inflight = self
189            .inflight
190            .lock()
191            .unwrap_or_else(std::sync::PoisonError::into_inner);
192        let Some(count) = inflight.get_mut(actor.as_ref()) else {
193            return;
194        };
195        *count = count.saturating_sub(1);
196        if *count == 0 && inflight.len() > MAX_TRACKED {
197            inflight.remove(actor.as_ref());
198        }
199    }
200
201    /// Ops from `actor` currently awaiting a decision. For tests and
202    /// operator reporting.
203    #[must_use]
204    pub fn in_flight(&self, actor: &str) -> usize {
205        self.inflight
206            .lock()
207            .unwrap_or_else(std::sync::PoisonError::into_inner)
208            .get(actor)
209            .copied()
210            .unwrap_or(0)
211    }
212}
213
214#[cfg(test)]
215mod tests {
216    use super::*;
217
218    fn signed(key_id: &str) -> Submission {
219        Submission {
220            channel: "ws".into(),
221            payload: Vec::new(),
222            author_sig: Some(choir_oplog::Witness::ed25519(key_id, Vec::new())),
223        }
224    }
225
226    #[test]
227    fn a_full_bucket_is_refused_with_the_limit_in_the_message() {
228        let quotas = Quotas::new(2);
229        let a = quotas.admit("alice").expect("first");
230        let _b = quotas.admit("alice").expect("second");
231        let refused = quotas.admit("alice").expect_err("third exceeds the limit");
232        assert!(
233            refused.contains("limit 2"),
234            "the rejection must name the quota so a client can react to it: {refused}"
235        );
236        // And it is a refusal, not a delay: releasing one slot lets the
237        // next in immediately.
238        quotas.release(&a);
239        quotas.admit("alice").expect("a freed slot admits");
240    }
241
242    #[test]
243    fn one_actors_ceiling_does_not_touch_another() {
244        let quotas = Quotas::new(1);
245        let _a = quotas.admit("alice").expect("alice's only slot");
246        assert!(quotas.admit("alice").is_err(), "alice is at her limit");
247        quotas
248            .admit("bob")
249            .expect("bob's quota is his own; this is the whole point");
250    }
251
252    #[test]
253    fn an_unsigned_submission_is_keyed_by_channel_not_lumped_together() {
254        let one = Submission {
255            channel: "agent-a".into(),
256            payload: Vec::new(),
257            author_sig: None,
258        };
259        let two = Submission {
260            channel: "agent-b".into(),
261            payload: Vec::new(),
262            author_sig: None,
263        };
264        assert_eq!(Quotas::actor_of(&one), "agent-a");
265        assert_ne!(
266            Quotas::actor_of(&one),
267            Quotas::actor_of(&two),
268            "unsigned clients must not share one bucket, or every dev \
269             workspace throttles every other"
270        );
271    }
272
273    #[test]
274    fn a_signed_submission_is_keyed_by_its_claimed_key_id() {
275        assert_eq!(Quotas::actor_of(&signed("kid-7")), "kid-7");
276    }
277
278    #[test]
279    fn releasing_more_than_was_taken_cannot_underflow() {
280        let quotas = Quotas::new(4);
281        let key = quotas.admit("alice").expect("slot");
282        quotas.release(&key);
283        quotas.release(&key);
284        quotas.release(&key);
285        assert_eq!(quotas.in_flight("alice"), 0);
286        // Still usable afterwards: a saturating counter must not have
287        // wrapped to something enormous that silently disables the quota.
288        assert!(quotas.admit("alice").is_ok());
289        assert_eq!(quotas.in_flight("alice"), 1);
290    }
291
292    #[test]
293    fn releasing_an_actor_that_was_never_admitted_is_a_no_op() {
294        let quotas = Quotas::new(4);
295        let stray: Arc<str> = Arc::from("never-seen");
296        quotas.release(&stray);
297        assert_eq!(quotas.in_flight("never-seen"), 0);
298    }
299
300    #[test]
301    fn idle_buckets_are_forgotten_once_the_table_is_oversized() {
302        let quotas = Quotas::new(4);
303        // Fill past the tracking cap with one-shot identities, the shape
304        // an invented-key-id flood would take.
305        for i in 0..=MAX_TRACKED {
306            let key = quotas.admit(&format!("throwaway-{i}")).expect("admitted");
307            quotas.release(&key);
308        }
309        let tracked = quotas.inflight.lock().expect("not poisoned").len();
310        assert!(
311            tracked <= MAX_TRACKED + 1,
312            "emptied buckets must be dropped once over the cap, or an \
313             invented key id is a permanent allocation: {tracked}"
314        );
315    }
316
317    #[test]
318    fn a_busy_actor_keeps_one_interned_key() {
319        let quotas = Quotas::new(8);
320        let first = quotas.admit("alice").expect("first");
321        let second = quotas.admit("alice").expect("second");
322        assert!(
323            Arc::ptr_eq(&first, &second),
324            "the same actor must hand back the same allocation, which is \
325             what keeps the per-op cost off the allocation budget"
326        );
327    }
328
329    #[test]
330    fn a_lowered_limit_does_not_revoke_what_is_already_in_flight() {
331        let quotas = Quotas::new(8);
332        let held: Vec<_> = (0..4)
333            .map(|_| quotas.admit("alice").expect("under the old limit"))
334            .collect();
335        quotas.set_limit(2);
336        assert_eq!(quotas.in_flight("alice"), 4, "nothing was taken back");
337        assert!(quotas.admit("alice").is_err(), "but nothing new is let in");
338        for key in &held {
339            quotas.release(key);
340        }
341        assert!(quotas.admit("alice").is_ok(), "and it recovers");
342    }
343}