ipfrs_semantic/index_compactor.rs
1//! # Index Compactor
2//!
3//! HNSW indexes accumulate deleted vectors over time that waste memory and degrade query
4//! performance. This module identifies fragmented indexes and coordinates rebuilds based
5//! on configurable compaction policies.
6//!
7//! ## Overview
8//!
9//! - [`CompactionPolicy`] — configures when compaction should be triggered
10//! - [`IndexFragmentStats`] — captures fragmentation metrics for a single index
11//! - [`IndexCompactor`] — analyzes indexes and produces [`CompactionPlan`]s
12//! - [`CompactionPlan`] — describes the planned compaction and its expected impact
13//! - [`CompactorStats`] — atomic counters tracking compactor activity
14
15use std::sync::atomic::{AtomicU64, Ordering};
16use std::sync::Arc;
17
18// ---------------------------------------------------------------------------
19// IndexFragmentStats
20// ---------------------------------------------------------------------------
21
22/// Statistics describing the fragmentation state of a single HNSW index.
23#[derive(Debug, Clone, PartialEq)]
24pub struct IndexFragmentStats {
25 /// Total number of vector slots (live + deleted).
26 pub total_vectors: usize,
27 /// Number of slots marked as deleted but not yet reclaimed.
28 pub deleted_vectors: usize,
29 /// Estimated on-disk / in-memory footprint in bytes.
30 pub index_bytes: u64,
31}
32
33impl IndexFragmentStats {
34 /// Creates a new `IndexFragmentStats`.
35 pub fn new(total_vectors: usize, deleted_vectors: usize, index_bytes: u64) -> Self {
36 Self {
37 total_vectors,
38 deleted_vectors,
39 index_bytes,
40 }
41 }
42
43 /// Ratio of deleted vectors to total vectors.
44 ///
45 /// Returns `0.0` when `total_vectors == 0` to avoid division-by-zero.
46 pub fn deleted_ratio(&self) -> f64 {
47 if self.total_vectors == 0 {
48 return 0.0;
49 }
50 self.deleted_vectors as f64 / self.total_vectors as f64
51 }
52
53 /// Number of live (non-deleted) vectors.
54 pub fn live_vectors(&self) -> usize {
55 self.total_vectors.saturating_sub(self.deleted_vectors)
56 }
57}
58
59// ---------------------------------------------------------------------------
60// CompactionReason
61// ---------------------------------------------------------------------------
62
63/// The primary reason a compaction was recommended.
64#[derive(Debug, Clone, PartialEq)]
65pub enum CompactionReason {
66 /// The fraction of deleted vectors exceeded the policy threshold.
67 HighDeletedRatio {
68 /// Observed deleted-to-total ratio.
69 ratio: f64,
70 },
71 /// The index size exceeded the policy's byte limit.
72 SizeExceeded {
73 /// Current measured size in bytes.
74 current_bytes: u64,
75 /// Configured size limit in bytes.
76 limit_bytes: u64,
77 },
78 /// Compaction was requested on a fixed schedule regardless of metrics.
79 Scheduled,
80}
81
82// ---------------------------------------------------------------------------
83// CompactionPriority
84// ---------------------------------------------------------------------------
85
86/// How urgently a compaction should be scheduled.
87///
88/// Variants are ordered from lowest to highest priority so that `Critical > High > Normal > Low`.
89#[derive(Debug, Clone, Copy, PartialEq, Eq)]
90#[repr(u8)]
91pub enum CompactionPriority {
92 /// Index health is acceptable; schedule compaction during a maintenance window.
93 Low = 0,
94 /// Standard compaction during off-peak hours.
95 Normal = 1,
96 /// Elevated fragmentation; schedule soon.
97 High = 2,
98 /// Severe fragmentation; compact immediately.
99 Critical = 3,
100}
101
102impl PartialOrd for CompactionPriority {
103 fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
104 Some(self.cmp(other))
105 }
106}
107
108impl Ord for CompactionPriority {
109 fn cmp(&self, other: &Self) -> std::cmp::Ordering {
110 (*self as u8).cmp(&(*other as u8))
111 }
112}
113
114// ---------------------------------------------------------------------------
115// CompactionPlan
116// ---------------------------------------------------------------------------
117
118/// A concrete recommendation to compact an index, including estimated impact.
119#[derive(Debug, Clone, PartialEq)]
120pub struct CompactionPlan {
121 /// Why the compaction was triggered.
122 pub reason: CompactionReason,
123 /// Estimated number of live vectors after the rebuild.
124 pub estimated_vectors_after: usize,
125 /// Approximate bytes that will be freed by the compaction.
126 pub estimated_bytes_saved: u64,
127 /// How urgently the compaction should be performed.
128 pub priority: CompactionPriority,
129}
130
131// ---------------------------------------------------------------------------
132// CompactionPolicy
133// ---------------------------------------------------------------------------
134
135/// Configures the triggers that cause [`IndexCompactor`] to recommend a compaction.
136#[derive(Debug, Clone)]
137pub struct CompactionPolicy {
138 /// Recommend compaction when `deleted / total > max_deleted_ratio`.
139 ///
140 /// Default: `0.1` (10 %).
141 pub max_deleted_ratio: f64,
142 /// Recommend compaction when the index exceeds this many bytes.
143 ///
144 /// Default: 512 MiB (`512 * 1024 * 1024`).
145 pub max_index_bytes: u64,
146 /// Skip compaction analysis for indexes with fewer than this many total vectors.
147 ///
148 /// Default: `1000`.
149 pub min_vectors_for_compaction: usize,
150}
151
152impl Default for CompactionPolicy {
153 fn default() -> Self {
154 Self {
155 max_deleted_ratio: 0.1,
156 max_index_bytes: 512 * 1024 * 1024,
157 min_vectors_for_compaction: 1000,
158 }
159 }
160}
161
162impl CompactionPolicy {
163 /// Returns `true` when `stats` indicates that compaction is needed according to this policy.
164 ///
165 /// Compaction is *not* triggered when `total_vectors < min_vectors_for_compaction`, even if
166 /// other thresholds are exceeded (tiny indexes are cheap to traverse as-is).
167 pub fn should_compact(&self, stats: &IndexFragmentStats) -> bool {
168 if stats.total_vectors < self.min_vectors_for_compaction {
169 return false;
170 }
171 if stats.deleted_ratio() > self.max_deleted_ratio {
172 return true;
173 }
174 if stats.index_bytes > self.max_index_bytes {
175 return true;
176 }
177 false
178 }
179}
180
181// ---------------------------------------------------------------------------
182// CompactorStats (atomic counters)
183// ---------------------------------------------------------------------------
184
185/// Internal mutable state of [`CompactorStats`] — not exposed directly.
186struct CompactorStatsInner {
187 total_analyzed: AtomicU64,
188 total_compaction_needed: AtomicU64,
189 total_skipped: AtomicU64,
190}
191
192impl CompactorStatsInner {
193 fn new() -> Self {
194 Self {
195 total_analyzed: AtomicU64::new(0),
196 total_compaction_needed: AtomicU64::new(0),
197 total_skipped: AtomicU64::new(0),
198 }
199 }
200}
201
202/// A snapshot of [`CompactorStats`] counters taken at a point in time.
203#[derive(Debug, Clone, PartialEq, Eq)]
204pub struct CompactorStatsSnapshot {
205 /// Total number of [`IndexFragmentStats`] passed to [`IndexCompactor::analyze`].
206 pub total_analyzed: u64,
207 /// Analyses that resulted in a [`CompactionPlan`] being returned.
208 pub total_compaction_needed: u64,
209 /// Analyses where no compaction was necessary.
210 pub total_skipped: u64,
211}
212
213/// Thread-safe counters tracking compactor activity.
214///
215/// Internally uses [`AtomicU64`] values so no locking is required.
216#[derive(Clone)]
217pub struct CompactorStats {
218 inner: Arc<CompactorStatsInner>,
219}
220
221impl CompactorStats {
222 fn new() -> Self {
223 Self {
224 inner: Arc::new(CompactorStatsInner::new()),
225 }
226 }
227
228 fn record_analyzed(&self) {
229 self.inner.total_analyzed.fetch_add(1, Ordering::Relaxed);
230 }
231
232 fn record_compaction_needed(&self) {
233 self.inner
234 .total_compaction_needed
235 .fetch_add(1, Ordering::Relaxed);
236 }
237
238 fn record_skipped(&self) {
239 self.inner.total_skipped.fetch_add(1, Ordering::Relaxed);
240 }
241
242 /// Returns a consistent point-in-time snapshot of all counters.
243 pub fn snapshot(&self) -> CompactorStatsSnapshot {
244 CompactorStatsSnapshot {
245 total_analyzed: self.inner.total_analyzed.load(Ordering::Relaxed),
246 total_compaction_needed: self.inner.total_compaction_needed.load(Ordering::Relaxed),
247 total_skipped: self.inner.total_skipped.load(Ordering::Relaxed),
248 }
249 }
250}
251
252impl std::fmt::Debug for CompactorStats {
253 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
254 let snap = self.snapshot();
255 f.debug_struct("CompactorStats")
256 .field("total_analyzed", &snap.total_analyzed)
257 .field("total_compaction_needed", &snap.total_compaction_needed)
258 .field("total_skipped", &snap.total_skipped)
259 .finish()
260 }
261}
262
263// ---------------------------------------------------------------------------
264// IndexCompactor
265// ---------------------------------------------------------------------------
266
267/// Analyses HNSW index fragmentation and recommends compaction plans.
268///
269/// `IndexCompactor` is the central entry point for compaction decisions. Given
270/// an [`IndexFragmentStats`] it applies the configured [`CompactionPolicy`] and
271/// returns an [`Option<CompactionPlan>`]: `Some` when compaction is warranted,
272/// `None` when the index is healthy enough to leave alone.
273///
274/// All activity is recorded in [`CompactorStats`] so operators can observe how
275/// frequently indexes require maintenance.
276#[derive(Debug, Clone)]
277pub struct IndexCompactor {
278 /// Configurable thresholds that drive compaction decisions.
279 pub policy: CompactionPolicy,
280 /// Atomic activity counters.
281 pub stats: CompactorStats,
282}
283
284impl IndexCompactor {
285 /// Creates a new `IndexCompactor` with the supplied policy.
286 pub fn new(policy: CompactionPolicy) -> Self {
287 Self {
288 policy,
289 stats: CompactorStats::new(),
290 }
291 }
292
293 /// Analyses `fragment_stats` and returns a [`CompactionPlan`] when compaction is needed.
294 ///
295 /// Returns `None` when the index is below all policy thresholds.
296 pub fn analyze(&self, fragment_stats: &IndexFragmentStats) -> Option<CompactionPlan> {
297 self.stats.record_analyzed();
298
299 if !self.policy.should_compact(fragment_stats) {
300 self.stats.record_skipped();
301 return None;
302 }
303
304 self.stats.record_compaction_needed();
305
306 let reason = self.primary_reason(fragment_stats);
307 let priority = self.estimate_priority(fragment_stats);
308 let estimated_bytes_saved = self.estimate_bytes_saved(fragment_stats);
309 let estimated_vectors_after = fragment_stats.live_vectors();
310
311 Some(CompactionPlan {
312 reason,
313 estimated_vectors_after,
314 estimated_bytes_saved,
315 priority,
316 })
317 }
318
319 /// Determines the primary compaction reason for the given stats.
320 ///
321 /// Deleted-ratio threshold takes precedence over size threshold when both are exceeded.
322 fn primary_reason(&self, stats: &IndexFragmentStats) -> CompactionReason {
323 let ratio = stats.deleted_ratio();
324 if ratio > self.policy.max_deleted_ratio {
325 return CompactionReason::HighDeletedRatio { ratio };
326 }
327 if stats.index_bytes > self.policy.max_index_bytes {
328 return CompactionReason::SizeExceeded {
329 current_bytes: stats.index_bytes,
330 limit_bytes: self.policy.max_index_bytes,
331 };
332 }
333 // Fallback — only reached when `should_compact` returned `true` via a scheduled path
334 // that bypasses both checks. Defined here for exhaustiveness.
335 CompactionReason::Scheduled
336 }
337
338 /// Estimates the urgency of the required compaction.
339 ///
340 /// | Condition | Priority |
341 /// |-----------|----------|
342 /// | `deleted_ratio > 0.5` **or** `index_bytes > 2 × limit` | `Critical` |
343 /// | `deleted_ratio > 0.25` | `High` |
344 /// | compaction needed | `Normal` |
345 /// | compaction *not* needed | `Low` |
346 pub fn estimate_priority(&self, stats: &IndexFragmentStats) -> CompactionPriority {
347 let ratio = stats.deleted_ratio();
348 let double_limit = self.policy.max_index_bytes.saturating_mul(2);
349
350 if ratio > 0.5 || stats.index_bytes > double_limit {
351 return CompactionPriority::Critical;
352 }
353 if ratio > 0.25 {
354 return CompactionPriority::High;
355 }
356 if self.policy.should_compact(stats) {
357 return CompactionPriority::Normal;
358 }
359 CompactionPriority::Low
360 }
361
362 /// Estimates how many bytes will be reclaimed by a compaction.
363 ///
364 /// Uses the approximation `deleted_ratio × index_bytes`.
365 pub fn estimate_bytes_saved(&self, stats: &IndexFragmentStats) -> u64 {
366 let ratio = stats.deleted_ratio();
367 (ratio * stats.index_bytes as f64) as u64
368 }
369}
370
371// ---------------------------------------------------------------------------
372// Tests
373// ---------------------------------------------------------------------------
374
375#[cfg(test)]
376mod tests {
377 use super::*;
378
379 // ------------------------------------------------------------------
380 // Helpers
381 // ------------------------------------------------------------------
382
383 fn default_compactor() -> IndexCompactor {
384 IndexCompactor::new(CompactionPolicy::default())
385 }
386
387 /// Builds stats with `total = 10_000` vectors unless overridden.
388 fn stats(total: usize, deleted: usize, bytes: u64) -> IndexFragmentStats {
389 IndexFragmentStats::new(total, deleted, bytes)
390 }
391
392 const MB: u64 = 1024 * 1024;
393
394 // ------------------------------------------------------------------
395 // 1. should_compact — high deleted ratio trigger
396 // ------------------------------------------------------------------
397
398 #[test]
399 fn test_should_compact_high_deleted_ratio() {
400 let policy = CompactionPolicy::default();
401 // 15 % deleted exceeds the 10 % default threshold
402 let s = stats(10_000, 1_500, 100 * MB);
403 assert!(policy.should_compact(&s));
404 }
405
406 // ------------------------------------------------------------------
407 // 2. should_compact — size exceeded trigger
408 // ------------------------------------------------------------------
409
410 #[test]
411 fn test_should_compact_size_exceeded() {
412 let policy = CompactionPolicy::default();
413 // 0 deleted vectors, but index is over the 512 MiB limit
414 let s = stats(10_000, 0, 600 * MB);
415 assert!(policy.should_compact(&s));
416 }
417
418 // ------------------------------------------------------------------
419 // 3. should_compact — scheduled (custom policy overriding thresholds)
420 // We test both triggers simultaneously to verify that either alone suffices.
421 // ------------------------------------------------------------------
422
423 #[test]
424 fn test_should_compact_both_triggers() {
425 let policy = CompactionPolicy::default();
426 let s = stats(10_000, 2_000, 700 * MB);
427 assert!(policy.should_compact(&s));
428 }
429
430 // ------------------------------------------------------------------
431 // 4. should_compact returns false when all metrics are below thresholds
432 // ------------------------------------------------------------------
433
434 #[test]
435 fn test_should_compact_returns_false_when_healthy() {
436 let policy = CompactionPolicy::default();
437 // 5 % deleted (below 10 %), 100 MiB (below 512 MiB)
438 let s = stats(10_000, 500, 100 * MB);
439 assert!(!policy.should_compact(&s));
440 }
441
442 // ------------------------------------------------------------------
443 // 5. min_vectors guard — tiny index must not be compacted even if ratio is high
444 // ------------------------------------------------------------------
445
446 #[test]
447 fn test_should_compact_min_vectors_guard() {
448 let policy = CompactionPolicy::default();
449 // Only 500 vectors (below the 1000 minimum), 50 % deleted
450 let s = stats(500, 250, 10 * MB);
451 assert!(!policy.should_compact(&s));
452 }
453
454 // ------------------------------------------------------------------
455 // 6. min_vectors guard — exactly at the boundary (1000) should compact
456 // ------------------------------------------------------------------
457
458 #[test]
459 fn test_should_compact_min_vectors_boundary() {
460 let policy = CompactionPolicy::default();
461 // Exactly 1000 vectors, 20 % deleted (above 10 % threshold)
462 let s = stats(1_000, 200, 50 * MB);
463 assert!(policy.should_compact(&s));
464 }
465
466 // ------------------------------------------------------------------
467 // 7. deleted_ratio calculation
468 // ------------------------------------------------------------------
469
470 #[test]
471 fn test_deleted_ratio_calculation() {
472 let s = stats(10_000, 2_500, 100 * MB);
473 let ratio = s.deleted_ratio();
474 assert!(
475 (ratio - 0.25).abs() < f64::EPSILON,
476 "expected 0.25, got {ratio}"
477 );
478 }
479
480 // ------------------------------------------------------------------
481 // 8. deleted_ratio returns 0.0 for empty index
482 // ------------------------------------------------------------------
483
484 #[test]
485 fn test_deleted_ratio_empty_index() {
486 let s = stats(0, 0, 0);
487 assert_eq!(s.deleted_ratio(), 0.0);
488 }
489
490 // ------------------------------------------------------------------
491 // 9. live_vectors helper
492 // ------------------------------------------------------------------
493
494 #[test]
495 fn test_live_vectors() {
496 let s = stats(10_000, 3_000, 100 * MB);
497 assert_eq!(s.live_vectors(), 7_000);
498 }
499
500 // ------------------------------------------------------------------
501 // 10. analyze returns Some when compaction is needed
502 // ------------------------------------------------------------------
503
504 #[test]
505 fn test_analyze_returns_some_when_needed() {
506 let compactor = default_compactor();
507 let s = stats(10_000, 2_000, 100 * MB); // 20 % deleted → above 10 % threshold
508 assert!(compactor.analyze(&s).is_some());
509 }
510
511 // ------------------------------------------------------------------
512 // 11. analyze returns None when index is clean
513 // ------------------------------------------------------------------
514
515 #[test]
516 fn test_analyze_returns_none_when_clean() {
517 let compactor = default_compactor();
518 let s = stats(10_000, 500, 100 * MB); // 5 % deleted, 100 MiB — healthy
519 assert!(compactor.analyze(&s).is_none());
520 }
521
522 // ------------------------------------------------------------------
523 // 12. estimate_priority — Low when below all thresholds
524 // ------------------------------------------------------------------
525
526 #[test]
527 fn test_estimate_priority_low() {
528 let compactor = default_compactor();
529 let s = stats(10_000, 100, 50 * MB); // 1 % deleted, 50 MiB
530 assert_eq!(compactor.estimate_priority(&s), CompactionPriority::Low);
531 }
532
533 // ------------------------------------------------------------------
534 // 13. estimate_priority — Normal when slightly over deleted threshold
535 // ------------------------------------------------------------------
536
537 #[test]
538 fn test_estimate_priority_normal() {
539 let compactor = default_compactor();
540 // 15 % deleted: above 10 % (normal) but below 25 % (high)
541 let s = stats(10_000, 1_500, 100 * MB);
542 assert_eq!(compactor.estimate_priority(&s), CompactionPriority::Normal);
543 }
544
545 // ------------------------------------------------------------------
546 // 14. estimate_priority — High when deleted_ratio > 0.25
547 // ------------------------------------------------------------------
548
549 #[test]
550 fn test_estimate_priority_high() {
551 let compactor = default_compactor();
552 // 30 % deleted
553 let s = stats(10_000, 3_000, 100 * MB);
554 assert_eq!(compactor.estimate_priority(&s), CompactionPriority::High);
555 }
556
557 // ------------------------------------------------------------------
558 // 15. estimate_priority — Critical when deleted_ratio > 0.5
559 // ------------------------------------------------------------------
560
561 #[test]
562 fn test_estimate_priority_critical_high_ratio() {
563 let compactor = default_compactor();
564 // 60 % deleted
565 let s = stats(10_000, 6_000, 100 * MB);
566 assert_eq!(
567 compactor.estimate_priority(&s),
568 CompactionPriority::Critical
569 );
570 }
571
572 // ------------------------------------------------------------------
573 // 16. estimate_priority — Critical when bytes > 2× limit
574 // ------------------------------------------------------------------
575
576 #[test]
577 fn test_estimate_priority_critical_size() {
578 let compactor = default_compactor();
579 // 0 deleted, but > 1024 MiB (2 × 512 MiB default limit)
580 let s = stats(10_000, 0, 1_100 * MB);
581 assert_eq!(
582 compactor.estimate_priority(&s),
583 CompactionPriority::Critical
584 );
585 }
586
587 // ------------------------------------------------------------------
588 // 17. CompactionPriority ordering (Critical > High > Normal > Low)
589 // ------------------------------------------------------------------
590
591 #[test]
592 fn test_compaction_priority_ordering() {
593 assert!(CompactionPriority::Critical > CompactionPriority::High);
594 assert!(CompactionPriority::High > CompactionPriority::Normal);
595 assert!(CompactionPriority::Normal > CompactionPriority::Low);
596 assert!(CompactionPriority::Critical > CompactionPriority::Low);
597 }
598
599 // ------------------------------------------------------------------
600 // 18. Stats increment on each call to analyze
601 // ------------------------------------------------------------------
602
603 #[test]
604 fn test_stats_increment_on_analyze() {
605 let compactor = default_compactor();
606
607 // First call — below thresholds → skipped
608 let clean = stats(10_000, 500, 100 * MB);
609 compactor.analyze(&clean);
610
611 // Second call — above threshold → compaction needed
612 let dirty = stats(10_000, 2_000, 100 * MB);
613 compactor.analyze(&dirty);
614
615 let snap = compactor.stats.snapshot();
616 assert_eq!(snap.total_analyzed, 2);
617 assert_eq!(snap.total_compaction_needed, 1);
618 assert_eq!(snap.total_skipped, 1);
619 }
620
621 // ------------------------------------------------------------------
622 // 19. estimate_bytes_saved formula: deleted_ratio × index_bytes
623 // ------------------------------------------------------------------
624
625 #[test]
626 fn test_estimate_bytes_saved_formula() {
627 let compactor = default_compactor();
628 // 25 % deleted, 400 MiB index → expect ~100 MiB saved
629 let s = stats(10_000, 2_500, 400 * MB);
630 let saved = compactor.estimate_bytes_saved(&s);
631 let expected = (0.25_f64 * (400 * MB) as f64) as u64;
632 assert_eq!(saved, expected);
633 }
634
635 // ------------------------------------------------------------------
636 // 20. CompactionPlan fields are correct
637 // ------------------------------------------------------------------
638
639 #[test]
640 fn test_compaction_plan_fields() {
641 let compactor = default_compactor();
642 let s = stats(10_000, 2_000, 200 * MB); // 20 % deleted
643
644 let plan = compactor.analyze(&s).expect("should produce a plan");
645 assert_eq!(plan.estimated_vectors_after, 8_000);
646 assert!(plan.estimated_bytes_saved > 0);
647 assert_eq!(plan.priority, CompactionPriority::Normal);
648 match plan.reason {
649 CompactionReason::HighDeletedRatio { ratio } => {
650 assert!((ratio - 0.2).abs() < f64::EPSILON);
651 }
652 other => panic!("unexpected reason: {other:?}"),
653 }
654 }
655
656 // ------------------------------------------------------------------
657 // 21. Custom policy — lower thresholds
658 // ------------------------------------------------------------------
659
660 #[test]
661 fn test_custom_policy_lower_thresholds() {
662 let policy = CompactionPolicy {
663 max_deleted_ratio: 0.05,
664 max_index_bytes: 50 * MB,
665 min_vectors_for_compaction: 100,
666 };
667 let compactor = IndexCompactor::new(policy);
668
669 // 8 % deleted (> 5 % custom threshold) — should trigger
670 let s = stats(1_000, 80, 10 * MB);
671 assert!(compactor.analyze(&s).is_some());
672 }
673
674 // ------------------------------------------------------------------
675 // 22. analyze on tiny index below min_vectors returns None
676 // ------------------------------------------------------------------
677
678 #[test]
679 fn test_analyze_tiny_index_skipped() {
680 let compactor = default_compactor();
681 // 900 vectors with 50 % deleted — but below the 1000-vector minimum
682 let s = stats(900, 450, 50 * MB);
683 assert!(compactor.analyze(&s).is_none());
684 }
685
686 // ------------------------------------------------------------------
687 // 23. Reason is SizeExceeded when only size is breached
688 // ------------------------------------------------------------------
689
690 #[test]
691 fn test_reason_size_exceeded() {
692 let compactor = default_compactor();
693 // 0 % deleted, 600 MiB (> 512 MiB limit)
694 let s = stats(10_000, 0, 600 * MB);
695 let plan = compactor.analyze(&s).expect("should produce a plan");
696 match plan.reason {
697 CompactionReason::SizeExceeded {
698 current_bytes,
699 limit_bytes,
700 } => {
701 assert_eq!(current_bytes, 600 * MB);
702 assert_eq!(limit_bytes, 512 * MB);
703 }
704 other => panic!("unexpected reason: {other:?}"),
705 }
706 }
707
708 // ------------------------------------------------------------------
709 // 24. Multiple analyses accumulate stats correctly
710 // ------------------------------------------------------------------
711
712 #[test]
713 fn test_stats_multiple_analyses() {
714 let compactor = default_compactor();
715
716 let clean = stats(10_000, 100, 50 * MB);
717 let dirty = stats(10_000, 5_000, 200 * MB);
718
719 for _ in 0..5 {
720 compactor.analyze(&clean);
721 }
722 for _ in 0..3 {
723 compactor.analyze(&dirty);
724 }
725
726 let snap = compactor.stats.snapshot();
727 assert_eq!(snap.total_analyzed, 8);
728 assert_eq!(snap.total_skipped, 5);
729 assert_eq!(snap.total_compaction_needed, 3);
730 }
731}