1use serde::{Deserialize, Serialize};
7
8#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
10pub enum CompactionAction {
11 CompactSled,
13 MergeSSTables { table_ids: Vec<String> },
15 EvictTier {
17 tier_name: String,
18 bytes_to_free: u64,
19 },
20 DropOrphanBlocks { cid_count: usize },
22 NoActionNeeded,
24}
25
26#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
28pub struct StorageMetrics {
29 pub sled_bytes_used: u64,
31 pub sled_bytes_free: u64,
33 pub wal_pending_ops: u64,
35 pub orphan_block_count: u64,
37 pub orphan_block_bytes: u64,
39 pub hot_tier_bytes: u64,
41 pub warm_tier_bytes: u64,
43 pub cold_tier_bytes: u64,
45 pub sstable_count: u64,
47 pub fragmentation_ratio: f64,
49}
50
51impl StorageMetrics {
52 pub fn total_bytes(&self) -> u64 {
54 self.sled_bytes_used
55 .saturating_add(self.hot_tier_bytes)
56 .saturating_add(self.warm_tier_bytes)
57 .saturating_add(self.cold_tier_bytes)
58 }
59
60 pub fn usage_ratio(&self) -> f64 {
64 let used = self.sled_bytes_used;
65 let free = self.sled_bytes_free;
66 if used == 0 && free == 0 {
67 return 0.0;
68 }
69 used as f64 / (used as f64 + free as f64)
70 }
71}
72
73#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
75pub struct AdvisoryThresholds {
76 pub wal_flush_threshold: u64,
78 pub orphan_gc_threshold: u64,
80 pub fragmentation_compact_threshold: f64,
82 pub sstable_merge_threshold: u64,
84 pub cold_tier_evict_ratio: f64,
86}
87
88impl Default for AdvisoryThresholds {
89 fn default() -> Self {
90 Self {
91 wal_flush_threshold: 1_000,
92 orphan_gc_threshold: 100,
93 fragmentation_compact_threshold: 0.3,
94 sstable_merge_threshold: 10,
95 cold_tier_evict_ratio: 0.8,
96 }
97 }
98}
99
100#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
103pub struct CompactionAdvice {
104 pub actions: Vec<CompactionAction>,
106 pub urgency: u8,
108 pub reason: String,
110 pub estimated_bytes_freed: u64,
112}
113
114impl CompactionAdvice {
115 pub fn is_urgent(&self) -> bool {
117 self.urgency >= 2
118 }
119}
120
121#[derive(Debug, Clone)]
125pub struct CompactionAdvisor {
126 pub thresholds: AdvisoryThresholds,
128}
129
130impl CompactionAdvisor {
131 pub fn new(thresholds: AdvisoryThresholds) -> Self {
133 Self { thresholds }
134 }
135
136 pub fn advise(&self, metrics: &StorageMetrics) -> CompactionAdvice {
139 let mut actions: Vec<CompactionAction> = Vec::new();
140 let mut reason_parts: Vec<String> = Vec::new();
141 let mut compact_sled_added = false;
142 let mut evict_bytes: u64 = 0;
143
144 if metrics.wal_pending_ops > self.thresholds.wal_flush_threshold {
146 actions.push(CompactionAction::CompactSled);
147 compact_sled_added = true;
148 reason_parts.push(format!(
149 "WAL has {} pending ops (threshold {})",
150 metrics.wal_pending_ops, self.thresholds.wal_flush_threshold
151 ));
152 }
153
154 if metrics.orphan_block_count > self.thresholds.orphan_gc_threshold {
156 actions.push(CompactionAction::DropOrphanBlocks {
157 cid_count: metrics.orphan_block_count as usize,
158 });
159 reason_parts.push(format!(
160 "orphan block count {} exceeds threshold {}",
161 metrics.orphan_block_count, self.thresholds.orphan_gc_threshold
162 ));
163 }
164
165 if metrics.fragmentation_ratio > self.thresholds.fragmentation_compact_threshold
167 && !compact_sled_added
168 {
169 actions.push(CompactionAction::CompactSled);
170 reason_parts.push(format!(
172 "fragmentation ratio {:.3} exceeds threshold {:.3}",
173 metrics.fragmentation_ratio, self.thresholds.fragmentation_compact_threshold
174 ));
175 }
176
177 if metrics.sstable_count > self.thresholds.sstable_merge_threshold {
179 let table_ids: Vec<String> = (0..metrics.sstable_count as usize)
180 .map(|i| format!("table_{}", i))
181 .collect();
182 actions.push(CompactionAction::MergeSSTables { table_ids });
183 reason_parts.push(format!(
184 "SSTable count {} exceeds merge threshold {}",
185 metrics.sstable_count, self.thresholds.sstable_merge_threshold
186 ));
187 }
188
189 if metrics.usage_ratio() > self.thresholds.cold_tier_evict_ratio {
191 let bytes_to_free = metrics.cold_tier_bytes / 4;
192 evict_bytes = bytes_to_free;
193 actions.push(CompactionAction::EvictTier {
194 tier_name: "cold".to_string(),
195 bytes_to_free,
196 });
197 reason_parts.push(format!(
198 "usage ratio {:.3} exceeds cold eviction threshold {:.3}",
199 metrics.usage_ratio(),
200 self.thresholds.cold_tier_evict_ratio
201 ));
202 }
203
204 if actions.is_empty() {
206 actions.push(CompactionAction::NoActionNeeded);
207 }
208
209 let real_action_count = actions
211 .iter()
212 .filter(|a| !matches!(a, CompactionAction::NoActionNeeded))
213 .count();
214
215 let urgency = match real_action_count {
216 0 => 0,
217 1 => 1,
218 2 => 2,
219 _ => 3,
220 };
221
222 let estimated_bytes_freed = metrics.orphan_block_bytes.saturating_add(evict_bytes);
224
225 let reason = if reason_parts.is_empty() {
226 "No issues detected; storage is healthy.".to_string()
227 } else {
228 reason_parts.join("; ")
229 };
230
231 CompactionAdvice {
232 actions,
233 urgency,
234 reason,
235 estimated_bytes_freed,
236 }
237 }
238
239 pub fn explain(&self, advice: &CompactionAdvice) -> Vec<String> {
242 advice
243 .actions
244 .iter()
245 .map(|action| match action {
246 CompactionAction::CompactSled => {
247 "CompactSled: flush the WAL and run sled compaction \
248 to reclaim fragmented space and reduce pending writes."
249 .to_string()
250 }
251 CompactionAction::MergeSSTables { table_ids } => {
252 format!(
253 "MergeSSTables: merge {} SSTable files ({}) \
254 to reduce read amplification and reclaim space.",
255 table_ids.len(),
256 table_ids.join(", ")
257 )
258 }
259 CompactionAction::EvictTier {
260 tier_name,
261 bytes_to_free,
262 } => {
263 format!(
264 "EvictTier({}): evict approximately {} bytes of cold data \
265 because storage usage exceeds the configured ratio.",
266 tier_name, bytes_to_free
267 )
268 }
269 CompactionAction::DropOrphanBlocks { cid_count } => {
270 format!(
271 "DropOrphanBlocks: garbage-collect {} unreferenced blocks \
272 to reclaim storage occupied by data with no live references.",
273 cid_count
274 )
275 }
276 CompactionAction::NoActionNeeded => {
277 "NoActionNeeded: all storage metrics are within healthy thresholds.".to_string()
278 }
279 })
280 .collect()
281 }
282}
283
284#[cfg(test)]
289mod tests {
290 use super::*;
291
292 fn healthy_metrics() -> StorageMetrics {
294 StorageMetrics {
295 sled_bytes_used: 100,
296 sled_bytes_free: 900,
297 wal_pending_ops: 10,
298 orphan_block_count: 5,
299 orphan_block_bytes: 1_024,
300 hot_tier_bytes: 200,
301 warm_tier_bytes: 100,
302 cold_tier_bytes: 400,
303 sstable_count: 3,
304 fragmentation_ratio: 0.05,
305 }
306 }
307
308 fn default_advisor() -> CompactionAdvisor {
309 CompactionAdvisor::new(AdvisoryThresholds::default())
310 }
311
312 #[test]
317 fn test_new_with_custom_thresholds() {
318 let thresholds = AdvisoryThresholds {
319 wal_flush_threshold: 500,
320 orphan_gc_threshold: 50,
321 fragmentation_compact_threshold: 0.2,
322 sstable_merge_threshold: 5,
323 cold_tier_evict_ratio: 0.7,
324 };
325 let advisor = CompactionAdvisor::new(thresholds.clone());
326 assert_eq!(advisor.thresholds, thresholds);
327 }
328
329 #[test]
334 fn test_advise_no_issues_returns_no_action_needed() {
335 let advisor = default_advisor();
336 let advice = advisor.advise(&healthy_metrics());
337 assert_eq!(advice.actions, vec![CompactionAction::NoActionNeeded]);
338 assert_eq!(advice.urgency, 0);
339 }
340
341 #[test]
346 fn test_advise_wal_over_threshold_adds_compact_sled() {
347 let advisor = default_advisor();
348 let mut metrics = healthy_metrics();
349 metrics.wal_pending_ops = 1_001;
350 let advice = advisor.advise(&metrics);
351 assert!(
352 advice.actions.contains(&CompactionAction::CompactSled),
353 "Expected CompactSled action"
354 );
355 assert!(!advice.actions.contains(&CompactionAction::NoActionNeeded));
356 }
357
358 #[test]
359 fn test_advise_orphans_over_threshold_adds_drop_orphan_blocks() {
360 let advisor = default_advisor();
361 let mut metrics = healthy_metrics();
362 metrics.orphan_block_count = 101;
363 let advice = advisor.advise(&metrics);
364 let has_orphan = advice.actions.iter().any(
365 |a| matches!(a, CompactionAction::DropOrphanBlocks { cid_count } if *cid_count == 101),
366 );
367 assert!(has_orphan, "Expected DropOrphanBlocks with cid_count=101");
368 }
369
370 #[test]
371 fn test_advise_fragmentation_over_threshold_adds_compact_sled() {
372 let advisor = default_advisor();
373 let mut metrics = healthy_metrics();
374 metrics.fragmentation_ratio = 0.5;
375 let advice = advisor.advise(&metrics);
376 assert!(
377 advice.actions.contains(&CompactionAction::CompactSled),
378 "Expected CompactSled due to fragmentation"
379 );
380 }
381
382 #[test]
383 fn test_advise_sstables_over_threshold_adds_merge_sstables_with_correct_ids() {
384 let advisor = default_advisor();
385 let mut metrics = healthy_metrics();
386 metrics.sstable_count = 12;
387 let advice = advisor.advise(&metrics);
388 let merge_action = advice.actions.iter().find_map(|a| {
389 if let CompactionAction::MergeSSTables { table_ids } = a {
390 Some(table_ids.clone())
391 } else {
392 None
393 }
394 });
395 let table_ids = merge_action.expect("Expected MergeSSTables action");
396 assert_eq!(table_ids.len(), 12);
397 assert_eq!(table_ids[0], "table_0");
398 assert_eq!(table_ids[11], "table_11");
399 }
400
401 #[test]
402 fn test_advise_cold_tier_eviction_triggered() {
403 let advisor = default_advisor();
404 let mut metrics = healthy_metrics();
405 metrics.sled_bytes_used = 900;
407 metrics.sled_bytes_free = 1;
408 metrics.cold_tier_bytes = 800;
409 let advice = advisor.advise(&metrics);
410 let evict_action = advice.actions.iter().find_map(|a| {
411 if let CompactionAction::EvictTier {
412 tier_name,
413 bytes_to_free,
414 } = a
415 {
416 Some((tier_name.clone(), *bytes_to_free))
417 } else {
418 None
419 }
420 });
421 let (tier, freed) = evict_action.expect("Expected EvictTier action");
422 assert_eq!(tier, "cold");
423 assert_eq!(freed, 200); }
425
426 #[test]
431 fn test_advise_multiple_conditions_all_actions_present() {
432 let advisor = default_advisor();
433 let mut metrics = healthy_metrics();
434 metrics.wal_pending_ops = 2_000; metrics.orphan_block_count = 200; metrics.sstable_count = 15; let advice = advisor.advise(&metrics);
438 assert!(advice.actions.contains(&CompactionAction::CompactSled));
439 assert!(advice
440 .actions
441 .iter()
442 .any(|a| matches!(a, CompactionAction::DropOrphanBlocks { .. })));
443 assert!(advice
444 .actions
445 .iter()
446 .any(|a| matches!(a, CompactionAction::MergeSSTables { .. })));
447 assert!(!advice.actions.contains(&CompactionAction::NoActionNeeded));
448 }
449
450 #[test]
455 fn test_urgency_zero_for_no_actions() {
456 let advisor = default_advisor();
457 let advice = advisor.advise(&healthy_metrics());
458 assert_eq!(advice.urgency, 0);
459 }
460
461 #[test]
462 fn test_urgency_one_for_single_action() {
463 let advisor = default_advisor();
464 let mut metrics = healthy_metrics();
465 metrics.wal_pending_ops = 5_000; let advice = advisor.advise(&metrics);
467 assert_eq!(advice.urgency, 1);
468 }
469
470 #[test]
471 fn test_urgency_two_for_two_actions() {
472 let advisor = default_advisor();
473 let mut metrics = healthy_metrics();
474 metrics.wal_pending_ops = 5_000; metrics.orphan_block_count = 200; let advice = advisor.advise(&metrics);
477 assert_eq!(advice.urgency, 2);
478 }
479
480 #[test]
481 fn test_urgency_three_for_three_or_more_actions() {
482 let advisor = default_advisor();
483 let mut metrics = healthy_metrics();
484 metrics.wal_pending_ops = 5_000; metrics.orphan_block_count = 200; metrics.sstable_count = 20; let advice = advisor.advise(&metrics);
488 assert_eq!(advice.urgency, 3);
489 }
490
491 #[test]
496 fn test_is_urgent_true_for_urgency_two() {
497 let advice = CompactionAdvice {
498 actions: vec![CompactionAction::CompactSled],
499 urgency: 2,
500 reason: "test".to_string(),
501 estimated_bytes_freed: 0,
502 };
503 assert!(advice.is_urgent());
504 }
505
506 #[test]
507 fn test_is_urgent_false_for_urgency_one() {
508 let advice = CompactionAdvice {
509 actions: vec![CompactionAction::CompactSled],
510 urgency: 1,
511 reason: "test".to_string(),
512 estimated_bytes_freed: 0,
513 };
514 assert!(!advice.is_urgent());
515 }
516
517 #[test]
518 fn test_is_urgent_true_for_urgency_three() {
519 let advice = CompactionAdvice {
520 actions: vec![CompactionAction::CompactSled],
521 urgency: 3,
522 reason: "test".to_string(),
523 estimated_bytes_freed: 0,
524 };
525 assert!(advice.is_urgent());
526 }
527
528 #[test]
533 fn test_estimated_bytes_freed_calculation() {
534 let advisor = default_advisor();
535 let mut metrics = healthy_metrics();
536 metrics.orphan_block_count = 200;
537 metrics.orphan_block_bytes = 10_000;
538 metrics.sled_bytes_used = 900;
540 metrics.sled_bytes_free = 1;
541 metrics.cold_tier_bytes = 400; let advice = advisor.advise(&metrics);
543 assert_eq!(advice.estimated_bytes_freed, 10_100);
545 }
546
547 #[test]
552 fn test_explain_returns_non_empty_strings_for_each_action() {
553 let advisor = default_advisor();
554 let mut metrics = healthy_metrics();
555 metrics.wal_pending_ops = 5_000;
556 metrics.orphan_block_count = 200;
557 let advice = advisor.advise(&metrics);
558 let explanations = advisor.explain(&advice);
559 assert_eq!(explanations.len(), advice.actions.len());
560 for explanation in &explanations {
561 assert!(
562 !explanation.is_empty(),
563 "Expected non-empty explanation string"
564 );
565 }
566 }
567
568 #[test]
569 fn test_explain_no_action_needed_has_description() {
570 let advisor = default_advisor();
571 let advice = advisor.advise(&healthy_metrics());
572 let explanations = advisor.explain(&advice);
573 assert_eq!(explanations.len(), 1);
574 assert!(explanations[0].contains("NoActionNeeded"));
575 }
576
577 #[test]
582 fn test_usage_ratio_zero_when_both_used_and_free_are_zero() {
583 let metrics = StorageMetrics {
584 sled_bytes_used: 0,
585 sled_bytes_free: 0,
586 wal_pending_ops: 0,
587 orphan_block_count: 0,
588 orphan_block_bytes: 0,
589 hot_tier_bytes: 0,
590 warm_tier_bytes: 0,
591 cold_tier_bytes: 0,
592 sstable_count: 0,
593 fragmentation_ratio: 0.0,
594 };
595 assert_eq!(metrics.usage_ratio(), 0.0);
596 }
597
598 #[test]
599 fn test_total_bytes_sums_all_tiers() {
600 let metrics = StorageMetrics {
601 sled_bytes_used: 100,
602 sled_bytes_free: 0,
603 wal_pending_ops: 0,
604 orphan_block_count: 0,
605 orphan_block_bytes: 0,
606 hot_tier_bytes: 200,
607 warm_tier_bytes: 300,
608 cold_tier_bytes: 400,
609 sstable_count: 0,
610 fragmentation_ratio: 0.0,
611 };
612 assert_eq!(metrics.total_bytes(), 1_000);
613 }
614
615 #[test]
620 fn test_fragmentation_does_not_duplicate_compact_sled_when_wal_also_triggers() {
621 let advisor = default_advisor();
622 let mut metrics = healthy_metrics();
623 metrics.wal_pending_ops = 5_000; metrics.fragmentation_ratio = 0.9; let advice = advisor.advise(&metrics);
626 let compact_count = advice
627 .actions
628 .iter()
629 .filter(|a| matches!(a, CompactionAction::CompactSled))
630 .count();
631 assert_eq!(compact_count, 1, "CompactSled should appear exactly once");
632 }
633}