Skip to main content

firecloud_storage/
quota.rs

1//! Storage quota management
2//!
3//! Tracks and enforces storage limits:
4//! - Local storage usage tracking
5//! - Quota enforcement
6//! - Storage statistics
7
8use crate::{StorageError, StorageResult};
9use serde::{Deserialize, Serialize};
10use sled::Db;
11use std::path::Path;
12use tracing::{debug, info, warn};
13
14const TREE_QUOTA: &str = "quota";
15const KEY_TOTAL_BYTES: &[u8] = b"total_bytes";
16const KEY_TOTAL_CHUNKS: &[u8] = b"total_chunks";
17const KEY_QUOTA_LIMIT: &[u8] = b"quota_limit";
18
19/// Storage quota and usage statistics
20#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct StorageQuota {
22    /// Total bytes currently stored
23    pub total_bytes: u64,
24    /// Total number of chunks stored
25    pub total_chunks: u64,
26    /// Maximum allowed storage in bytes (0 = unlimited)
27    pub quota_limit: u64,
28}
29
30impl StorageQuota {
31    /// Check if quota is available for storing additional bytes
32    pub fn has_space(&self, bytes: u64) -> bool {
33        if self.quota_limit == 0 {
34            return true; // Unlimited
35        }
36        self.total_bytes + bytes <= self.quota_limit
37    }
38
39    /// Get remaining space in bytes
40    pub fn remaining_bytes(&self) -> u64 {
41        if self.quota_limit == 0 {
42            return u64::MAX; // Unlimited
43        }
44        self.quota_limit.saturating_sub(self.total_bytes)
45    }
46
47    /// Get usage percentage (0-100)
48    pub fn usage_percentage(&self) -> f64 {
49        if self.quota_limit == 0 {
50            return 0.0; // Unlimited
51        }
52        (self.total_bytes as f64 / self.quota_limit as f64) * 100.0
53    }
54
55    /// Check if quota is nearly full (>90%)
56    pub fn is_nearly_full(&self) -> bool {
57        self.usage_percentage() > 90.0
58    }
59
60    /// Check if quota is full
61    pub fn is_full(&self) -> bool {
62        if self.quota_limit == 0 {
63            return false; // Unlimited
64        }
65        self.total_bytes >= self.quota_limit
66    }
67}
68
69impl Default for StorageQuota {
70    fn default() -> Self {
71        Self {
72            total_bytes: 0,
73            total_chunks: 0,
74            quota_limit: 0, // Unlimited by default
75        }
76    }
77}
78
79/// Manages storage quota using Sled
80pub struct QuotaManager {
81    db: Db,
82}
83
84impl QuotaManager {
85    /// Open or create a quota manager
86    pub fn open<P: AsRef<Path>>(path: P) -> StorageResult<Self> {
87        let db = sled::open(path).map_err(|e| StorageError::Database(e.to_string()))?;
88        info!("Opened quota manager");
89        Ok(Self { db })
90    }
91
92    /// Get current quota and usage statistics
93    pub fn get_quota(&self) -> StorageResult<StorageQuota> {
94        let tree = self
95            .db
96            .open_tree(TREE_QUOTA)
97            .map_err(|e| StorageError::Database(e.to_string()))?;
98
99        let total_bytes = tree
100            .get(KEY_TOTAL_BYTES)
101            .map_err(|e| StorageError::Database(e.to_string()))?
102            .map(|bytes| u64::from_le_bytes(bytes.as_ref().try_into().unwrap_or([0; 8])))
103            .unwrap_or(0);
104
105        let total_chunks = tree
106            .get(KEY_TOTAL_CHUNKS)
107            .map_err(|e| StorageError::Database(e.to_string()))?
108            .map(|bytes| u64::from_le_bytes(bytes.as_ref().try_into().unwrap_or([0; 8])))
109            .unwrap_or(0);
110
111        let quota_limit = tree
112            .get(KEY_QUOTA_LIMIT)
113            .map_err(|e| StorageError::Database(e.to_string()))?
114            .map(|bytes| u64::from_le_bytes(bytes.as_ref().try_into().unwrap_or([0; 8])))
115            .unwrap_or(0);
116
117        Ok(StorageQuota {
118            total_bytes,
119            total_chunks,
120            quota_limit,
121        })
122    }
123
124    /// Set quota limit in bytes (0 = unlimited)
125    pub fn set_quota_limit(&self, limit: u64) -> StorageResult<()> {
126        let tree = self
127            .db
128            .open_tree(TREE_QUOTA)
129            .map_err(|e| StorageError::Database(e.to_string()))?;
130
131        tree.insert(KEY_QUOTA_LIMIT, &limit.to_le_bytes())
132            .map_err(|e| StorageError::Database(e.to_string()))?;
133
134        info!("Set quota limit to {} bytes", limit);
135        Ok(())
136    }
137
138    /// Add storage usage (when storing a new chunk)
139    pub fn add_usage(&self, bytes: u64) -> StorageResult<()> {
140        let tree = self
141            .db
142            .open_tree(TREE_QUOTA)
143            .map_err(|e| StorageError::Database(e.to_string()))?;
144
145        // Check if we have space
146        let quota = self.get_quota()?;
147        if !quota.has_space(bytes) {
148            return Err(StorageError::QuotaExceeded {
149                requested: bytes,
150                available: quota.remaining_bytes(),
151            });
152        }
153
154        // Update total bytes
155        let new_total = quota.total_bytes + bytes;
156        tree.insert(KEY_TOTAL_BYTES, &new_total.to_le_bytes())
157            .map_err(|e| StorageError::Database(e.to_string()))?;
158
159        // Update total chunks
160        let new_chunks = quota.total_chunks + 1;
161        tree.insert(KEY_TOTAL_CHUNKS, &new_chunks.to_le_bytes())
162            .map_err(|e| StorageError::Database(e.to_string()))?;
163
164        debug!(
165            "Added {} bytes, total: {} / {} chunks: {}",
166            bytes, new_total, quota.quota_limit, new_chunks
167        );
168
169        // Warn if nearly full
170        if new_total as f64 / quota.quota_limit as f64 > 0.9 && quota.quota_limit > 0 {
171            warn!(
172                "Storage quota nearly full: {:.1}%",
173                (new_total as f64 / quota.quota_limit as f64) * 100.0
174            );
175        }
176
177        Ok(())
178    }
179
180    /// Remove storage usage (when deleting a chunk)
181    pub fn remove_usage(&self, bytes: u64) -> StorageResult<()> {
182        let tree = self
183            .db
184            .open_tree(TREE_QUOTA)
185            .map_err(|e| StorageError::Database(e.to_string()))?;
186
187        let quota = self.get_quota()?;
188
189        // Update total bytes
190        let new_total = quota.total_bytes.saturating_sub(bytes);
191        tree.insert(KEY_TOTAL_BYTES, &new_total.to_le_bytes())
192            .map_err(|e| StorageError::Database(e.to_string()))?;
193
194        // Update total chunks
195        let new_chunks = quota.total_chunks.saturating_sub(1);
196        tree.insert(KEY_TOTAL_CHUNKS, &new_chunks.to_le_bytes())
197            .map_err(|e| StorageError::Database(e.to_string()))?;
198
199        debug!(
200            "Removed {} bytes, total: {} chunks: {}",
201            bytes, new_total, new_chunks
202        );
203
204        Ok(())
205    }
206
207    /// Check if we can store additional bytes
208    pub fn can_store(&self, bytes: u64) -> StorageResult<bool> {
209        let quota = self.get_quota()?;
210        Ok(quota.has_space(bytes))
211    }
212
213    /// Get usage statistics
214    pub fn get_stats(&self) -> StorageResult<QuotaStats> {
215        let quota = self.get_quota()?;
216
217        Ok(QuotaStats {
218            total_bytes: quota.total_bytes,
219            total_chunks: quota.total_chunks,
220            quota_limit: quota.quota_limit,
221            remaining_bytes: quota.remaining_bytes(),
222            usage_percentage: quota.usage_percentage(),
223            is_nearly_full: quota.is_nearly_full(),
224            is_full: quota.is_full(),
225        })
226    }
227
228    /// Reset all usage statistics (dangerous!)
229    pub fn reset(&self) -> StorageResult<()> {
230        let tree = self
231            .db
232            .open_tree(TREE_QUOTA)
233            .map_err(|e| StorageError::Database(e.to_string()))?;
234
235        tree.clear().map_err(|e| StorageError::Database(e.to_string()))?;
236
237        warn!("Storage quota statistics reset!");
238        Ok(())
239    }
240}
241
242/// Detailed quota statistics
243#[derive(Debug, Clone, Serialize, Deserialize)]
244pub struct QuotaStats {
245    pub total_bytes: u64,
246    pub total_chunks: u64,
247    pub quota_limit: u64,
248    pub remaining_bytes: u64,
249    pub usage_percentage: f64,
250    pub is_nearly_full: bool,
251    pub is_full: bool,
252}
253
254impl QuotaStats {
255    /// Format bytes in human-readable format
256    pub fn format_bytes(bytes: u64) -> String {
257        const UNITS: &[&str] = &["B", "KB", "MB", "GB", "TB"];
258        let mut size = bytes as f64;
259        let mut unit_idx = 0;
260
261        while size >= 1024.0 && unit_idx < UNITS.len() - 1 {
262            size /= 1024.0;
263            unit_idx += 1;
264        }
265
266        format!("{:.2} {}", size, UNITS[unit_idx])
267    }
268
269    /// Get a human-readable summary
270    pub fn summary(&self) -> String {
271        if self.quota_limit == 0 {
272            format!(
273                "{} used in {} chunks (unlimited)",
274                Self::format_bytes(self.total_bytes),
275                self.total_chunks
276            )
277        } else {
278            format!(
279                "{} / {} used ({:.1}%) in {} chunks",
280                Self::format_bytes(self.total_bytes),
281                Self::format_bytes(self.quota_limit),
282                self.usage_percentage,
283                self.total_chunks
284            )
285        }
286    }
287}
288
289#[cfg(test)]
290mod tests {
291    use super::*;
292    use tempfile::tempdir;
293
294    #[test]
295    fn test_quota_basic() {
296        let dir = tempdir().unwrap();
297        let manager = QuotaManager::open(dir.path().join("quota")).unwrap();
298
299        // Set quota to 1GB
300        manager.set_quota_limit(1_000_000_000).unwrap();
301
302        // Add some usage
303        manager.add_usage(500_000_000).unwrap();
304
305        let stats = manager.get_stats().unwrap();
306        assert_eq!(stats.total_bytes, 500_000_000);
307        assert_eq!(stats.total_chunks, 1);
308        assert_eq!(stats.usage_percentage, 50.0);
309
310        // Check we can store more
311        assert!(manager.can_store(400_000_000).unwrap());
312
313        // Check we can't exceed quota
314        assert!(!manager.can_store(600_000_000).unwrap());
315    }
316
317    #[test]
318    fn test_quota_exceeded() {
319        let dir = tempdir().unwrap();
320        let manager = QuotaManager::open(dir.path().join("quota")).unwrap();
321
322        manager.set_quota_limit(1000).unwrap();
323        manager.add_usage(800).unwrap();
324
325        // Should fail when exceeding quota
326        let result = manager.add_usage(300);
327        assert!(matches!(result, Err(StorageError::QuotaExceeded { .. })));
328    }
329
330    #[test]
331    fn test_remove_usage() {
332        let dir = tempdir().unwrap();
333        let manager = QuotaManager::open(dir.path().join("quota")).unwrap();
334
335        manager.add_usage(1000).unwrap();
336        assert_eq!(manager.get_stats().unwrap().total_bytes, 1000);
337
338        manager.remove_usage(400).unwrap();
339        assert_eq!(manager.get_stats().unwrap().total_bytes, 600);
340        assert_eq!(manager.get_stats().unwrap().total_chunks, 0);
341    }
342
343    #[test]
344    fn test_unlimited_quota() {
345        let dir = tempdir().unwrap();
346        let manager = QuotaManager::open(dir.path().join("quota")).unwrap();
347
348        // No limit set (0 = unlimited)
349        manager.add_usage(u64::MAX / 2).unwrap();
350        assert!(manager.can_store(u64::MAX / 2).unwrap());
351    }
352}