//! Byte/request budgets for AUTOMATIC chat-attachment fetches (Phase 3B). //! //! The four-permit semaphore bounds how many auto-fetch tasks run at once, but //! not how much a peer can make us download over time: with permits released //! after each transfer, an insider could stream distinct ≤4 MiB images //! sequentially forever. This budget adds per-author and session (room-wide) //! token buckets over both request COUNT and declared BYTES. Like the Phase 2 //! chat gate, time is passed in — never read from a clock — so every refill //! boundary is unit-testable. //! //! Only the automatic path consults this; a user's explicit click (Save / //! Download / Load image) is human-rate-limited and always allowed through to //! the fetch (still subject to the transfer cap and cache/decoder budgets). use iroh::EndpointId; use std::collections::HashMap; /// Per-author request burst: how many auto-fetches one author can trigger /// back-to-back before refill pacing binds. pub const AUTHOR_REQ_BURST: f64 = 8.0; /// Per-author request refill: one recovered every 10 s. pub const AUTHOR_REQ_REFILL_PER_MS: f64 = 1.0 / 10_000.0; /// Per-author byte burst (declared sizes): a couple of full-size auto images /// plus a normal working set. pub const AUTHOR_BYTES_BURST: f64 = (16 * 1024 * 1024) as f64; /// Per-author byte refill: 64 KiB/s (~one 4 MiB auto image per minute). pub const AUTHOR_BYTES_REFILL_PER_MS: f64 = (64 * 1024) as f64 / 1000.0; /// Session-wide request burst across all authors. pub const SESSION_REQ_BURST: f64 = 16.0; /// Session-wide request refill: one recovered every 5 s. pub const SESSION_REQ_REFILL_PER_MS: f64 = 1.0 / 5_000.0; /// Session-wide byte burst across all authors. pub const SESSION_BYTES_BURST: f64 = (48 * 1024 * 1024) as f64; /// Session-wide byte refill: 128 KiB/s. pub const SESSION_BYTES_REFILL_PER_MS: f64 = (128 * 1024) as f64 / 1000.0; /// Bound on the per-author bucket map. Authors are roster members (≤32 live), /// so this tracks the roster plus recently departed; the least-recently-active /// entry is pruned past the cap. pub const AUTHOR_MAP_CAP: usize = 64; /// A deterministic token bucket that can take a WEIGHTED cost (bytes), unlike /// the unit-cost bucket in the gossip chat gate. #[derive(Debug, Clone, Copy)] struct WeightedBucket { tokens: f64, last_ms: u64, } impl WeightedBucket { fn full(burst: f64, now_ms: u64) -> Self { Self { tokens: burst, last_ms: now_ms, } } /// Refill for elapsed time (capped at `burst`) without consuming. fn refill(&mut self, burst: f64, refill_per_ms: f64, now_ms: u64) { let elapsed = now_ms.saturating_sub(self.last_ms) as f64; self.tokens = (self.tokens + elapsed * refill_per_ms).min(burst); self.last_ms = now_ms; } fn has(&self, cost: f64) -> bool { self.tokens >= cost } fn take(&mut self, cost: f64) { self.tokens -= cost; } } /// One author's pair of buckets plus last activity (for idle pruning). #[derive(Debug)] struct AuthorBudget { reqs: WeightedBucket, bytes: WeightedBucket, last_seen_ms: u64, } /// Admission budget for automatic attachment fetches. All four buckets are /// checked BEFORE any is consumed, so a rejection never burns tokens (no /// refund bookkeeping — the check-then-take is atomic within `admit`). #[derive(Debug)] pub struct AutoFetchBudget { session_reqs: WeightedBucket, session_bytes: WeightedBucket, authors: HashMap, } impl AutoFetchBudget { pub fn new(now_ms: u64) -> Self { Self { session_reqs: WeightedBucket::full(SESSION_REQ_BURST, now_ms), session_bytes: WeightedBucket::full(SESSION_BYTES_BURST, now_ms), authors: HashMap::new(), } } /// Whether an auto-fetch of `size` declared bytes for `author` may start /// now. Consumes one request token and `size` byte tokens from BOTH the /// author's and the session's buckets — or nothing at all on rejection. pub fn admit(&mut self, author: EndpointId, size: u64, now_ms: u64) -> bool { self.prune(author, now_ms); let entry = self.authors.entry(author).or_insert_with(|| AuthorBudget { reqs: WeightedBucket::full(AUTHOR_REQ_BURST, now_ms), bytes: WeightedBucket::full(AUTHOR_BYTES_BURST, now_ms), last_seen_ms: now_ms, }); entry.last_seen_ms = now_ms; entry .reqs .refill(AUTHOR_REQ_BURST, AUTHOR_REQ_REFILL_PER_MS, now_ms); entry .bytes .refill(AUTHOR_BYTES_BURST, AUTHOR_BYTES_REFILL_PER_MS, now_ms); self.session_reqs .refill(SESSION_REQ_BURST, SESSION_REQ_REFILL_PER_MS, now_ms); self.session_bytes .refill(SESSION_BYTES_BURST, SESSION_BYTES_REFILL_PER_MS, now_ms); let cost = size as f64; let ok = entry.reqs.has(1.0) && entry.bytes.has(cost) && self.session_reqs.has(1.0) && self.session_bytes.has(cost); if ok { let entry = self.authors.get_mut(&author).expect("just inserted"); entry.reqs.take(1.0); entry.bytes.take(cost); self.session_reqs.take(1.0); self.session_bytes.take(cost); } ok } /// Keep the author map bounded: past the cap, drop the least-recently /// active entry that isn't the author being admitted. A pruned author /// returns with full buckets, but authors are roster-gated upstream, so /// the map can't be churned by strangers. fn prune(&mut self, keep: EndpointId, _now_ms: u64) { while self.authors.len() >= AUTHOR_MAP_CAP { let Some(victim) = self .authors .iter() .filter(|(id, _)| **id != keep) .min_by_key(|(_, b)| b.last_seen_ms) .map(|(id, _)| *id) else { break; }; self.authors.remove(&victim); } } #[cfg(test)] fn author_count(&self) -> usize { self.authors.len() } } #[cfg(test)] mod tests { use super::*; use iroh::SecretKey; const T0: u64 = 1_000_000; const MIB: u64 = 1024 * 1024; fn author() -> EndpointId { SecretKey::generate().public() } #[test] fn author_request_burst_then_refill_recovers() { let mut b = AutoFetchBudget::new(T0); let a = author(); // Tiny sizes so only the REQUEST buckets can bind. for _ in 0..AUTHOR_REQ_BURST as usize { assert!(b.admit(a, 1, T0)); } assert!(!b.admit(a, 1, T0), "author request burst exhausted"); // One request refills after 10 s. assert!(b.admit(a, 1, T0 + 10_000)); assert!(!b.admit(a, 1, T0 + 10_000)); } #[test] fn author_byte_budget_binds_and_recovers() { let mut b = AutoFetchBudget::new(T0); let a = author(); // 4 × 4 MiB = the full 16 MiB author byte burst (well under the // 8-request burst, so bytes are the binding constraint). for _ in 0..4 { assert!(b.admit(a, 4 * MIB, T0)); } assert!(!b.admit(a, 4 * MIB, T0), "author byte burst exhausted"); // 64 KiB/s → a 4 MiB image is affordable again after 64 s (which also // refills 6 request tokens, so bytes stay the binding constraint). assert!(!b.admit(a, 4 * MIB, T0 + 32_000)); assert!(b.admit(a, 4 * MIB, T0 + 64_000)); } #[test] fn session_budget_binds_across_authors_without_burning_author_tokens() { let mut b = AutoFetchBudget::new(T0); // Three authors × 16 MiB exhausts the 48 MiB session byte burst even // though each author is within their own budget. for _ in 0..3 { let a = author(); for _ in 0..4 { assert!(b.admit(a, 4 * MIB, T0)); } } let fresh = author(); assert!(!b.admit(fresh, 4 * MIB, T0), "session bytes exhausted"); // The rejection consumed NOTHING: once the session refills enough for // one image (4 MiB / 128 KiB/s = 32 s), the fresh author's own full // burst is intact and admits immediately. assert!(b.admit(fresh, 4 * MIB, T0 + 32_000)); } #[test] fn session_request_bucket_binds_across_authors() { let mut b = AutoFetchBudget::new(T0); // 16 tiny requests from distinct authors exhaust the session request // burst while every author bucket stays nearly full. for _ in 0..SESSION_REQ_BURST as usize { assert!(b.admit(author(), 1, T0)); } assert!(!b.admit(author(), 1, T0), "session requests exhausted"); assert!(b.admit(author(), 1, T0 + 5_000), "one recovers after 5 s"); } #[test] fn author_map_stays_bounded_pruning_least_recent() { let mut b = AutoFetchBudget::new(T0); // Session request refill would bind over a naive loop; space the // admissions out so only the map bound is under test. let mut t = T0; let first = author(); assert!(b.admit(first, 1, t)); for _ in 0..(AUTHOR_MAP_CAP + 10) { t += 10_000; assert!(b.admit(author(), 1, t)); assert!(b.author_count() <= AUTHOR_MAP_CAP); } assert!(b.author_count() <= AUTHOR_MAP_CAP); } }