Skip to main content

ipfrs_storage/
cost_estimator.rs

1//! Storage Cost Estimator
2//!
3//! Estimates monetary and computational costs for storage operations across
4//! different backend types: local SSD, cloud object storage, and cold archive.
5//!
6//! # Overview
7//!
8//! - [`BackendType`] — enum of supported storage backends with cost parameters
9//! - [`OperationCost`] — cost breakdown for a single storage operation
10//! - [`CostProjection`] — monthly/annual projection with savings vs baseline
11//! - [`StorageCostEstimator`] — main estimator struct
12
13/// Storage backend types with associated cost parameters.
14#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
15pub enum BackendType {
16    /// Fast local NVMe SSD
17    LocalSsd,
18    /// Cloud object storage — e.g. AWS S3 Standard
19    CloudHot,
20    /// Cloud object storage — e.g. AWS S3 Infrequent Access
21    CloudWarm,
22    /// Cloud archive — e.g. AWS S3 Glacier
23    CloudCold,
24    /// Spinning local hard disk drive
25    LocalHdd,
26}
27
28impl BackendType {
29    /// Cost per GB per month in USD.
30    pub fn cost_per_gb_month(self) -> f64 {
31        match self {
32            BackendType::LocalSsd => 0.10,
33            BackendType::CloudHot => 0.023,
34            BackendType::CloudWarm => 0.0125,
35            BackendType::CloudCold => 0.004,
36            BackendType::LocalHdd => 0.03,
37        }
38    }
39
40    /// Cost per PUT (write) request in USD.
41    pub fn cost_per_put_request(self) -> f64 {
42        match self {
43            BackendType::LocalSsd => 0.0,
44            BackendType::CloudHot => 0.000_005,
45            BackendType::CloudWarm => 0.000_010,
46            BackendType::CloudCold => 0.000_030,
47            BackendType::LocalHdd => 0.0,
48        }
49    }
50
51    /// Cost per GET (read) request in USD.
52    pub fn cost_per_get_request(self) -> f64 {
53        match self {
54            BackendType::LocalSsd => 0.0,
55            BackendType::CloudHot => 0.000_000_4,
56            BackendType::CloudWarm => 0.000_001,
57            BackendType::CloudCold => 0.001,
58            BackendType::LocalHdd => 0.0,
59        }
60    }
61
62    /// Typical read latency in milliseconds.
63    pub fn read_latency_ms(self) -> u64 {
64        match self {
65            BackendType::LocalSsd => 0,
66            BackendType::CloudHot => 5,
67            BackendType::CloudWarm => 30,
68            BackendType::CloudCold => 500,
69            BackendType::LocalHdd => 5,
70        }
71    }
72
73    /// Return all backend variants in definition order.
74    fn all() -> [BackendType; 5] {
75        [
76            BackendType::LocalSsd,
77            BackendType::CloudHot,
78            BackendType::CloudWarm,
79            BackendType::CloudCold,
80            BackendType::LocalHdd,
81        ]
82    }
83}
84
85/// Cost breakdown for a single storage operation.
86#[derive(Clone, Debug, PartialEq)]
87pub struct OperationCost {
88    /// The backend this cost was estimated for.
89    pub backend: BackendType,
90    /// Monthly GB storage cost in USD.
91    pub storage_cost: f64,
92    /// Total PUT request cost in USD.
93    pub put_cost: f64,
94    /// Total GET request cost in USD.
95    pub get_cost: f64,
96    /// Sum of all cost components in USD.
97    pub total_cost: f64,
98}
99
100impl OperationCost {
101    /// Returns `true` when this operation is cheaper than `other`.
102    pub fn is_cheaper_than(&self, other: &OperationCost) -> bool {
103        self.total_cost < other.total_cost
104    }
105}
106
107/// Monthly and annual cost projection for a backend.
108#[derive(Clone, Debug, PartialEq)]
109pub struct CostProjection {
110    /// The backend this projection covers.
111    pub backend: BackendType,
112    /// Estimated cost for one month in USD.
113    pub monthly_cost: f64,
114    /// Estimated cost for twelve months in USD.
115    pub annual_cost: f64,
116    /// `baseline.monthly_cost - this.monthly_cost`.
117    ///
118    /// Positive means cheaper than the baseline (`CloudHot`).
119    /// Negative means more expensive than the baseline.
120    pub savings_vs_baseline: f64,
121}
122
123/// Estimates monetary and computational costs for storage operations.
124///
125/// # Example
126/// ```
127/// use ipfrs_storage::cost_estimator::{BackendType, StorageCostEstimator};
128///
129/// let estimator = StorageCostEstimator::new();
130/// let cost = estimator.estimate_operation(BackendType::CloudHot, 1 << 30, 1_000, 10_000);
131/// assert!(cost.total_cost > 0.0);
132/// ```
133pub struct StorageCostEstimator;
134
135impl StorageCostEstimator {
136    /// Create a new estimator instance.
137    pub fn new() -> Self {
138        StorageCostEstimator
139    }
140
141    /// Estimate the cost of a storage operation.
142    ///
143    /// - `size_bytes` — data volume stored
144    /// - `num_puts` — number of write/PUT requests
145    /// - `num_gets` — number of read/GET requests
146    pub fn estimate_operation(
147        &self,
148        backend: BackendType,
149        size_bytes: u64,
150        num_puts: u64,
151        num_gets: u64,
152    ) -> OperationCost {
153        let gb = size_bytes as f64 / (1024.0_f64 * 1024.0 * 1024.0);
154        let storage_cost = gb * backend.cost_per_gb_month();
155        let put_cost = num_puts as f64 * backend.cost_per_put_request();
156        let get_cost = num_gets as f64 * backend.cost_per_get_request();
157        let total_cost = storage_cost + put_cost + get_cost;
158        OperationCost {
159            backend,
160            storage_cost,
161            put_cost,
162            get_cost,
163            total_cost,
164        }
165    }
166
167    /// Estimate costs for all backends and return them sorted by `total_cost` ascending.
168    pub fn compare_backends(
169        &self,
170        size_bytes: u64,
171        num_puts: u64,
172        num_gets: u64,
173    ) -> Vec<OperationCost> {
174        let mut costs: Vec<OperationCost> = BackendType::all()
175            .iter()
176            .map(|&b| self.estimate_operation(b, size_bytes, num_puts, num_gets))
177            .collect();
178
179        costs.sort_by(|a, b| {
180            a.total_cost
181                .partial_cmp(&b.total_cost)
182                .unwrap_or(std::cmp::Ordering::Equal)
183        });
184        costs
185    }
186
187    /// Project monthly and annual costs for a backend.
188    ///
189    /// `savings_vs_baseline` is computed relative to `CloudHot`.
190    pub fn project_annual(
191        &self,
192        backend: BackendType,
193        size_bytes: u64,
194        monthly_puts: u64,
195        monthly_gets: u64,
196    ) -> CostProjection {
197        let this_monthly = self
198            .estimate_operation(backend, size_bytes, monthly_puts, monthly_gets)
199            .total_cost;
200        let baseline_monthly = self
201            .estimate_operation(
202                BackendType::CloudHot,
203                size_bytes,
204                monthly_puts,
205                monthly_gets,
206            )
207            .total_cost;
208        CostProjection {
209            backend,
210            monthly_cost: this_monthly,
211            annual_cost: this_monthly * 12.0,
212            savings_vs_baseline: baseline_monthly - this_monthly,
213        }
214    }
215
216    /// Return the backend with the lowest total cost for the given workload.
217    pub fn cheapest_backend(&self, size_bytes: u64, num_puts: u64, num_gets: u64) -> BackendType {
218        let ranked = self.compare_backends(size_bytes, num_puts, num_gets);
219        // compare_backends always has 5 elements; safe to index.
220        ranked[0].backend
221    }
222}
223
224impl Default for StorageCostEstimator {
225    fn default() -> Self {
226        Self::new()
227    }
228}
229
230// ---------------------------------------------------------------------------
231// Tests
232// ---------------------------------------------------------------------------
233
234#[cfg(test)]
235mod tests {
236    use super::*;
237
238    const GIB: u64 = 1024 * 1024 * 1024;
239
240    // ------------------------------------------------------------------
241    // BackendType helpers
242    // ------------------------------------------------------------------
243
244    #[test]
245    fn local_ssd_has_zero_request_costs() {
246        assert_eq!(BackendType::LocalSsd.cost_per_put_request(), 0.0);
247        assert_eq!(BackendType::LocalSsd.cost_per_get_request(), 0.0);
248    }
249
250    #[test]
251    fn local_hdd_has_zero_request_costs() {
252        assert_eq!(BackendType::LocalHdd.cost_per_put_request(), 0.0);
253        assert_eq!(BackendType::LocalHdd.cost_per_get_request(), 0.0);
254    }
255
256    #[test]
257    fn cloud_cold_has_highest_get_cost() {
258        let cold = BackendType::CloudCold.cost_per_get_request();
259        let hot = BackendType::CloudHot.cost_per_get_request();
260        let warm = BackendType::CloudWarm.cost_per_get_request();
261        assert!(cold > hot, "CloudCold get cost must exceed CloudHot");
262        assert!(cold > warm, "CloudCold get cost must exceed CloudWarm");
263    }
264
265    #[test]
266    fn cloud_cold_has_highest_put_cost() {
267        let cold = BackendType::CloudCold.cost_per_put_request();
268        let hot = BackendType::CloudHot.cost_per_put_request();
269        let warm = BackendType::CloudWarm.cost_per_put_request();
270        assert!(cold > hot);
271        assert!(cold > warm);
272    }
273
274    #[test]
275    fn read_latency_ordering() {
276        // LocalSsd fastest, CloudCold slowest
277        assert_eq!(BackendType::LocalSsd.read_latency_ms(), 0);
278        assert!(BackendType::CloudHot.read_latency_ms() < BackendType::CloudWarm.read_latency_ms());
279        assert!(
280            BackendType::CloudWarm.read_latency_ms() < BackendType::CloudCold.read_latency_ms()
281        );
282    }
283
284    #[test]
285    fn local_ssd_latency_is_zero() {
286        assert_eq!(BackendType::LocalSsd.read_latency_ms(), 0);
287    }
288
289    #[test]
290    fn local_hdd_latency_equals_cloud_hot() {
291        assert_eq!(
292            BackendType::LocalHdd.read_latency_ms(),
293            BackendType::CloudHot.read_latency_ms()
294        );
295    }
296
297    // ------------------------------------------------------------------
298    // estimate_operation
299    // ------------------------------------------------------------------
300
301    #[test]
302    fn estimate_operation_sums_correctly() {
303        let est = StorageCostEstimator::new();
304        let cost = est.estimate_operation(BackendType::CloudHot, GIB, 1_000, 5_000);
305
306        let expected_storage = BackendType::CloudHot.cost_per_gb_month();
307        let expected_put = 1_000.0 * BackendType::CloudHot.cost_per_put_request();
308        let expected_get = 5_000.0 * BackendType::CloudHot.cost_per_get_request();
309        let expected_total = expected_storage + expected_put + expected_get;
310
311        assert!((cost.storage_cost - expected_storage).abs() < 1e-12);
312        assert!((cost.put_cost - expected_put).abs() < 1e-12);
313        assert!((cost.get_cost - expected_get).abs() < 1e-12);
314        assert!((cost.total_cost - expected_total).abs() < 1e-12);
315    }
316
317    #[test]
318    fn estimate_operation_local_ssd_zero_request_costs() {
319        let est = StorageCostEstimator::new();
320        let cost = est.estimate_operation(BackendType::LocalSsd, GIB, 1_000_000, 1_000_000);
321        assert_eq!(cost.put_cost, 0.0);
322        assert_eq!(cost.get_cost, 0.0);
323        assert!((cost.total_cost - cost.storage_cost).abs() < 1e-12);
324    }
325
326    #[test]
327    fn estimate_operation_zero_size_zero_storage_cost() {
328        let est = StorageCostEstimator::new();
329        let cost = est.estimate_operation(BackendType::CloudHot, 0, 0, 0);
330        assert_eq!(cost.storage_cost, 0.0);
331        assert_eq!(cost.put_cost, 0.0);
332        assert_eq!(cost.get_cost, 0.0);
333        assert_eq!(cost.total_cost, 0.0);
334    }
335
336    #[test]
337    fn estimate_operation_backend_field_correct() {
338        let est = StorageCostEstimator::new();
339        let cost = est.estimate_operation(BackendType::CloudWarm, GIB, 0, 0);
340        assert_eq!(cost.backend, BackendType::CloudWarm);
341    }
342
343    // ------------------------------------------------------------------
344    // compare_backends
345    // ------------------------------------------------------------------
346
347    #[test]
348    fn compare_backends_returns_five_entries() {
349        let est = StorageCostEstimator::new();
350        let costs = est.compare_backends(GIB, 100, 100);
351        assert_eq!(costs.len(), 5);
352    }
353
354    #[test]
355    fn compare_backends_sorted_ascending() {
356        let est = StorageCostEstimator::new();
357        let costs = est.compare_backends(GIB, 10_000, 100_000);
358        for window in costs.windows(2) {
359            assert!(
360                window[0].total_cost <= window[1].total_cost,
361                "compare_backends not sorted ascending: {} > {}",
362                window[0].total_cost,
363                window[1].total_cost
364            );
365        }
366    }
367
368    #[test]
369    fn compare_backends_all_backends_represented() {
370        let est = StorageCostEstimator::new();
371        let costs = est.compare_backends(GIB, 0, 0);
372        let mut backends: Vec<BackendType> = costs.iter().map(|c| c.backend).collect();
373        backends.sort_by_key(|b| format!("{b:?}"));
374        let mut expected = [
375            BackendType::LocalSsd,
376            BackendType::CloudHot,
377            BackendType::CloudWarm,
378            BackendType::CloudCold,
379            BackendType::LocalHdd,
380        ];
381        expected.sort_by_key(|b| format!("{b:?}"));
382        assert_eq!(backends, expected.to_vec());
383    }
384
385    // ------------------------------------------------------------------
386    // cheapest_backend
387    // ------------------------------------------------------------------
388
389    #[test]
390    fn cheapest_backend_cloud_cold_for_archival_no_requests() {
391        // No puts or gets — only storage cost matters.
392        // CloudCold has cost_per_gb_month=0.004, the lowest.
393        let est = StorageCostEstimator::new();
394        let cheapest = est.cheapest_backend(GIB, 0, 0);
395        assert_eq!(cheapest, BackendType::CloudCold);
396    }
397
398    #[test]
399    fn cheapest_backend_local_ssd_for_high_read_zero_cost() {
400        // LocalSsd charges $0 per request, only storage cost.
401        // With massive gets, cloud backends accumulate large get costs.
402        // CloudCold get = $0.001/req => 1e9 gets => $1_000_000
403        // LocalSsd gets = $0 => cheapest.
404        let est = StorageCostEstimator::new();
405        let cheapest = est.cheapest_backend(GIB, 0, 1_000_000_000);
406        // LocalSsd or LocalHdd both have $0 request costs — compare storage:
407        // LocalSsd = $0.10/GB, LocalHdd = $0.03/GB => LocalHdd is cheaper
408        // But LocalHdd latency is higher; regardless, by cost LocalHdd wins.
409        // The test verifies a local backend wins, not cloud.
410        let result = cheapest;
411        assert!(
412            result == BackendType::LocalSsd || result == BackendType::LocalHdd,
413            "Expected a local backend for high-read workload, got {result:?}"
414        );
415    }
416
417    #[test]
418    fn cheapest_backend_local_hdd_cheapest_storage_only() {
419        // At zero requests, only storage cost matters:
420        // CloudCold=0.004, CloudWarm=0.0125, CloudHot=0.023, LocalHdd=0.03, LocalSsd=0.10
421        // So CloudCold is cheapest overall with 0 requests.
422        let est = StorageCostEstimator::new();
423        let cheapest = est.cheapest_backend(10 * GIB, 0, 0);
424        assert_eq!(cheapest, BackendType::CloudCold);
425    }
426
427    #[test]
428    fn cheapest_backend_local_ssd_dominates_with_zero_cost_requests() {
429        // When puts are very large and we compare local vs cloud:
430        // LocalSsd put cost = 0, CloudCold put cost = 0.00003/req
431        // 10e6 puts on CloudCold = $300; LocalSsd = $0 puts
432        // Storage: LocalSsd 1GiB = $0.10, CloudCold = $0.004
433        // Total LocalSsd ~ $0.10, CloudCold ~ $300.004 => LocalSsd wins
434        let est = StorageCostEstimator::new();
435        let cheapest = est.cheapest_backend(GIB, 10_000_000, 0);
436        assert!(
437            cheapest == BackendType::LocalSsd || cheapest == BackendType::LocalHdd,
438            "Expected local backend for massive PUT workload, got {cheapest:?}"
439        );
440    }
441
442    // ------------------------------------------------------------------
443    // project_annual
444    // ------------------------------------------------------------------
445
446    #[test]
447    fn project_annual_annual_equals_monthly_times_12() {
448        let est = StorageCostEstimator::new();
449        let proj = est.project_annual(BackendType::CloudHot, GIB, 1_000, 10_000);
450        assert!((proj.annual_cost - proj.monthly_cost * 12.0).abs() < 1e-9);
451    }
452
453    #[test]
454    fn project_annual_baseline_savings_is_zero_for_cloud_hot() {
455        // CloudHot IS the baseline, so savings = 0
456        let est = StorageCostEstimator::new();
457        let proj = est.project_annual(BackendType::CloudHot, GIB, 1_000, 10_000);
458        assert!(proj.savings_vs_baseline.abs() < 1e-12);
459    }
460
461    #[test]
462    fn project_annual_cold_cheaper_so_positive_savings() {
463        // CloudCold storage is cheaper than CloudHot => positive savings
464        let est = StorageCostEstimator::new();
465        let proj = est.project_annual(BackendType::CloudCold, GIB, 0, 0);
466        assert!(
467            proj.savings_vs_baseline > 0.0,
468            "CloudCold (no requests) should have positive savings vs CloudHot"
469        );
470    }
471
472    #[test]
473    fn project_annual_local_ssd_negative_savings_vs_cloud_hot() {
474        // LocalSsd storage = $0.10/GB > CloudHot $0.023/GB => negative savings
475        let est = StorageCostEstimator::new();
476        let proj = est.project_annual(BackendType::LocalSsd, GIB, 0, 0);
477        assert!(
478            proj.savings_vs_baseline < 0.0,
479            "LocalSsd should have negative savings vs CloudHot"
480        );
481    }
482
483    #[test]
484    fn project_annual_backend_field_set_correctly() {
485        let est = StorageCostEstimator::new();
486        let proj = est.project_annual(BackendType::CloudWarm, GIB, 500, 500);
487        assert_eq!(proj.backend, BackendType::CloudWarm);
488    }
489
490    // ------------------------------------------------------------------
491    // OperationCost::is_cheaper_than
492    // ------------------------------------------------------------------
493
494    #[test]
495    fn is_cheaper_than_returns_true_when_cheaper() {
496        let est = StorageCostEstimator::new();
497        let cold = est.estimate_operation(BackendType::CloudCold, GIB, 0, 0);
498        let hot = est.estimate_operation(BackendType::CloudHot, GIB, 0, 0);
499        assert!(cold.is_cheaper_than(&hot));
500    }
501
502    #[test]
503    fn is_cheaper_than_returns_false_when_more_expensive() {
504        let est = StorageCostEstimator::new();
505        let ssd = est.estimate_operation(BackendType::LocalSsd, GIB, 0, 0);
506        let cold = est.estimate_operation(BackendType::CloudCold, GIB, 0, 0);
507        assert!(!ssd.is_cheaper_than(&cold));
508    }
509
510    #[test]
511    fn is_cheaper_than_equal_costs_returns_false() {
512        let est = StorageCostEstimator::new();
513        let a = est.estimate_operation(BackendType::LocalSsd, GIB, 0, 0);
514        let b = est.estimate_operation(BackendType::LocalSsd, GIB, 0, 0);
515        assert!(!a.is_cheaper_than(&b));
516    }
517
518    // ------------------------------------------------------------------
519    // Default
520    // ------------------------------------------------------------------
521
522    #[test]
523    fn default_creates_valid_estimator() {
524        let est = StorageCostEstimator;
525        let cost = est.estimate_operation(BackendType::CloudHot, GIB, 0, 0);
526        assert!(cost.total_cost > 0.0);
527    }
528}