1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
//! TTL-aware expiration profile for log file utilization.
//!
use std::collections::BTreeMap;
/// Tracks the distribution of expiration times for entries in a log file.
///
/// Used by the cleaner to predict when a file will become fully obsolete
/// due to TTL expiration, avoiding unnecessary migration work.
#[derive(Debug, Default, Clone)]
pub struct ExpirationProfile {
/// Map from expiration_time bucket to byte count.
/// Key: packed hours since epoch (same resolution as BinEntry.expiration_time).
buckets: BTreeMap<u32, u64>,
/// Total bytes tracked in this profile.
total_bytes: u64,
}
impl ExpirationProfile {
pub fn new() -> Self {
Self::default()
}
/// Record `size` bytes that expire at `expiration_time` (0 = never).
pub fn add(&mut self, expiration_time: u32, size: u64) {
if expiration_time != 0 {
*self.buckets.entry(expiration_time).or_default() += size;
}
self.total_bytes += size;
}
/// Returns true if all tracked bytes are predicted to be expired by `current_time`.
pub fn is_all_expired(&self, current_time: u32) -> bool {
if self.total_bytes == 0 {
return true;
}
self.buckets.keys().all(|&t| t <= current_time)
}
/// Returns the fraction (0.0-1.0) of bytes predicted expired by `current_time`.
pub fn expired_fraction(&self, current_time: u32) -> f64 {
if self.total_bytes == 0 {
return 1.0;
}
let expired: u64 = self
.buckets
.iter()
.filter(|&(&t, _)| t <= current_time)
.map(|(_, &b)| b)
.sum();
expired as f64 / self.total_bytes as f64
}
pub fn total_bytes(&self) -> u64 {
self.total_bytes
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_new_is_empty() {
let ep = ExpirationProfile::new();
assert_eq!(ep.total_bytes(), 0);
assert!(ep.is_all_expired(0));
assert_eq!(ep.expired_fraction(0), 1.0);
}
#[test]
fn test_add_no_expiration() {
let mut ep = ExpirationProfile::new();
ep.add(0, 100);
assert_eq!(ep.total_bytes(), 100);
// Bucket 0 (never-expires) is not added to buckets map,
// so is_all_expired returns true (no buckets with future expiry).
assert!(ep.is_all_expired(0));
}
#[test]
fn test_add_with_expiration() {
let mut ep = ExpirationProfile::new();
ep.add(100, 500);
ep.add(200, 300);
assert_eq!(ep.total_bytes(), 800);
// At time 99, nothing is expired.
assert!(!ep.is_all_expired(99));
assert_eq!(ep.expired_fraction(99), 0.0);
// At time 100, first bucket expired.
assert!(!ep.is_all_expired(100));
assert!((ep.expired_fraction(100) - 500.0 / 800.0).abs() < 1e-9);
// At time 200, all expired.
assert!(ep.is_all_expired(200));
assert_eq!(ep.expired_fraction(200), 1.0);
}
#[test]
fn test_expired_fraction_mixed_never_and_ttl() {
let mut ep = ExpirationProfile::new();
ep.add(0, 400); // never expires
ep.add(10, 600); // expires at hour 10
assert_eq!(ep.total_bytes(), 1000);
// At time 10: 600 bytes expired, 400 never expire.
let frac = ep.expired_fraction(10);
assert!((frac - 0.6).abs() < 1e-9);
// is_all_expired: bucket 10 <= 10, so all buckets expired.
assert!(ep.is_all_expired(10));
}
}