Skip to main content

chio_bounded/
bounded_map.rs

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    /// Sequence number of this key's newest insert. `order` entries carry the
10    /// seq they were pushed with, so a stale duplicate left behind by a
11    /// re-insert is distinguishable from the key's live position.
12    seq: u64,
13}
14
15/// Capacity-bounded, optionally TTL-swept map for caches and rate-limit tables.
16/// Eviction order is oldest-insert (approximate LRU: a re-insert of an existing
17/// key moves it to newest; `get` refreshes the idle timestamp but does not
18/// reorder). `insert` returns any (key, value) evicted for capacity so the
19/// caller can persist-before-drop. `capacity == 0` disables the cache,
20/// mirroring `Ring`.
21pub 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        // A re-insert of an existing key leaves its previous (key, old_seq) entry
70        // in `order`; that entry is now stale (its seq no longer matches the
71        // key's live seq) and is skipped by both eviction and compaction.
72        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    /// True when `(key, seq)` names a live key at its newest insert position.
103    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        // Rebuild `order` from the live entries in seq order, dropping every
109        // stale duplicate. O(n log n) but amortized over `capacity` inserts.
110        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        // Skip stale duplicate positions (a key whose live seq is newer than the
133        // popped one) so a recently-refreshed key is never evicted ahead of a
134        // genuinely-older key.
135        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        // At cap: inserting a new key evicts the oldest-inserted (key 1).
159        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        // Teeth: against a naive stale-duplicate `order`, evict_oldest pops the
169        // front (stale) copy of the refreshed key and deletes its live entry.
170        let gauge = SizeGauge::new();
171        let mut map: BoundedMap<u32, u32> = BoundedMap::new(2, 0, gauge.clone());
172        map.insert(1, 10, 0); // true-oldest so far
173        map.insert(2, 20, 0);
174        // Re-insert (refresh) key 1: it becomes newest, so key 2 is now oldest.
175        map.insert(1, 11, 0);
176        // Insert a new key at capacity: the true-oldest (key 2) must be evicted,
177        // NOT the just-refreshed key 1.
178        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        // Drive far more re-inserts than 2*capacity so compaction runs and stale
193        // duplicates are reclaimed; capacity and gauge must still hold.
194        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; // only 4 distinct keys: all re-inserts
198            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        // All four keys present with their latest values.
203        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        // capacity high so eviction never fires; idle_ttl 10s; sweep every 256.
212        let mut map: BoundedMap<u32, u32> = BoundedMap::new(4096, 10, gauge.clone());
213        map.insert(1, 10, 100); // last_seen 100
214        map.insert(2, 20, 100);
215        // Refresh key 2 at t=105 so it survives a sweep at t=115.
216        assert_eq!(map.get(&2, 105), Some(&20));
217        // Force a sweep by driving 256 inserts at t=115; floor = 115 - 10 = 105.
218        for k in 1000..1256u32 {
219            map.insert(k, k, 115);
220        }
221        // Key 1 (last_seen 100 <= 105 floor) is swept; key 2 (last_seen 105) is
222        // NOT > floor 105, so it is also swept. Refresh key 2 later to prove the
223        // keep path independently.
224        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)); // refresh last_seen to 200
230        for k in 1000..1256u32 {
231            map2.insert(k, k, 205); // floor 195; key 1 last_seen 200 > 195 survives
232        }
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}