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 Default for QuotaConfig {
34    fn default() -> Self {
35        // Generous so existing samples and tests keep passing. Tighten via
36        // `RuntimeConfig::quota` for sandboxed modules.
37        Self {
38            cpu_budget: 1_000_000_000,
39            mem_limit: 64 * 1024 * 1024,
40            spawn_burst: 10_000,
41            spawn_per_sec: 10_000,
42            send_burst: 50_000,
43            send_per_sec: 50_000,
44        }
45    }
46}
47
48pub struct TokenBucket {
49    capacity: i64,
50    tokens: AtomicI64,
51    refill_per_sec: i64,
52    last_refill: Mutex<Instant>,
53}
54
55impl TokenBucket {
56    pub fn new(capacity: i64, refill_per_sec: i64) -> Self {
57        Self {
58            capacity,
59            tokens: AtomicI64::new(capacity),
60            refill_per_sec,
61            last_refill: Mutex::new(Instant::now()),
62        }
63    }
64
65    /// Refill or fail closed on lock poison (no tokens granted).
66    fn refill(&self) -> Result<(), RuntimeError> {
67        let mut last = sync_lock::lock(&self.last_refill, "TokenBucket::refill")?;
68        let elapsed = last.elapsed();
69        if elapsed >= Duration::from_millis(50) {
70            let add = (elapsed.as_secs_f64() * self.refill_per_sec as f64) as i64;
71            if add > 0 {
72                let cur = self.tokens.load(Ordering::Relaxed);
73                let new = (cur + add).min(self.capacity);
74                self.tokens.store(new, Ordering::Relaxed);
75                *last = Instant::now();
76            }
77        }
78        Ok(())
79    }
80
81    /// True and consume one token iff one was available.
82    pub fn try_consume(&self) -> bool {
83        if self.refill().is_err() {
84            return false;
85        }
86        loop {
87            let cur = self.tokens.load(Ordering::Relaxed);
88            if cur <= 0 {
89                return false;
90            }
91            if self
92                .tokens
93                .compare_exchange_weak(cur, cur - 1, Ordering::Relaxed, Ordering::Relaxed)
94                .is_ok()
95            {
96                return true;
97            }
98        }
99    }
100}
101
102#[derive(Debug, Clone, Copy, PartialEq, Eq)]
103pub enum QuotaError {
104    CpuExhausted,
105    MemoryExhausted { requested: usize, limit: usize },
106    SpawnRateExceeded,
107    SendRateExceeded,
108}
109
110impl std::fmt::Display for QuotaError {
111    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
112        match self {
113            QuotaError::CpuExhausted => f.write_str("flow CPU budget exhausted"),
114            QuotaError::MemoryExhausted { requested, limit } => {
115                write!(f, "flow memory exhausted (requested {requested}, limit {limit})")
116            }
117            QuotaError::SpawnRateExceeded => f.write_str("spawn rate exceeded"),
118            QuotaError::SendRateExceeded => f.write_str("send rate exceeded"),
119        }
120    }
121}
122
123impl std::error::Error for QuotaError {}
124
125pub struct FlowQuota {
126    cpu_budget: AtomicI64,
127    mem_limit: usize,
128    mem_used: AtomicUsize,
129    spawn_bucket: TokenBucket,
130    send_bucket: TokenBucket,
131}
132
133impl FlowQuota {
134    pub fn from_config(cfg: QuotaConfig) -> Self {
135        Self::new(
136            cfg.cpu_budget,
137            cfg.mem_limit,
138            cfg.spawn_burst,
139            cfg.spawn_per_sec,
140            cfg.send_burst,
141            cfg.send_per_sec,
142        )
143    }
144
145    pub fn new(
146        cpu_budget: i64,
147        mem_limit: usize,
148        spawn_burst: i64,
149        spawn_per_sec: i64,
150        send_burst: i64,
151        send_per_sec: i64,
152    ) -> Self {
153        Self {
154            cpu_budget: AtomicI64::new(cpu_budget),
155            mem_limit,
156            mem_used: AtomicUsize::new(0),
157            spawn_bucket: TokenBucket::new(spawn_burst, spawn_per_sec),
158            send_bucket: TokenBucket::new(send_burst, send_per_sec),
159        }
160    }
161
162    pub fn remaining_cpu(&self) -> i64 {
163        self.cpu_budget.load(Ordering::Relaxed).max(0)
164    }
165
166    /// Call once per executed instruction (or every N, in a batch — the
167    /// batch only thickens trap latency, it does not change the limit).
168    pub fn charge_cpu(&self, cost: i64) -> Result<(), QuotaError> {
169        let remaining = self.cpu_budget.fetch_sub(cost, Ordering::Relaxed) - cost;
170        if remaining < 0 {
171            self.cpu_budget.fetch_add(cost, Ordering::Relaxed);
172            return Err(QuotaError::CpuExhausted);
173        }
174        Ok(())
175    }
176
177    pub fn alloc(&self, bytes: usize) -> Result<(), QuotaError> {
178        loop {
179            let cur = self.mem_used.load(Ordering::Relaxed);
180            let next = cur.saturating_add(bytes);
181            if next > self.mem_limit {
182                return Err(QuotaError::MemoryExhausted {
183                    requested: bytes,
184                    limit: self.mem_limit,
185                });
186            }
187            if self
188                .mem_used
189                .compare_exchange_weak(cur, next, Ordering::Relaxed, Ordering::Relaxed)
190                .is_ok()
191            {
192                return Ok(());
193            }
194        }
195    }
196
197    pub fn free(&self, bytes: usize) {
198        loop {
199            let cur = self.mem_used.load(Ordering::Relaxed);
200            let next = cur.saturating_sub(bytes);
201            if self
202                .mem_used
203                .compare_exchange_weak(cur, next, Ordering::Relaxed, Ordering::Relaxed)
204                .is_ok()
205            {
206                return;
207            }
208        }
209    }
210
211    pub fn check_spawn(&self) -> Result<(), QuotaError> {
212        if self.spawn_bucket.try_consume() {
213            Ok(())
214        } else {
215            Err(QuotaError::SpawnRateExceeded)
216        }
217    }
218
219    pub fn check_send(&self) -> Result<(), QuotaError> {
220        if self.send_bucket.try_consume() {
221            Ok(())
222        } else {
223            Err(QuotaError::SendRateExceeded)
224        }
225    }
226
227    /// Reachable only after `check_admin` against `CapTarget::Scheduler`.
228    pub fn top_up_cpu(&self, extra: i64) {
229        self.cpu_budget.fetch_add(extra, Ordering::Relaxed);
230    }
231
232    pub fn mem_used(&self) -> usize {
233        self.mem_used.load(Ordering::Relaxed)
234    }
235}
236
237/// Live-flow quota directory so ADMIN top-up can find a running flow.
238pub struct QuotaTable {
239    inner: Mutex<std::collections::HashMap<u64, Arc<FlowQuota>>>,
240}
241
242impl QuotaTable {
243    pub fn new() -> Self {
244        Self {
245            inner: Mutex::new(std::collections::HashMap::new()),
246        }
247    }
248
249    pub fn insert(&self, id: super::process::FlowId, quota: Arc<FlowQuota>) -> Result<(), RuntimeError> {
250        sync_lock::lock(&self.inner, "QuotaTable::insert")?.insert(id.as_u64(), quota);
251        Ok(())
252    }
253
254    pub fn get(&self, id: super::process::FlowId) -> Result<Option<Arc<FlowQuota>>, RuntimeError> {
255        Ok(sync_lock::lock(&self.inner, "QuotaTable::get")?
256            .get(&id.as_u64())
257            .cloned())
258    }
259
260    pub fn remove(&self, id: super::process::FlowId) -> Result<(), RuntimeError> {
261        sync_lock::lock(&self.inner, "QuotaTable::remove")?.remove(&id.as_u64());
262        Ok(())
263    }
264}
265
266impl Default for QuotaTable {
267    fn default() -> Self {
268        Self::new()
269    }
270}
271
272#[cfg(test)]
273mod tests {
274    use super::*;
275
276    #[test]
277    fn cpu_budget_fails_closed() {
278        let q = FlowQuota::new(10, 1024, 1, 1, 1, 1);
279        assert!(q.charge_cpu(7).is_ok());
280        assert!(q.charge_cpu(4).is_err());
281        assert!(q.charge_cpu(3).is_ok());
282    }
283
284    #[test]
285    fn memory_never_exceeds_limit() {
286        let q = FlowQuota::new(1000, 100, 1, 1, 1, 1);
287        assert!(q.alloc(60).is_ok());
288        assert!(q.alloc(60).is_err());
289        q.free(60);
290        assert!(q.alloc(60).is_ok());
291    }
292
293    #[test]
294    fn spawn_bucket_does_not_over_issue() {
295        let q = FlowQuota::new(1000, 100, 3, 0, 1, 1);
296        assert!(q.check_spawn().is_ok());
297        assert!(q.check_spawn().is_ok());
298        assert!(q.check_spawn().is_ok());
299        assert!(q.check_spawn().is_err());
300    }
301}