1#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
15pub enum BackendType {
16 LocalSsd,
18 CloudHot,
20 CloudWarm,
22 CloudCold,
24 LocalHdd,
26}
27
28impl BackendType {
29 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 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 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 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 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#[derive(Clone, Debug, PartialEq)]
87pub struct OperationCost {
88 pub backend: BackendType,
90 pub storage_cost: f64,
92 pub put_cost: f64,
94 pub get_cost: f64,
96 pub total_cost: f64,
98}
99
100impl OperationCost {
101 pub fn is_cheaper_than(&self, other: &OperationCost) -> bool {
103 self.total_cost < other.total_cost
104 }
105}
106
107#[derive(Clone, Debug, PartialEq)]
109pub struct CostProjection {
110 pub backend: BackendType,
112 pub monthly_cost: f64,
114 pub annual_cost: f64,
116 pub savings_vs_baseline: f64,
121}
122
123pub struct StorageCostEstimator;
134
135impl StorageCostEstimator {
136 pub fn new() -> Self {
138 StorageCostEstimator
139 }
140
141 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 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 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 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 ranked[0].backend
221 }
222}
223
224impl Default for StorageCostEstimator {
225 fn default() -> Self {
226 Self::new()
227 }
228}
229
230#[cfg(test)]
235mod tests {
236 use super::*;
237
238 const GIB: u64 = 1024 * 1024 * 1024;
239
240 #[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 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 #[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 #[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 #[test]
390 fn cheapest_backend_cloud_cold_for_archival_no_requests() {
391 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 let est = StorageCostEstimator::new();
405 let cheapest = est.cheapest_backend(GIB, 0, 1_000_000_000);
406 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 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 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 #[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 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 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 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 #[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 #[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}