1use std::collections::{HashMap, VecDeque};
2use std::hash::Hash;
3
4use crate::SizeGauge;
5
6struct Timestamped<V> {
7 value: V,
8 last_seen_secs: u64,
9 seq: u64,
13}
14
15pub struct BoundedMap<K, V> {
22 inner: HashMap<K, Timestamped<V>>,
23 order: VecDeque<(K, u64)>,
24 capacity: usize,
25 idle_ttl_secs: u64,
26 sweep_interval: usize,
27 inserts_since_sweep: usize,
28 next_seq: u64,
29 gauge: SizeGauge,
30}
31
32impl<K: Eq + Hash + Clone, V> BoundedMap<K, V> {
33 pub fn new(capacity: usize, idle_ttl_secs: u64, gauge: SizeGauge) -> Self {
34 Self {
35 inner: HashMap::new(),
36 order: VecDeque::new(),
37 capacity,
38 idle_ttl_secs,
39 sweep_interval: 256,
40 inserts_since_sweep: 0,
41 next_seq: 0,
42 gauge,
43 }
44 }
45
46 pub fn insert(&mut self, key: K, value: V, now_secs: u64) -> Option<(K, V)> {
47 if self.capacity == 0 {
48 return Some((key, value));
49 }
50 self.inserts_since_sweep = self.inserts_since_sweep.saturating_add(1);
51 if self.inserts_since_sweep >= self.sweep_interval {
52 self.sweep_idle(now_secs);
53 self.inserts_since_sweep = 0;
54 }
55 let mut evicted = None;
56 if !self.inner.contains_key(&key) && self.inner.len() >= self.capacity {
57 evicted = self.evict_oldest();
58 }
59 let seq = self.next_seq;
60 self.next_seq = self.next_seq.saturating_add(1);
61 self.inner.insert(
62 key.clone(),
63 Timestamped {
64 value,
65 last_seen_secs: now_secs,
66 seq,
67 },
68 );
69 self.order.push_back((key, seq));
73 if self.order.len() > self.capacity.saturating_mul(2) {
74 self.compact_order();
75 }
76 self.gauge.set(self.inner.len());
77 evicted
78 }
79
80 pub fn get(&mut self, key: &K, now_secs: u64) -> Option<&V> {
81 match self.inner.get_mut(key) {
82 Some(entry) => {
83 entry.last_seen_secs = now_secs;
84 Some(&entry.value)
85 }
86 None => None,
87 }
88 }
89
90 pub fn len(&self) -> usize {
91 self.inner.len()
92 }
93
94 pub fn is_empty(&self) -> bool {
95 self.inner.is_empty()
96 }
97
98 pub fn capacity(&self) -> usize {
99 self.capacity
100 }
101
102 fn is_live_position(&self, key: &K, seq: u64) -> bool {
104 matches!(self.inner.get(key), Some(entry) if entry.seq == seq)
105 }
106
107 fn compact_order(&mut self) {
108 let mut live: Vec<(K, u64)> = self
111 .inner
112 .iter()
113 .map(|(k, entry)| (k.clone(), entry.seq))
114 .collect();
115 live.sort_by_key(|(_, seq)| *seq);
116 self.order = live.into_iter().collect();
117 }
118
119 fn sweep_idle(&mut self, now_secs: u64) {
120 if self.idle_ttl_secs == 0 {
121 return;
122 }
123 let floor = now_secs.saturating_sub(self.idle_ttl_secs);
124 self.inner.retain(|_, entry| entry.last_seen_secs > floor);
125 let inner = &self.inner;
126 self.order
127 .retain(|(k, seq)| matches!(inner.get(k), Some(entry) if entry.seq == *seq));
128 self.gauge.set(self.inner.len());
129 }
130
131 fn evict_oldest(&mut self) -> Option<(K, V)> {
132 while let Some((candidate, seq)) = self.order.pop_front() {
136 if self.is_live_position(&candidate, seq) {
137 if let Some(entry) = self.inner.remove(&candidate) {
138 self.gauge.set(self.inner.len());
139 return Some((candidate, entry.value));
140 }
141 }
142 }
143 None
144 }
145}
146
147#[cfg(test)]
148mod tests {
149 use crate::{BoundedMap, SizeGauge};
150
151 #[test]
152 fn insert_evicts_oldest_at_cap_and_returns_it() {
153 let gauge = SizeGauge::new();
154 let mut map: BoundedMap<u32, u32> = BoundedMap::new(2, 0, gauge.clone());
155 assert_eq!(map.insert(1, 10, 0), None);
156 assert_eq!(map.insert(2, 20, 0), None);
157 assert_eq!(map.len(), 2);
158 assert_eq!(map.insert(3, 30, 0), Some((1, 10)));
160 assert_eq!(map.len(), 2);
161 assert_eq!(gauge.get(), 2);
162 assert_eq!(map.get(&1, 0), None);
163 assert_eq!(map.get(&3, 0), Some(&30));
164 }
165
166 #[test]
167 fn reinsert_moves_key_to_newest_so_refreshed_key_survives_eviction() {
168 let gauge = SizeGauge::new();
171 let mut map: BoundedMap<u32, u32> = BoundedMap::new(2, 0, gauge.clone());
172 map.insert(1, 10, 0); map.insert(2, 20, 0);
174 map.insert(1, 11, 0);
176 assert_eq!(
179 map.insert(3, 30, 0),
180 Some((2, 20)),
181 "the genuinely-oldest key must be evicted, not the refreshed one"
182 );
183 assert_eq!(map.get(&1, 0), Some(&11), "refreshed key must survive");
184 assert_eq!(map.get(&2, 0), None, "true-oldest key must be gone");
185 assert_eq!(map.get(&3, 0), Some(&30));
186 assert_eq!(map.len(), 2);
187 assert_eq!(gauge.get(), 2);
188 }
189
190 #[test]
191 fn many_reinserts_do_not_leak_order_or_breach_capacity() {
192 let gauge = SizeGauge::new();
195 let mut map: BoundedMap<u32, u32> = BoundedMap::new(4, 0, gauge.clone());
196 for round in 0..1000u32 {
197 let key = round % 4; let _ = map.insert(key, round, 0);
199 assert!(map.len() <= 4, "capacity breached: {}", map.len());
200 assert_eq!(map.len(), gauge.get(), "gauge desynced from len");
201 }
202 for key in 0..4u32 {
204 assert!(map.get(&key, 0).is_some(), "live key {key} lost");
205 }
206 }
207
208 #[test]
209 fn sweep_idle_drops_expired_keeps_fresh() {
210 let gauge = SizeGauge::new();
211 let mut map: BoundedMap<u32, u32> = BoundedMap::new(4096, 10, gauge.clone());
213 map.insert(1, 10, 100); map.insert(2, 20, 100);
215 assert_eq!(map.get(&2, 105), Some(&20));
217 for k in 1000..1256u32 {
219 map.insert(k, k, 115);
220 }
221 assert_eq!(map.get(&1, 115), None);
225
226 let gauge2 = SizeGauge::new();
227 let mut map2: BoundedMap<u32, u32> = BoundedMap::new(4096, 10, gauge2);
228 map2.insert(1, 10, 100);
229 assert_eq!(map2.get(&1, 200), Some(&10)); for k in 1000..1256u32 {
231 map2.insert(k, k, 205); }
233 assert_eq!(map2.get(&1, 205), Some(&10));
234 }
235
236 #[test]
237 fn zero_capacity_disables_and_hands_pair_back() {
238 let gauge = SizeGauge::new();
239 let mut map: BoundedMap<u32, u32> = BoundedMap::new(0, 0, gauge.clone());
240 assert_eq!(map.insert(1, 10, 0), Some((1, 10)));
241 assert_eq!(map.len(), 0);
242 assert_eq!(gauge.get(), 0);
243 }
244}
245
246#[cfg(test)]
247mod prop {
248 use crate::{BoundedMap, SizeGauge};
249 use proptest::prelude::*;
250
251 #[derive(Debug, Clone)]
252 enum Op {
253 Insert(u8, u8, u64),
254 Get(u8, u64),
255 }
256
257 fn op_strategy() -> impl Strategy<Value = Op> {
258 prop_oneof![
259 (any::<u8>(), any::<u8>(), 0u64..1000).prop_map(|(k, v, t)| Op::Insert(k, v, t)),
260 (any::<u8>(), 0u64..1000).prop_map(|(k, t)| Op::Get(k, t)),
261 ]
262 }
263
264 proptest! {
265 #[test]
266 fn gauge_tracks_len_and_len_never_exceeds_capacity(
267 cap in 1usize..64,
268 ttl in 0u64..50,
269 ops in prop::collection::vec(op_strategy(), 0..500),
270 ) {
271 let gauge = SizeGauge::new();
272 let mut map: BoundedMap<u8, u8> = BoundedMap::new(cap, ttl, gauge.clone());
273 for op in ops {
274 match op {
275 Op::Insert(k, v, t) => { let _ = map.insert(k, v, t); }
276 Op::Get(k, t) => { let _ = map.get(&k, t); }
277 }
278 prop_assert!(map.len() <= cap, "len {} exceeded cap {}", map.len(), cap);
279 prop_assert_eq!(map.len(), gauge.get(), "gauge desynced from len");
280 }
281 }
282 }
283}