Skip to main content

byteflow/scheduler/
quota.rs

1//! Resource quotas — Phase 3.
2//!
3//! Three independent budgets per flow:
4//! - `cpu_budget` — remaining reductions/instructions before the flow is
5//!   stopped pending an ADMIN top-up. Distinct from the scheduler *quantum*
6//!   (fairness / preemption, not an absolute consumption cap).
7//! - `mem_used` / `mem_limit` — bytes currently charged to this flow's heap.
8//! - `spawn_bucket` / `send_bucket` — token buckets drained on SPAWN / SEND,
9//!   refilled by time. Covers the spawn-bomb and mailbox-flood DoS the
10//!   threat model already admits.
11//!
12//! All three fail *closed*: exhausting a budget aborts the instruction
13//! (error / trap), never degrades silently into unlimited behaviour.
14
15use std::sync::atomic::{AtomicI64, AtomicUsize, Ordering};
16use std::sync::{Arc, Mutex};
17use std::time::{Duration, Instant};
18
19use super::error::RuntimeError;
20use super::sync_lock;
21
22/// Tunables copied onto every newly spawned flow.
23#[derive(Clone, Copy, Debug)]
24pub struct QuotaConfig {
25    pub cpu_budget: i64,
26    pub mem_limit: usize,
27    pub spawn_burst: i64,
28    pub spawn_per_sec: i64,
29    pub send_burst: i64,
30    pub send_per_sec: i64,
31}
32
33impl QuotaConfig {
34    /// Current defaults: generous so samples and tests keep passing.
35    pub fn permissive() -> Self {
36        Self {
37            cpu_budget: 1_000_000_000,
38            mem_limit: 64 * 1024 * 1024,
39            spawn_burst: 10_000,
40            spawn_per_sec: 10_000,
41            send_burst: 50_000,
42            send_per_sec: 50_000,
43        }
44    }
45
46    /// Starting point for untrusted modules. Tune under real load before
47    /// using as a production default.
48    pub fn sandbox() -> Self {
49        Self {
50            cpu_budget: 50_000,
51            mem_limit: 16 * 1024 * 1024,
52            spawn_burst: 32,
53            spawn_per_sec: 32,
54            send_burst: 256,
55            send_per_sec: 64,
56        }
57    }
58}
59
60impl Default for QuotaConfig {
61    fn default() -> Self {
62        Self::permissive()
63    }
64}
65
66pub struct TokenBucket {
67    capacity: i64,
68    tokens: AtomicI64,
69    refill_per_sec: i64,
70    last_refill: Mutex<Instant>,
71}
72
73impl TokenBucket {
74    pub fn new(capacity: i64, refill_per_sec: i64) -> Self {
75        Self {
76            capacity,
77            tokens: AtomicI64::new(capacity),
78            refill_per_sec,
79            last_refill: Mutex::new(Instant::now()),
80        }
81    }
82
83    /// Refill or fail closed on lock poison (no tokens granted).
84    fn refill(&self) -> Result<(), RuntimeError> {
85        let mut last = sync_lock::lock(&self.last_refill, "TokenBucket::refill")?;
86        let elapsed = last.elapsed();
87        if elapsed >= Duration::from_millis(50) {
88            let add = (elapsed.as_secs_f64() * self.refill_per_sec as f64) as i64;
89            if add > 0 {
90                let cur = self.tokens.load(Ordering::Relaxed);
91                let new = (cur + add).min(self.capacity);
92                self.tokens.store(new, Ordering::Relaxed);
93                *last = Instant::now();
94            }
95        }
96        Ok(())
97    }
98
99    /// True and consume one token iff one was available.
100    pub fn try_consume(&self) -> bool {
101        if self.refill().is_err() {
102            return false;
103        }
104        loop {
105            let cur = self.tokens.load(Ordering::Relaxed);
106            if cur <= 0 {
107                return false;
108            }
109            if self
110                .tokens
111                .compare_exchange_weak(cur, cur - 1, Ordering::Relaxed, Ordering::Relaxed)
112                .is_ok()
113            {
114                return true;
115            }
116        }
117    }
118
119    /// ADMIN credit. May exceed capacity until the next refill caps it.
120    pub fn credit(&self, extra: i64) {
121        if extra <= 0 {
122            return;
123        }
124        self.tokens.fetch_add(extra, Ordering::Relaxed);
125    }
126}
127
128#[derive(Debug, Clone, Copy, PartialEq, Eq)]
129pub enum QuotaError {
130    CpuExhausted,
131    MemoryExhausted { requested: usize, limit: usize },
132    SpawnRateExceeded,
133    SendRateExceeded,
134}
135
136impl std::fmt::Display for QuotaError {
137    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
138        match self {
139            QuotaError::CpuExhausted => f.write_str("flow CPU budget exhausted"),
140            QuotaError::MemoryExhausted { requested, limit } => {
141                write!(f, "flow memory exhausted (requested {requested}, limit {limit})")
142            }
143            QuotaError::SpawnRateExceeded => f.write_str("spawn rate exceeded"),
144            QuotaError::SendRateExceeded => f.write_str("send rate exceeded"),
145        }
146    }
147}
148
149impl std::error::Error for QuotaError {}
150
151pub struct FlowQuota {
152    cpu_budget: AtomicI64,
153    mem_limit: AtomicUsize,
154    mem_used: AtomicUsize,
155    spawn_bucket: TokenBucket,
156    send_bucket: TokenBucket,
157}
158
159impl FlowQuota {
160    pub fn from_config(cfg: QuotaConfig) -> Self {
161        Self::new(
162            cfg.cpu_budget,
163            cfg.mem_limit,
164            cfg.spawn_burst,
165            cfg.spawn_per_sec,
166            cfg.send_burst,
167            cfg.send_per_sec,
168        )
169    }
170
171    pub fn new(
172        cpu_budget: i64,
173        mem_limit: usize,
174        spawn_burst: i64,
175        spawn_per_sec: i64,
176        send_burst: i64,
177        send_per_sec: i64,
178    ) -> Self {
179        Self {
180            cpu_budget: AtomicI64::new(cpu_budget),
181            mem_limit: AtomicUsize::new(mem_limit),
182            mem_used: AtomicUsize::new(0),
183            spawn_bucket: TokenBucket::new(spawn_burst, spawn_per_sec),
184            send_bucket: TokenBucket::new(send_burst, send_per_sec),
185        }
186    }
187
188    pub fn remaining_cpu(&self) -> i64 {
189        self.cpu_budget.load(Ordering::Relaxed).max(0)
190    }
191
192    /// Call once per executed instruction (or every N, in a batch — the
193    /// batch only thickens trap latency, it does not change the limit).
194    pub fn charge_cpu(&self, cost: i64) -> Result<(), QuotaError> {
195        let remaining = self.cpu_budget.fetch_sub(cost, Ordering::Relaxed) - cost;
196        if remaining < 0 {
197            self.cpu_budget.fetch_add(cost, Ordering::Relaxed);
198            return Err(QuotaError::CpuExhausted);
199        }
200        Ok(())
201    }
202
203    pub fn alloc(&self, bytes: usize) -> Result<(), QuotaError> {
204        loop {
205            let cur = self.mem_used.load(Ordering::Relaxed);
206            let next = cur.saturating_add(bytes);
207            let limit = self.mem_limit.load(Ordering::Relaxed);
208            if next > limit {
209                return Err(QuotaError::MemoryExhausted {
210                    requested: bytes,
211                    limit,
212                });
213            }
214            if self
215                .mem_used
216                .compare_exchange_weak(cur, next, Ordering::Relaxed, Ordering::Relaxed)
217                .is_ok()
218            {
219                return Ok(());
220            }
221        }
222    }
223
224    pub fn free(&self, bytes: usize) {
225        loop {
226            let cur = self.mem_used.load(Ordering::Relaxed);
227            let next = cur.saturating_sub(bytes);
228            if self
229                .mem_used
230                .compare_exchange_weak(cur, next, Ordering::Relaxed, Ordering::Relaxed)
231                .is_ok()
232            {
233                return;
234            }
235        }
236    }
237
238    pub fn check_spawn(&self) -> Result<(), QuotaError> {
239        if self.spawn_bucket.try_consume() {
240            Ok(())
241        } else {
242            Err(QuotaError::SpawnRateExceeded)
243        }
244    }
245
246    pub fn check_send(&self) -> Result<(), QuotaError> {
247        if self.send_bucket.try_consume() {
248            Ok(())
249        } else {
250            Err(QuotaError::SendRateExceeded)
251        }
252    }
253
254    /// Reachable only after `check_admin` against `CapTarget::Scheduler`.
255    pub fn top_up_cpu(&self, extra: i64) {
256        self.cpu_budget.fetch_add(extra, Ordering::Relaxed);
257    }
258
259    pub fn top_up_mem(&self, extra: usize) {
260        self.mem_limit.fetch_add(extra, Ordering::Relaxed);
261    }
262
263    pub fn top_up_send(&self, extra: i64) {
264        self.send_bucket.credit(extra);
265    }
266
267    pub fn mem_used(&self) -> usize {
268        self.mem_used.load(Ordering::Relaxed)
269    }
270}
271
272/// Live-flow quota directory so ADMIN top-up can find a running flow.
273pub struct QuotaTable {
274    inner: Mutex<std::collections::HashMap<u64, Arc<FlowQuota>>>,
275}
276
277impl QuotaTable {
278    pub fn new() -> Self {
279        Self {
280            inner: Mutex::new(std::collections::HashMap::new()),
281        }
282    }
283
284    pub fn insert(&self, id: super::process::FlowId, quota: Arc<FlowQuota>) -> Result<(), RuntimeError> {
285        sync_lock::lock(&self.inner, "QuotaTable::insert")?.insert(id.as_u64(), quota);
286        Ok(())
287    }
288
289    pub fn get(&self, id: super::process::FlowId) -> Result<Option<Arc<FlowQuota>>, RuntimeError> {
290        Ok(sync_lock::lock(&self.inner, "QuotaTable::get")?
291            .get(&id.as_u64())
292            .cloned())
293    }
294
295    pub fn remove(&self, id: super::process::FlowId) -> Result<(), RuntimeError> {
296        sync_lock::lock(&self.inner, "QuotaTable::remove")?.remove(&id.as_u64());
297        Ok(())
298    }
299}
300
301impl Default for QuotaTable {
302    fn default() -> Self {
303        Self::new()
304    }
305}
306
307#[cfg(test)]
308mod tests {
309    use super::*;
310
311    #[test]
312    fn cpu_budget_fails_closed() {
313        let q = FlowQuota::new(10, 1024, 1, 1, 1, 1);
314        assert!(q.charge_cpu(7).is_ok());
315        assert!(q.charge_cpu(4).is_err());
316        assert!(q.charge_cpu(3).is_ok());
317    }
318
319    #[test]
320    fn memory_never_exceeds_limit() {
321        let q = FlowQuota::new(1000, 100, 1, 1, 1, 1);
322        assert!(q.alloc(60).is_ok());
323        assert!(q.alloc(60).is_err());
324        q.free(60);
325        assert!(q.alloc(60).is_ok());
326    }
327
328    #[test]
329    fn sandbox_is_tighter_than_permissive() {
330        let p = QuotaConfig::permissive();
331        let s = QuotaConfig::sandbox();
332        assert!(s.cpu_budget < p.cpu_budget);
333        assert!(s.mem_limit < p.mem_limit);
334        assert!(s.send_burst < p.send_burst);
335        assert_eq!(QuotaConfig::default().cpu_budget, p.cpu_budget);
336    }
337
338    #[test]
339    fn top_up_mem_raises_limit() {
340        let q = FlowQuota::new(1000, 100, 1, 1, 1, 1);
341        assert!(q.alloc(60).is_ok());
342        assert!(q.alloc(60).is_err());
343        q.top_up_mem(50);
344        assert!(q.alloc(60).is_ok());
345    }
346
347    #[test]
348    fn spawn_bucket_does_not_over_issue() {
349        let q = FlowQuota::new(1000, 100, 3, 0, 1, 1);
350        assert!(q.check_spawn().is_ok());
351        assert!(q.check_spawn().is_ok());
352        assert!(q.check_spawn().is_ok());
353        assert!(q.check_spawn().is_err());
354    }
355}