beck_rt/quota.rs
1//! F3's per-actor write quota: how much one actor may turn into permanent storage.
2//!
3//! [`docs/14`](../../../../../docs/14-review-findings.md) F3 splits the "events are forever"
4//! problem in two. Channel (a) — rejected garbage — was closed by §3.7's rule that **only validated
5//! events are durably logged**, so a refused command leaves nothing behind. Channel (b) is the one
6//! this module is for:
7//!
8//! > *validated* spam from a legitimate but abusive session — permanent by design. Remediation:
9//! > per-actor rate/volume quotas … on by default with generous limits.
10//!
11//! On by default is the load-bearing half. A quota a program has to ask for is a quota most
12//! programs do not have, and F3's whole point is that the *default* deployment should not turn an
13//! abusive session into a permanent cost.
14//!
15//! # The table is bounded, which is the part that is easy to get wrong
16//!
17//! The obvious implementation is a map from actor to a counter. That map is **unbounded memory
18//! keyed by a string the client chooses** — the same denial of service the quota exists to prevent,
19//! moved one level down and made harder to see. Under `DevIdentity` the actor *is* whatever the
20//! client says, so an attacker sending a fresh name per proposal would both evade the quota and
21//! grow the table.
22//!
23//! So the counters are **sharded**: a fixed number of buckets, an actor hashed into one, and no
24//! per-actor allocation ever. Memory is [`BUCKETS`] × a few bytes, for the life of the process,
25//! whatever arrives. Two consequences, both deliberate:
26//!
27//! * **Two actors can share a bucket, and therefore a budget.** That is why the limit is generous
28//! rather than tight: a shared bucket must still be ample for both. [`Quota::default`] says what
29//! the numbers are and why.
30//! * **The hash is keyed per process** (`RandomState`), so a client cannot compute a name that
31//! lands in a chosen bucket. Without that, sharing a bucket stops being an accident an operator
32//! accepts and becomes a way to spend somebody else's budget on purpose.
33//!
34//! # What this is not
35//!
36//! It binds an **actor**, so it is worth exactly what the actor is worth. Under
37//! [`crate::identity::DevIdentity`] the actor is the claim the client sent, so an attacker who
38//! rotates names spreads across buckets rather than being stopped — the *total* is still bounded by
39//! [`BUCKETS`] × the limit, which is a bound rather than the bound anybody wanted.
40//! [`docs/48`](../../../../../docs/48-identity-report.md) is the seam that fixes this, and
41//! [`docs/82`](../../../../../docs/82-the-edge-report.md) §82.5 is the
42//! composition written out rather than left to be inferred.
43
44use std::sync::atomic::{AtomicU64, Ordering};
45
46/// How many counters exist, for all actors, forever.
47///
48/// A power of two so the index is a mask. 1,024 × 16 bytes is 16 KiB, which is small enough that
49/// the table never has to be swept, resized or evicted from — and "never has to be swept" is the
50/// property that makes this bounded rather than merely large.
51pub const BUCKETS: usize = 1024;
52
53/// The limits, and the window they are counted over.
54#[derive(Clone, Copy, Debug, PartialEq, Eq)]
55pub struct Quota {
56 /// How many events one bucket may add to the log per window. `None` disables the quota.
57 pub events_per_window: Option<u32>,
58 /// How long a window is, in milliseconds.
59 pub window_ms: u64,
60}
61
62impl Default for Quota {
63 /// "On by default with generous limits" — F3's own words, with numbers attached.
64 ///
65 /// **600 events a minute**, which is ten a second sustained. The number is chosen from what a
66 /// *person* can produce: a fast typist committing a todo per keystroke does not reach it, and a
67 /// UI that batches at all is nowhere near. It is deliberately far above any interactive use and
68 /// far below what a script can do in a second, which is the gap F3 asks to be closed.
69 ///
70 /// It is generous for the reason the module doc gives as well: two actors can share a bucket,
71 /// so the limit has to be ample for both and still bite a script.
72 fn default() -> Self {
73 Quota {
74 events_per_window: Some(600),
75 window_ms: 60_000,
76 }
77 }
78}
79
80impl Quota {
81 /// A quota that refuses nothing — for a deployment that enforces elsewhere, and for the tests
82 /// that assert what the runtime does when the limit is not the thing under test.
83 pub fn unlimited() -> Quota {
84 Quota {
85 events_per_window: None,
86 window_ms: 60_000,
87 }
88 }
89}
90
91/// One bucket: how many, and when the window it belongs to started.
92///
93/// Two atomics rather than a lock, because this is read and written on the path every proposal
94/// takes and a lock there would serialise the merge point on bookkeeping.
95#[derive(Default)]
96struct Bucket {
97 count: AtomicU64,
98 window_start_ms: AtomicU64,
99}
100
101/// The sharded counters, and the process-random hash that decides which bucket an actor lands in.
102pub struct RateLimit {
103 quota: Quota,
104 buckets: Box<[Bucket]>,
105 /// `RandomState` is the standard library's answer to exactly this question — it is what makes a
106 /// `HashMap` resistant to a caller choosing colliding keys — and it seeds itself once per
107 /// process from the OS. Using it here rather than minting a key by hand keeps the workspace's
108 /// `forbid(unsafe)` intact and keeps this module out of the business of finding entropy.
109 hash: std::collections::hash_map::RandomState,
110}
111
112impl RateLimit {
113 pub fn new(quota: Quota) -> RateLimit {
114 let mut buckets = Vec::with_capacity(BUCKETS);
115 buckets.resize_with(BUCKETS, Bucket::default);
116 RateLimit {
117 quota,
118 buckets: buckets.into_boxed_slice(),
119 hash: std::collections::hash_map::RandomState::new(),
120 }
121 }
122
123 pub fn quota(&self) -> Quota {
124 self.quota
125 }
126
127 /// Charge one event to this actor, and say whether it is allowed.
128 ///
129 /// `now_ms` is passed in rather than read, for the reason `Proposal::at` is: the merge point is
130 /// the one place time enters, and it enters from the clock the process was configured with
131 /// (§3.7, F11). A quota that read the wall clock itself would be a second, ambient one.
132 pub fn admit(&self, actor: &str, now_ms: i64) -> bool {
133 let Some(limit) = self.quota.events_per_window else {
134 return true;
135 };
136 let now = now_ms.max(0) as u64;
137 let window = now - (now % self.quota.window_ms.max(1));
138 let bucket = &self.buckets[self.index(actor)];
139
140 // Roll the window if this bucket is still in an older one. A racing pair may both roll;
141 // both write the same `window`, and the loser's `count` reset is the same reset, so the
142 // worst case is one event's worth of slack rather than a wrong window.
143 if bucket.window_start_ms.swap(window, Ordering::Relaxed) != window {
144 bucket.count.store(0, Ordering::Relaxed);
145 }
146 bucket.count.fetch_add(1, Ordering::Relaxed) < u64::from(limit)
147 }
148
149 fn index(&self, actor: &str) -> usize {
150 // Keyed, so a client cannot pick a name that shares a bucket with somebody else's. What is
151 // required is not that the hash be cryptographic but that an attacker cannot *search* for a
152 // collision — and there is no oracle to search against, because which bucket a name landed
153 // in is never observable from outside.
154 use std::hash::BuildHasher;
155 (self.hash.hash_one(actor) as usize) & (BUCKETS - 1)
156 }
157}
158
159#[cfg(test)]
160mod tests {
161 use super::*;
162
163 #[test]
164 fn an_actor_is_admitted_up_to_the_limit_and_refused_after_it() {
165 let rl = RateLimit::new(Quota {
166 events_per_window: Some(3),
167 window_ms: 1000,
168 });
169 assert!(rl.admit("ana", 0));
170 assert!(rl.admit("ana", 10));
171 assert!(rl.admit("ana", 20));
172 assert!(!rl.admit("ana", 30), "the fourth is over the limit");
173 }
174
175 #[test]
176 fn the_next_window_starts_the_count_again() {
177 let rl = RateLimit::new(Quota {
178 events_per_window: Some(2),
179 window_ms: 1000,
180 });
181 assert!(rl.admit("ana", 0));
182 assert!(rl.admit("ana", 1));
183 assert!(!rl.admit("ana", 2));
184 assert!(rl.admit("ana", 1000), "a new window is a new budget");
185 assert!(rl.admit("ana", 1999));
186 assert!(!rl.admit("ana", 1999));
187 }
188
189 /// The disabled setting really does disable it, which the runtime's own tests depend on.
190 #[test]
191 fn an_unlimited_quota_admits_everything() {
192 let rl = RateLimit::new(Quota::unlimited());
193 for i in 0..10_000 {
194 assert!(rl.admit("ana", i));
195 }
196 }
197
198 /// The property the whole design exists for: the table does not grow.
199 ///
200 /// Ten thousand distinct actors — which is what a client rotating names produces — and the
201 /// memory afterwards is the same [`BUCKETS`] counters it was before. There is nothing to
202 /// measure because there is nothing to allocate; the assertion is that the structure has no
203 /// per-actor storage to have grown.
204 #[test]
205 fn ten_thousand_actors_allocate_nothing() {
206 let rl = RateLimit::new(Quota::default());
207 for i in 0..10_000 {
208 rl.admit(&format!("actor-{i}"), 0);
209 }
210 assert_eq!(rl.buckets.len(), BUCKETS);
211 }
212
213 /// Rotating names is not free, even though it is not stopped.
214 ///
215 /// `docs/82` §82.5: under `DevIdentity` an attacker chooses the actor, so a fresh name per
216 /// proposal spreads across buckets rather than exhausting one. The total is still bounded —
217 /// `BUCKETS × limit` per window — and this test is what says that bound is real rather than
218 /// asserted.
219 #[test]
220 fn rotating_actor_names_is_bounded_by_the_table_rather_than_by_the_limit() {
221 let limit = 4;
222 let rl = RateLimit::new(Quota {
223 events_per_window: Some(limit),
224 window_ms: 60_000,
225 });
226 let admitted = (0..200_000)
227 .filter(|i| rl.admit(&format!("actor-{i}"), 0))
228 .count();
229 let ceiling = BUCKETS * limit as usize;
230 assert!(
231 admitted <= ceiling,
232 "{admitted} admitted, and the table can only ever allow {ceiling}"
233 );
234 assert!(
235 admitted > ceiling / 2,
236 "{admitted} — the buckets should fill roughly evenly, or the hash is the problem"
237 );
238 }
239}