Skip to main content

ipfrs_storage/
quota_manager.rs

1//! Storage Quota Manager — per-namespace quota tracking and enforcement
2//!
3//! Tracks per-namespace storage quotas and enforces limits before write operations.
4//! Supports hard limits (reject), soft limits (warn), and unlimited namespaces.
5
6use std::collections::HashMap;
7use std::sync::atomic::{AtomicU64, Ordering};
8use std::sync::{Arc, RwLock};
9
10use thiserror::Error;
11use tracing::warn;
12
13// ---------------------------------------------------------------------------
14// Errors
15// ---------------------------------------------------------------------------
16
17/// Errors that can occur during quota operations.
18#[derive(Debug, Error)]
19pub enum QuotaError {
20    /// The requested write would exceed the hard limit for a namespace.
21    #[error("quota exceeded for namespace '{namespace}': used {used} bytes, limit {limit} bytes")]
22    QuotaExceeded {
23        namespace: String,
24        used: u64,
25        limit: u64,
26    },
27
28    /// No quota entry exists for the given namespace.
29    #[error("namespace not found: '{0}'")]
30    NamespaceNotFound(String),
31
32    /// A quota limit of zero is invalid (must be > 0).
33    #[error("invalid limit: limit must be greater than 0")]
34    InvalidLimit,
35}
36
37// ---------------------------------------------------------------------------
38// QuotaPolicy
39// ---------------------------------------------------------------------------
40
41/// Enforcement policy applied to a namespace quota.
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub enum QuotaPolicy {
44    /// Reject writes that would cause the namespace to exceed its limit.
45    HardLimit,
46    /// Allow writes that would exceed the limit, but emit a tracing warning.
47    SoftLimit,
48    /// No enforcement — writes are always allowed regardless of usage.
49    NoLimit,
50}
51
52impl QuotaPolicy {
53    /// Returns a human-readable string representation used in [`QuotaStats`].
54    pub fn as_str(&self) -> &'static str {
55        match self {
56            QuotaPolicy::HardLimit => "hard",
57            QuotaPolicy::SoftLimit => "soft",
58            QuotaPolicy::NoLimit => "none",
59        }
60    }
61}
62
63// ---------------------------------------------------------------------------
64// NamespaceQuota
65// ---------------------------------------------------------------------------
66
67/// Quota state for a single namespace.
68pub struct NamespaceQuota {
69    /// Identifier for this namespace.
70    pub namespace: String,
71    /// Maximum bytes allowed (only meaningful for HardLimit / SoftLimit).
72    pub limit_bytes: u64,
73    /// Currently consumed bytes (atomic, shared across threads).
74    pub used_bytes: Arc<AtomicU64>,
75    /// Enforcement policy.
76    pub policy: QuotaPolicy,
77    /// Number of stored blocks (atomic, shared across threads).
78    pub block_count: Arc<AtomicU64>,
79}
80
81impl NamespaceQuota {
82    /// Create a new `NamespaceQuota`.
83    pub fn new(namespace: String, limit_bytes: u64, policy: QuotaPolicy) -> Self {
84        Self {
85            namespace,
86            limit_bytes,
87            used_bytes: Arc::new(AtomicU64::new(0)),
88            policy,
89            block_count: Arc::new(AtomicU64::new(0)),
90        }
91    }
92
93    /// Returns how many bytes are still available before the limit is reached.
94    ///
95    /// Uses saturating subtraction, so returns `0` when already over limit.
96    pub fn available_bytes(&self) -> u64 {
97        self.limit_bytes
98            .saturating_sub(self.used_bytes.load(Ordering::Relaxed))
99    }
100
101    /// Returns the fraction of the limit currently used (range `[0.0, ∞)`).
102    ///
103    /// Returns `0.0` when `limit_bytes` is `0` to avoid division by zero.
104    pub fn usage_ratio(&self) -> f64 {
105        if self.limit_bytes == 0 {
106            return 0.0;
107        }
108        self.used_bytes.load(Ordering::Relaxed) as f64 / self.limit_bytes as f64
109    }
110
111    /// Returns `true` when `usage_ratio()` exceeds `threshold`.
112    pub fn is_over_soft_threshold(&self, threshold: f64) -> bool {
113        self.usage_ratio() > threshold
114    }
115}
116
117// ---------------------------------------------------------------------------
118// QuotaStats  (snapshot — no atomics)
119// ---------------------------------------------------------------------------
120
121/// A plain-data snapshot of quota state for a namespace.
122#[derive(Debug, Clone)]
123pub struct QuotaStats {
124    /// Namespace identifier.
125    pub namespace: String,
126    /// Maximum bytes allowed.
127    pub limit_bytes: u64,
128    /// Currently consumed bytes.
129    pub used_bytes: u64,
130    /// Bytes remaining before the limit.
131    pub available_bytes: u64,
132    /// Number of stored blocks.
133    pub block_count: u64,
134    /// Usage as a percentage (`used_bytes / limit_bytes * 100`).
135    pub usage_pct: f64,
136    /// Policy string: `"hard"`, `"soft"`, or `"none"`.
137    pub policy: String,
138}
139
140impl QuotaStats {
141    fn from_quota(q: &NamespaceQuota) -> Self {
142        let used = q.used_bytes.load(Ordering::Relaxed);
143        let limit = q.limit_bytes;
144        let available = limit.saturating_sub(used);
145        let usage_pct = if limit == 0 {
146            0.0
147        } else {
148            used as f64 / limit as f64 * 100.0
149        };
150        Self {
151            namespace: q.namespace.clone(),
152            limit_bytes: limit,
153            used_bytes: used,
154            available_bytes: available,
155            block_count: q.block_count.load(Ordering::Relaxed),
156            usage_pct,
157            policy: q.policy.as_str().to_owned(),
158        }
159    }
160}
161
162// ---------------------------------------------------------------------------
163// StorageQuotaManager
164// ---------------------------------------------------------------------------
165
166/// Thread-safe manager that tracks per-namespace storage quotas.
167///
168/// # Example
169/// ```rust
170/// use ipfrs_storage::quota_manager::{StorageQuotaManager, QuotaPolicy};
171///
172/// let mgr = StorageQuotaManager::new(0.8);
173/// mgr.register_namespace("ns1".to_owned(), 1024, QuotaPolicy::HardLimit).unwrap();
174/// mgr.check_write("ns1", 512).unwrap();
175/// mgr.record_write("ns1", 512).unwrap();
176/// let stats = mgr.stats("ns1").unwrap();
177/// assert_eq!(stats.used_bytes, 512);
178/// ```
179pub struct StorageQuotaManager {
180    quotas: RwLock<HashMap<String, NamespaceQuota>>,
181    soft_threshold: f64,
182}
183
184impl StorageQuotaManager {
185    /// Create a new `StorageQuotaManager`.
186    ///
187    /// `soft_threshold` is a ratio in `[0.0, 1.0]` — namespaces whose usage
188    /// exceeds this fraction of their limit are considered "over threshold".
189    /// The recommended default is `0.8` (80 %).
190    pub fn new(soft_threshold: f64) -> Self {
191        Self {
192            quotas: RwLock::new(HashMap::new()),
193            soft_threshold,
194        }
195    }
196
197    /// Register a new namespace with the given limit and policy.
198    ///
199    /// Returns [`QuotaError::InvalidLimit`] if `limit_bytes` is `0` and
200    /// the policy is not [`QuotaPolicy::NoLimit`].
201    pub fn register_namespace(
202        &self,
203        namespace: String,
204        limit_bytes: u64,
205        policy: QuotaPolicy,
206    ) -> Result<(), QuotaError> {
207        if limit_bytes == 0 && policy != QuotaPolicy::NoLimit {
208            return Err(QuotaError::InvalidLimit);
209        }
210        let quota = NamespaceQuota::new(namespace.clone(), limit_bytes, policy);
211        let mut map = self.quotas.write().unwrap_or_else(|e| e.into_inner());
212        map.insert(namespace, quota);
213        Ok(())
214    }
215
216    /// Check whether a write of `size_bytes` is permitted for `namespace`.
217    ///
218    /// - [`QuotaPolicy::HardLimit`]: returns `Err(QuotaExceeded)` when
219    ///   `used + size > limit`.
220    /// - [`QuotaPolicy::SoftLimit`]: logs a warning when the limit would be
221    ///   exceeded, but still returns `Ok(())`.
222    /// - [`QuotaPolicy::NoLimit`]: always returns `Ok(())`.
223    ///
224    /// Returns [`QuotaError::NamespaceNotFound`] for unknown namespaces.
225    pub fn check_write(&self, namespace: &str, size_bytes: u64) -> Result<(), QuotaError> {
226        let map = self.quotas.read().unwrap_or_else(|e| e.into_inner());
227        let quota = map
228            .get(namespace)
229            .ok_or_else(|| QuotaError::NamespaceNotFound(namespace.to_owned()))?;
230
231        match quota.policy {
232            QuotaPolicy::NoLimit => Ok(()),
233            QuotaPolicy::HardLimit => {
234                let used = quota.used_bytes.load(Ordering::Relaxed);
235                let projected = used.saturating_add(size_bytes);
236                if projected > quota.limit_bytes {
237                    Err(QuotaError::QuotaExceeded {
238                        namespace: namespace.to_owned(),
239                        used,
240                        limit: quota.limit_bytes,
241                    })
242                } else {
243                    Ok(())
244                }
245            }
246            QuotaPolicy::SoftLimit => {
247                let used = quota.used_bytes.load(Ordering::Relaxed);
248                let projected = used.saturating_add(size_bytes);
249                if projected > quota.limit_bytes {
250                    warn!(
251                        namespace = %namespace,
252                        used = used,
253                        size = size_bytes,
254                        limit = quota.limit_bytes,
255                        "soft quota exceeded — write allowed but usage is over limit"
256                    );
257                }
258                Ok(())
259            }
260        }
261    }
262
263    /// Record that `size_bytes` have been successfully written to `namespace`.
264    ///
265    /// Atomically increments `used_bytes` and `block_count`.
266    pub fn record_write(&self, namespace: &str, size_bytes: u64) -> Result<(), QuotaError> {
267        let map = self.quotas.read().unwrap_or_else(|e| e.into_inner());
268        let quota = map
269            .get(namespace)
270            .ok_or_else(|| QuotaError::NamespaceNotFound(namespace.to_owned()))?;
271        quota.used_bytes.fetch_add(size_bytes, Ordering::Relaxed);
272        quota.block_count.fetch_add(1, Ordering::Relaxed);
273        Ok(())
274    }
275
276    /// Record that `size_bytes` have been deleted from `namespace`.
277    ///
278    /// Uses saturating subtraction so that underflow is safe.
279    /// `block_count` is also decremented using saturating subtraction.
280    pub fn record_delete(&self, namespace: &str, size_bytes: u64) {
281        let map = self.quotas.read().unwrap_or_else(|e| e.into_inner());
282        if let Some(quota) = map.get(namespace) {
283            // Saturating subtract for used_bytes
284            let prev =
285                quota
286                    .used_bytes
287                    .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |current| {
288                        Some(current.saturating_sub(size_bytes))
289                    });
290            // fetch_update only errors if closure returns None, which ours never does.
291            let _ = prev;
292
293            // Saturating subtract for block_count
294            let _ =
295                quota
296                    .block_count
297                    .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |current| {
298                        Some(current.saturating_sub(1))
299                    });
300        }
301    }
302
303    /// Return a snapshot of quota statistics for `namespace`.
304    pub fn stats(&self, namespace: &str) -> Result<QuotaStats, QuotaError> {
305        let map = self.quotas.read().unwrap_or_else(|e| e.into_inner());
306        map.get(namespace)
307            .map(QuotaStats::from_quota)
308            .ok_or_else(|| QuotaError::NamespaceNotFound(namespace.to_owned()))
309    }
310
311    /// Return snapshots for all registered namespaces, sorted by namespace name.
312    pub fn all_stats(&self) -> Vec<QuotaStats> {
313        let map = self.quotas.read().unwrap_or_else(|e| e.into_inner());
314        let mut stats: Vec<QuotaStats> = map.values().map(QuotaStats::from_quota).collect();
315        stats.sort_by(|a, b| a.namespace.cmp(&b.namespace));
316        stats
317    }
318
319    /// Return namespaces whose usage ratio exceeds `self.soft_threshold`.
320    pub fn namespaces_over_threshold(&self) -> Vec<String> {
321        let map = self.quotas.read().unwrap_or_else(|e| e.into_inner());
322        let mut over: Vec<String> = map
323            .values()
324            .filter(|q| q.is_over_soft_threshold(self.soft_threshold))
325            .map(|q| q.namespace.clone())
326            .collect();
327        over.sort();
328        over
329    }
330
331    /// Zero out `used_bytes` and `block_count` for `namespace`.
332    pub fn reset_namespace(&self, namespace: &str) -> Result<(), QuotaError> {
333        let map = self.quotas.read().unwrap_or_else(|e| e.into_inner());
334        let quota = map
335            .get(namespace)
336            .ok_or_else(|| QuotaError::NamespaceNotFound(namespace.to_owned()))?;
337        quota.used_bytes.store(0, Ordering::Relaxed);
338        quota.block_count.store(0, Ordering::Relaxed);
339        Ok(())
340    }
341
342    /// Remove `namespace` from the manager.
343    ///
344    /// Returns `true` if the namespace existed, `false` otherwise.
345    pub fn remove_namespace(&self, namespace: &str) -> bool {
346        let mut map = self.quotas.write().unwrap_or_else(|e| e.into_inner());
347        map.remove(namespace).is_some()
348    }
349}
350
351// ---------------------------------------------------------------------------
352// Tests
353// ---------------------------------------------------------------------------
354
355#[cfg(test)]
356mod tests {
357    use super::*;
358
359    fn make_mgr() -> StorageQuotaManager {
360        StorageQuotaManager::new(0.8)
361    }
362
363    // 1. new() with custom soft_threshold
364    #[test]
365    fn test_new_custom_soft_threshold() {
366        let mgr = StorageQuotaManager::new(0.5);
367        assert_eq!(mgr.soft_threshold, 0.5);
368    }
369
370    // 2. register_namespace with HardLimit
371    #[test]
372    fn test_register_hard_limit() {
373        let mgr = make_mgr();
374        mgr.register_namespace("ns".to_owned(), 1024, QuotaPolicy::HardLimit)
375            .expect("should register");
376        let stats = mgr.stats("ns").expect("should have stats");
377        assert_eq!(stats.limit_bytes, 1024);
378        assert_eq!(stats.policy, "hard");
379    }
380
381    // 3. register_namespace with SoftLimit
382    #[test]
383    fn test_register_soft_limit() {
384        let mgr = make_mgr();
385        mgr.register_namespace("ns".to_owned(), 2048, QuotaPolicy::SoftLimit)
386            .expect("should register");
387        let stats = mgr.stats("ns").expect("should have stats");
388        assert_eq!(stats.policy, "soft");
389    }
390
391    // 3b. register_namespace with NoLimit
392    #[test]
393    fn test_register_no_limit() {
394        let mgr = make_mgr();
395        mgr.register_namespace("ns".to_owned(), 0, QuotaPolicy::NoLimit)
396            .expect("NoLimit with 0 bytes should succeed");
397        let stats = mgr.stats("ns").expect("should have stats");
398        assert_eq!(stats.policy, "none");
399    }
400
401    // 4. check_write under limit returns Ok
402    #[test]
403    fn test_check_write_under_limit_ok() {
404        let mgr = make_mgr();
405        mgr.register_namespace("ns".to_owned(), 1000, QuotaPolicy::HardLimit)
406            .unwrap();
407        mgr.check_write("ns", 500).expect("should be under limit");
408    }
409
410    // 5. check_write over limit HardLimit returns QuotaExceeded
411    #[test]
412    fn test_check_write_over_hard_limit_err() {
413        let mgr = make_mgr();
414        mgr.register_namespace("ns".to_owned(), 100, QuotaPolicy::HardLimit)
415            .unwrap();
416        mgr.record_write("ns", 90).unwrap(); // used = 90
417        let err = mgr
418            .check_write("ns", 20)
419            .expect_err("should exceed hard limit");
420        matches!(err, QuotaError::QuotaExceeded { .. });
421    }
422
423    // 6. check_write over limit SoftLimit returns Ok
424    #[test]
425    fn test_check_write_over_soft_limit_ok() {
426        let mgr = make_mgr();
427        mgr.register_namespace("ns".to_owned(), 100, QuotaPolicy::SoftLimit)
428            .unwrap();
429        mgr.record_write("ns", 90).unwrap(); // used = 90
430        mgr.check_write("ns", 50)
431            .expect("SoftLimit should return Ok even over limit");
432    }
433
434    // 7. check_write NoLimit always Ok
435    #[test]
436    fn test_check_write_no_limit_always_ok() {
437        let mgr = make_mgr();
438        mgr.register_namespace("ns".to_owned(), 0, QuotaPolicy::NoLimit)
439            .unwrap();
440        mgr.check_write("ns", u64::MAX)
441            .expect("NoLimit should always be Ok");
442    }
443
444    // 8. check_write unknown namespace returns NamespaceNotFound
445    #[test]
446    fn test_check_write_unknown_namespace() {
447        let mgr = make_mgr();
448        let err = mgr
449            .check_write("missing", 100)
450            .expect_err("unknown namespace should error");
451        matches!(err, QuotaError::NamespaceNotFound(_));
452    }
453
454    // 9. record_write increments used_bytes and block_count
455    #[test]
456    fn test_record_write_increments() {
457        let mgr = make_mgr();
458        mgr.register_namespace("ns".to_owned(), 10000, QuotaPolicy::HardLimit)
459            .unwrap();
460        mgr.record_write("ns", 300).unwrap();
461        mgr.record_write("ns", 200).unwrap();
462        let stats = mgr.stats("ns").unwrap();
463        assert_eq!(stats.used_bytes, 500);
464        assert_eq!(stats.block_count, 2);
465    }
466
467    // 10. record_delete decrements used_bytes and block_count
468    #[test]
469    fn test_record_delete_decrements() {
470        let mgr = make_mgr();
471        mgr.register_namespace("ns".to_owned(), 10000, QuotaPolicy::HardLimit)
472            .unwrap();
473        mgr.record_write("ns", 1000).unwrap();
474        mgr.record_write("ns", 500).unwrap();
475        mgr.record_delete("ns", 300);
476        let stats = mgr.stats("ns").unwrap();
477        assert_eq!(stats.used_bytes, 1200);
478        assert_eq!(stats.block_count, 1);
479    }
480
481    // 11. record_delete underflow is safe (saturating)
482    #[test]
483    fn test_record_delete_underflow_safe() {
484        let mgr = make_mgr();
485        mgr.register_namespace("ns".to_owned(), 10000, QuotaPolicy::HardLimit)
486            .unwrap();
487        // Delete more than recorded — should not panic, should saturate at 0
488        mgr.record_delete("ns", 999_999);
489        let stats = mgr.stats("ns").unwrap();
490        assert_eq!(stats.used_bytes, 0);
491        assert_eq!(stats.block_count, 0);
492    }
493
494    // 12. stats() returns correct snapshot
495    #[test]
496    fn test_stats_correct_snapshot() {
497        let mgr = make_mgr();
498        mgr.register_namespace("ns".to_owned(), 1000, QuotaPolicy::SoftLimit)
499            .unwrap();
500        mgr.record_write("ns", 400).unwrap();
501        let s = mgr.stats("ns").unwrap();
502        assert_eq!(s.namespace, "ns");
503        assert_eq!(s.limit_bytes, 1000);
504        assert_eq!(s.used_bytes, 400);
505        assert_eq!(s.available_bytes, 600);
506        assert_eq!(s.block_count, 1);
507        assert!((s.usage_pct - 40.0).abs() < 1e-9);
508        assert_eq!(s.policy, "soft");
509    }
510
511    // 13. all_stats() returns sorted list
512    #[test]
513    fn test_all_stats_sorted() {
514        let mgr = make_mgr();
515        mgr.register_namespace("zebra".to_owned(), 500, QuotaPolicy::HardLimit)
516            .unwrap();
517        mgr.register_namespace("alpha".to_owned(), 500, QuotaPolicy::HardLimit)
518            .unwrap();
519        mgr.register_namespace("mango".to_owned(), 500, QuotaPolicy::HardLimit)
520            .unwrap();
521        let all = mgr.all_stats();
522        assert_eq!(all.len(), 3);
523        assert_eq!(all[0].namespace, "alpha");
524        assert_eq!(all[1].namespace, "mango");
525        assert_eq!(all[2].namespace, "zebra");
526    }
527
528    // 14. namespaces_over_threshold filtered correctly
529    #[test]
530    fn test_namespaces_over_threshold() {
531        let mgr = StorageQuotaManager::new(0.8);
532        mgr.register_namespace("low".to_owned(), 1000, QuotaPolicy::HardLimit)
533            .unwrap();
534        mgr.register_namespace("high".to_owned(), 1000, QuotaPolicy::SoftLimit)
535            .unwrap();
536        // Push "high" above 80 %
537        mgr.record_write("high", 900).unwrap();
538        // Keep "low" well under
539        mgr.record_write("low", 100).unwrap();
540        let over = mgr.namespaces_over_threshold();
541        assert_eq!(over, vec!["high".to_owned()]);
542    }
543
544    // 15. reset_namespace zeroes counters
545    #[test]
546    fn test_reset_namespace() {
547        let mgr = make_mgr();
548        mgr.register_namespace("ns".to_owned(), 10000, QuotaPolicy::HardLimit)
549            .unwrap();
550        mgr.record_write("ns", 5000).unwrap();
551        mgr.reset_namespace("ns").unwrap();
552        let stats = mgr.stats("ns").unwrap();
553        assert_eq!(stats.used_bytes, 0);
554        assert_eq!(stats.block_count, 0);
555    }
556
557    // 16. remove_namespace removes entry
558    #[test]
559    fn test_remove_namespace() {
560        let mgr = make_mgr();
561        mgr.register_namespace("ns".to_owned(), 1000, QuotaPolicy::HardLimit)
562            .unwrap();
563        assert!(mgr.remove_namespace("ns"));
564        assert!(!mgr.remove_namespace("ns")); // second call returns false
565        let err = mgr.stats("ns").expect_err("should be gone");
566        matches!(err, QuotaError::NamespaceNotFound(_));
567    }
568
569    // 17. InvalidLimit for limit=0 with HardLimit
570    #[test]
571    fn test_invalid_limit_zero() {
572        let mgr = make_mgr();
573        let err = mgr
574            .register_namespace("ns".to_owned(), 0, QuotaPolicy::HardLimit)
575            .expect_err("limit=0 with HardLimit should fail");
576        matches!(err, QuotaError::InvalidLimit);
577    }
578
579    // 17b. InvalidLimit for limit=0 with SoftLimit
580    #[test]
581    fn test_invalid_limit_zero_soft() {
582        let mgr = make_mgr();
583        let err = mgr
584            .register_namespace("ns".to_owned(), 0, QuotaPolicy::SoftLimit)
585            .expect_err("limit=0 with SoftLimit should fail");
586        matches!(err, QuotaError::InvalidLimit);
587    }
588}