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}