Skip to main content

chronix_engine/cache/
segment.rs

1//! Segment Cache — scan-resistant cache for decoded Arrow arrays from segment files.
2//!
3//! Caches decoded column data keyed by `(SegmentId, row_group, column_name)`.
4//! Uses **W-TinyLFU** admission control with LRU eviction and a configurable
5//! memory bound. The frequency sketch (Count-Min Sketch) prevents sequential
6//! scan patterns from thrashing the cache by rejecting cold newcomers when
7//! they would evict hotter items.
8//!
9//! Also features singleflight dedup (only one loader per cache key), O(1)
10//! eviction via an LRU linked list, and probabilistic promotion to minimise
11//! write-lock contention on the hot read path.
12
13use std::collections::HashMap;
14use std::fmt;
15use std::sync::Arc;
16use std::sync::atomic::{AtomicU64, Ordering};
17
18use arrow::array::ArrayRef;
19use parking_lot::{Mutex, RwLock};
20use tokio::sync::Notify;
21
22use chronix_core::SegmentId;
23
24// ── Frequency sketch (Count-Min Sketch for TinyLFU) ───────────────────
25
26/// Number of rows in the Count-Min Sketch.
27const SKETCH_DEPTH: usize = 4;
28/// Default sketch width, used when no capacity hint is given.
29const DEFAULT_SKETCH_WIDTH: usize = 2048;
30
31/// A Count-Min Sketch for estimating access frequency.
32///
33/// Used by the TinyLFU admission filter to decide whether a newly loaded
34/// item is "hot" enough to evict the current LRU victim. Each key is
35/// hashed to `SKETCH_DEPTH` rows; the minimum counter across rows is the
36/// estimated frequency.
37///
38/// Width scales with cache capacity to reduce hash collisions
39/// in large caches.
40struct FrequencySketch {
41    /// Counter matrix: `SKETCH_DEPTH` rows × `width` columns.
42    table: Vec<Vec<u16>>,
43    /// Number of columns (must be power of 2).
44    width: usize,
45    /// Total number of increment operations (for periodic reset).
46    additions: u64,
47    /// Reset all counters after this many increments.
48    reset_interval: u64,
49}
50
51impl FrequencySketch {
52    /// Sketch with the default width (test-only convenience).
53    #[cfg(test)]
54    fn new() -> Self {
55        Self::with_width(DEFAULT_SKETCH_WIDTH)
56    }
57
58    /// Create a sketch with a specific width (rounded up to power of 2).
59    fn with_width(width: usize) -> Self {
60        let width = width.next_power_of_two().max(64);
61        Self {
62            table: vec![vec![0u16; width]; SKETCH_DEPTH],
63            width,
64            additions: 0,
65            reset_interval: 10 * width as u64,
66        }
67    }
68
69    /// Increment the frequency estimate for a key.
70    fn increment(&mut self, hash: u64) {
71        for row in 0..SKETCH_DEPTH {
72            let col = self.index(hash, row);
73            self.table[row][col] = self.table[row][col].saturating_add(1);
74        }
75        self.additions += 1;
76        if self.additions >= self.reset_interval {
77            self.reset();
78        }
79    }
80
81    /// Estimate the frequency of a key (minimum across all rows).
82    fn estimate(&self, hash: u64) -> u16 {
83        let mut min = u16::MAX;
84        for row in 0..SKETCH_DEPTH {
85            let col = self.index(hash, row);
86            min = min.min(self.table[row][col]);
87        }
88        min
89    }
90
91    /// Halve all counters (aging / decay) and reset the addition counter.
92    fn reset(&mut self) {
93        for row in &mut self.table {
94            for cell in row.iter_mut() {
95                *cell >>= 1;
96            }
97        }
98        self.additions = 0;
99    }
100
101    /// Compute the column index for a given hash and row, using
102    /// hash mixing to produce independent indices per row.
103    fn index(&self, hash: u64, row: usize) -> usize {
104        let mixed = hash.wrapping_mul(0x9E37_79B9_7F4A_7C15_u64.wrapping_add(row as u64));
105        (mixed >> 48) as usize & (self.width - 1)
106    }
107}
108
109/// Compute a 64-bit hash for a cache key (used by the frequency sketch).
110fn key_hash(key: &SegmentCacheKey) -> u64 {
111    use std::hash::{Hash, Hasher};
112    let mut h = std::collections::hash_map::DefaultHasher::new();
113    key.hash(&mut h);
114    h.finish()
115}
116
117/// Error returned to waiter threads when a singleflight cache load fails.
118///
119/// The original typed error cannot be transferred across threads (the
120/// loader's error type `E` is not available to waiters), so the error
121/// message is preserved as a `String`.
122#[derive(Debug, Clone)]
123pub struct CacheLoadError(pub String);
124
125impl fmt::Display for CacheLoadError {
126    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
127        write!(f, "cache load failed: {}", self.0)
128    }
129}
130
131impl std::error::Error for CacheLoadError {}
132
133impl From<CacheLoadError> for String {
134    fn from(e: CacheLoadError) -> Self {
135        e.0
136    }
137}
138
139/// Key for segment cache lookups.
140///
141/// `column` uses `Arc<str>` instead of `String` to make
142/// LRU promotion clones cheap (atomic refcount bump vs heap alloc).
143#[derive(Debug, Clone, PartialEq, Eq, Hash)]
144pub struct SegmentCacheKey {
145    /// The segment this data came from.
146    pub segment_id: SegmentId,
147    /// Row group index within the segment.
148    pub row_group: u32,
149    /// Column name (cheaply clonable via Arc).
150    pub column: Arc<str>,
151}
152
153/// An entry in the segment cache with LRU tracking.
154struct CacheEntry {
155    /// The cached Arrow array.
156    array: ArrayRef,
157    /// Byte size of this array (for eviction accounting).
158    byte_size: usize,
159    /// Previous key in LRU order (toward most-recently-used).
160    prev: Option<SegmentCacheKey>,
161    /// Next key in LRU order (toward least-recently-used).
162    next: Option<SegmentCacheKey>,
163}
164
165/// Singleflight state for a key currently being loaded.
166///
167/// Uses [`tokio::sync::Notify`] instead of `parking_lot::Condvar` so
168/// that waiters yield the tokio worker thread instead of blocking it.
169struct InflightEntry {
170    /// The load result, set by the loader thread.
171    result: Mutex<Option<Result<ArrayRef, String>>>,
172    /// Async notification channel — wakes all `.notified()` futures.
173    notify: Notify,
174}
175
176/// Inner mutable state protected by a single mutex.
177struct CacheInner {
178    /// Cached entries in a doubly-linked LRU list threaded through the HashMap.
179    entries: HashMap<SegmentCacheKey, CacheEntry>,
180    /// Head of LRU list — most recently used.
181    lru_head: Option<SegmentCacheKey>,
182    /// Tail of LRU list — least recently used (eviction candidate).
183    lru_tail: Option<SegmentCacheKey>,
184    /// In-flight loads for singleflight dedup.
185    inflight: HashMap<SegmentCacheKey, Arc<InflightEntry>>,
186}
187
188/// LRU segment cache with configurable memory bound.
189///
190/// Stores decoded Arrow arrays from segment files. When the cache exceeds
191/// its memory bound, the least-recently-used entries are evicted via an
192/// O(1) linked-list removal.
193///
194/// **Singleflight:** When multiple threads request the same uncached key
195/// simultaneously, only one thread invokes the loader while the others
196/// wait. This prevents thundering-herd cache stampedes.
197///
198/// Thread-safe via `RwLock<CacheInner>` with atomic counters for stats.
199pub struct SegmentCache {
200    /// Mutable cache state.
201    inner: RwLock<CacheInner>,
202    /// TinyLFU frequency sketch — behind its own Mutex so hits on the
203    /// read-lock fast path can still record frequency.
204    sketch: Mutex<FrequencySketch>,
205    /// Maximum cache size in bytes.
206    max_bytes: usize,
207    /// Current cache size in bytes.
208    current_bytes: AtomicU64,
209    /// Cache hit counter.
210    hits: AtomicU64,
211    /// Cache miss counter.
212    misses: AtomicU64,
213}
214
215impl SegmentCache {
216    /// Create a new segment cache with the given maximum size in bytes.
217    #[must_use]
218    pub fn new(max_bytes: usize) -> Self {
219        // Scale sketch width with cache capacity. Assume ~64KB
220        // average block size, then use 10× estimated max entries as width.
221        let estimated_entries = (max_bytes / (64 * 1024)).max(1);
222        let sketch_width = (estimated_entries * 10).clamp(DEFAULT_SKETCH_WIDTH, 1 << 20);
223        Self {
224            inner: RwLock::new(CacheInner {
225                entries: HashMap::new(),
226                lru_head: None,
227                lru_tail: None,
228                inflight: HashMap::new(),
229            }),
230            sketch: Mutex::new(FrequencySketch::with_width(sketch_width)),
231            max_bytes,
232            current_bytes: AtomicU64::new(0),
233            hits: AtomicU64::new(0),
234            misses: AtomicU64::new(0),
235        }
236    }
237
238    /// Promote a key to the head (most-recently-used) of the LRU list.
239    fn promote(inner: &mut CacheInner, key: &SegmentCacheKey) {
240        // Already at head?
241        if inner.lru_head.as_ref() == Some(key) {
242            return;
243        }
244        // Detach from current position
245        Self::detach(inner, key);
246        // Push to head
247        Self::push_head(inner, key);
248    }
249
250    /// Detach a key from the LRU doubly-linked list.
251    fn detach(inner: &mut CacheInner, key: &SegmentCacheKey) {
252        let (prev, next) = {
253            let entry = match inner.entries.get(key) {
254                Some(e) => e,
255                None => return,
256            };
257            (entry.prev.clone(), entry.next.clone())
258        };
259
260        if let Some(ref prev_key) = prev {
261            if let Some(prev_entry) = inner.entries.get_mut(prev_key) {
262                prev_entry.next = next.clone();
263            }
264        } else {
265            // This was the head
266            inner.lru_head = next.clone();
267        }
268
269        if let Some(ref next_key) = next {
270            if let Some(next_entry) = inner.entries.get_mut(next_key) {
271                next_entry.prev = prev;
272            }
273        } else {
274            // This was the tail
275            inner.lru_tail = prev;
276        }
277
278        if let Some(entry) = inner.entries.get_mut(key) {
279            entry.prev = None;
280            entry.next = None;
281        }
282    }
283
284    /// Push a key to the head of the LRU list. The key must already exist
285    /// in `entries` but must NOT be linked in the list.
286    fn push_head(inner: &mut CacheInner, key: &SegmentCacheKey) {
287        let old_head = inner.lru_head.take();
288        if let Some(ref old_head_key) = old_head
289            && let Some(old_entry) = inner.entries.get_mut(old_head_key)
290        {
291            old_entry.prev = Some(key.clone());
292        }
293        if let Some(entry) = inner.entries.get_mut(key) {
294            entry.prev = None;
295            entry.next = old_head.clone();
296        }
297        inner.lru_head = Some(key.clone());
298        if inner.lru_tail.is_none() {
299            inner.lru_tail = Some(key.clone());
300        }
301    }
302
303    /// Get a cached array, or load it using the provided closure on miss.
304    ///
305    /// Uses singleflight dedup: if another task is already loading the
306    /// same key, this task awaits the result instead of invoking the
307    /// loader again.  The wait uses [`tokio::sync::Notify`] so the
308    /// calling tokio worker thread is **not** blocked.
309    ///
310    /// Cache hits use a read lock (no contention).  LRU promotion
311    /// is probabilistic — only every 16th hit acquires a write lock
312    /// to reorder the LRU list, amortising lock contention on the
313    /// hot read path.
314    ///
315    /// # Errors
316    ///
317    /// Returns an error if the loader function fails.
318    pub async fn get_or_load<F, E>(
319        &self,
320        key: SegmentCacheKey,
321        loader: F,
322    ) -> std::result::Result<ArrayRef, E>
323    where
324        F: FnOnce() -> std::result::Result<ArrayRef, E>,
325        E: std::fmt::Display + Clone + From<CacheLoadError>,
326    {
327        // Decision from the locked critical section — no MutexGuard
328        // escapes this block, so the future remains Send.
329        enum Action {
330            Hit(ArrayRef),
331            Wait(Arc<InflightEntry>),
332            Load(Arc<InflightEntry>),
333        }
334
335        // Try read lock first for cache hits — avoids write
336        // lock contention on the hot path.
337        {
338            let inner = self.inner.read();
339            if let Some(entry) = inner.entries.get(&key) {
340                let arr = entry.array.clone();
341                drop(inner);
342                self.sketch.lock().increment(key_hash(&key));
343                let n = self.hits.fetch_add(1, Ordering::Relaxed);
344                // Probabilistic LRU promotion: only every 16th hit takes a
345                // write lock to reorder the list.  This trades a small LRU
346                // accuracy loss for dramatically reduced lock contention.
347                if n.is_multiple_of(16) {
348                    let mut w = self.inner.write();
349                    Self::promote(&mut w, &key);
350                }
351                return Ok(arr);
352            }
353        }
354
355        let action = {
356            let mut inner = self.inner.write();
357
358            // Re-check under write lock (another thread may have inserted)
359            if let Some(entry) = inner.entries.get(&key) {
360                let arr = entry.array.clone();
361                Self::promote(&mut inner, &key);
362                self.hits.fetch_add(1, Ordering::Relaxed);
363                Action::Hit(arr)
364            } else if let Some(waiter) = inner.inflight.get(&key).cloned() {
365                self.misses.fetch_add(1, Ordering::Relaxed);
366                Action::Wait(waiter)
367            } else {
368                let entry = Arc::new(InflightEntry {
369                    result: Mutex::new(None),
370                    notify: Notify::new(),
371                });
372                inner.inflight.insert(key.clone(), entry.clone());
373                self.misses.fetch_add(1, Ordering::Relaxed);
374                Action::Load(entry)
375            }
376            // MutexGuard dropped here
377        };
378
379        match action {
380            Action::Hit(arr) => Ok(arr),
381            Action::Wait(waiter) => self.wait_inflight(&waiter).await.map_err(E::from),
382            Action::Load(entry) => {
383                // We are the loader — call the closure outside of any lock
384                let load_result = loader();
385
386                // Publish result
387                let array = match load_result {
388                    Ok(arr) => {
389                        *entry.result.lock() = Some(Ok(arr.clone()));
390                        entry.notify.notify_waiters();
391                        arr
392                    }
393                    Err(e) => {
394                        *entry.result.lock() = Some(Err(e.to_string()));
395                        entry.notify.notify_waiters();
396                        self.inner.write().inflight.remove(&key);
397                        return Err(e);
398                    }
399                };
400
401                let byte_size = array.get_array_memory_size();
402
403                // Combine cache insertion + inflight removal
404                // under a single lock acquisition to avoid a window where
405                // the entry is in the cache but still marked as inflight.
406                {
407                    let mut inner = self.inner.write();
408                    let new_hash = key_hash(&key);
409                    let mut sketch = self.sketch.lock();
410                    sketch.increment(new_hash);
411
412                    // TinyLFU admission gate: only admit the newcomer if it
413                    // is at least as popular as the LRU victim it would
414                    // evict. This prevents sequential scan patterns from
415                    // thrashing the cache.
416                    let admit = if byte_size > self.max_bytes {
417                        false // single item exceeds entire budget
418                    } else if self.current_bytes.load(Ordering::Relaxed) as usize + byte_size
419                        > self.max_bytes
420                    {
421                        // Cache is full — compare newcomer freq vs victim freq
422                        if let Some(ref victim_key) = inner.lru_tail {
423                            let victim_freq = sketch.estimate(key_hash(victim_key));
424                            let new_freq = sketch.estimate(new_hash);
425                            new_freq >= victim_freq
426                        } else {
427                            true // empty cache, always admit
428                        }
429                    } else {
430                        true // cache has room, always admit
431                    };
432                    drop(sketch);
433
434                    if admit {
435                        self.evict_to_fit_locked(&mut inner, byte_size);
436                        inner.entries.insert(
437                            key.clone(),
438                            CacheEntry {
439                                array: array.clone(),
440                                byte_size,
441                                prev: None,
442                                next: None,
443                            },
444                        );
445                        Self::push_head(&mut inner, &key);
446                        self.current_bytes
447                            .fetch_add(byte_size as u64, Ordering::Relaxed);
448                    }
449                    inner.inflight.remove(&key);
450                }
451
452                Ok(array)
453            }
454        }
455    }
456
457    /// Wait for an in-flight load to complete and return its result.
458    ///
459    /// Uses [`tokio::sync::Notify`] so the caller yields the tokio worker
460    /// thread rather than blocking it with a `Condvar::wait()`.
461    async fn wait_inflight(
462        &self,
463        entry: &Arc<InflightEntry>,
464    ) -> std::result::Result<ArrayRef, CacheLoadError> {
465        loop {
466            // Register the `Notified` future BEFORE checking
467            // the result. `Notify::notify_waiters()` is not sticky — if we
468            // check-then-register, the notification can fire in between
469            // and the waiter hangs forever.
470            let notified = entry.notify.notified();
471
472            // Now check if the result is already available.
473            if let Some(ref result) = *entry.result.lock() {
474                return match result {
475                    Ok(arr) => Ok(arr.clone()),
476                    Err(msg) => Err(CacheLoadError(msg.clone())),
477                };
478            }
479            // Yield to tokio runtime until notified — safe because we
480            // registered the future before the result check.
481            notified.await;
482        }
483    }
484
485    /// Evict LRU entries until there's room for `needed_bytes`.
486    /// Must be called with `inner` already locked.
487    fn evict_to_fit_locked(&self, inner: &mut CacheInner, needed_bytes: usize) {
488        let target = self.max_bytes.saturating_sub(needed_bytes);
489
490        loop {
491            #[allow(clippy::cast_possible_truncation)]
492            let current = self.current_bytes.load(Ordering::Relaxed) as usize;
493            if current <= target {
494                break;
495            }
496
497            // Evict from the tail (least-recently-used) — O(1)
498            let tail_key = match inner.lru_tail.clone() {
499                Some(k) => k,
500                None => break,
501            };
502            Self::detach(inner, &tail_key);
503            if let Some(removed) = inner.entries.remove(&tail_key) {
504                self.current_bytes
505                    .fetch_sub(removed.byte_size as u64, Ordering::Relaxed);
506            }
507        }
508    }
509
510    /// Invalidate all entries for a given segment.
511    pub fn invalidate_segment(&self, segment_id: SegmentId) {
512        let mut inner = self.inner.write();
513        let keys_to_remove: Vec<SegmentCacheKey> = inner
514            .entries
515            .keys()
516            .filter(|k| k.segment_id == segment_id)
517            .cloned()
518            .collect();
519
520        for key in keys_to_remove {
521            Self::detach(&mut inner, &key);
522            if let Some(removed) = inner.entries.remove(&key) {
523                self.current_bytes
524                    .fetch_sub(removed.byte_size as u64, Ordering::Relaxed);
525            }
526        }
527    }
528
529    /// Clear the entire cache.
530    pub fn clear(&self) {
531        let mut inner = self.inner.write();
532        inner.entries.clear();
533        inner.lru_head = None;
534        inner.lru_tail = None;
535        self.current_bytes.store(0, Ordering::Relaxed);
536    }
537
538    /// Returns the current cache size in bytes.
539    #[must_use]
540    pub fn size_bytes(&self) -> u64 {
541        self.current_bytes.load(Ordering::Relaxed)
542    }
543
544    /// Returns the number of cached entries.
545    #[must_use]
546    pub fn entry_count(&self) -> usize {
547        self.inner.read().entries.len()
548    }
549
550    /// Returns the total cache hits.
551    #[must_use]
552    pub fn hits(&self) -> u64 {
553        self.hits.load(Ordering::Relaxed)
554    }
555
556    /// Returns the total cache misses.
557    #[must_use]
558    pub fn misses(&self) -> u64 {
559        self.misses.load(Ordering::Relaxed)
560    }
561
562    /// Returns the maximum cache size in bytes.
563    #[must_use]
564    pub fn max_bytes(&self) -> usize {
565        self.max_bytes
566    }
567}
568
569impl std::fmt::Debug for SegmentCache {
570    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
571        f.debug_struct("SegmentCache")
572            .field("max_bytes", &self.max_bytes)
573            .field("current_bytes", &self.current_bytes.load(Ordering::Relaxed))
574            .field("entries", &self.inner.read().entries.len())
575            .field("hits", &self.hits.load(Ordering::Relaxed))
576            .field("misses", &self.misses.load(Ordering::Relaxed))
577            .finish_non_exhaustive()
578    }
579}
580
581#[cfg(test)]
582mod tests {
583    use super::*;
584    use arrow::array::Int64Array;
585    use std::sync::Arc;
586
587    fn make_key(seg_id: u64, rg: u32, col: &str) -> SegmentCacheKey {
588        SegmentCacheKey {
589            segment_id: SegmentId(seg_id),
590            row_group: rg,
591            column: Arc::from(col),
592        }
593    }
594
595    fn make_array(size: usize) -> ArrayRef {
596        let values: Vec<i64> = (0..size as i64).collect();
597        Arc::new(Int64Array::from(values))
598    }
599
600    #[tokio::test]
601    async fn cache_hit_returns_same_data() {
602        let cache = SegmentCache::new(1024 * 1024);
603        let key = make_key(1, 0, chronix_core::TIME_COLUMN);
604        let arr = make_array(100);
605
606        let loaded = cache
607            .get_or_load(key.clone(), || Ok::<_, String>(arr.clone()))
608            .await
609            .unwrap();
610        assert_eq!(loaded.len(), 100);
611        assert_eq!(cache.misses(), 1);
612
613        // Second access should be a hit
614        let hit = cache
615            .get_or_load(key, || Ok::<_, String>(make_array(999)))
616            .await
617            .unwrap();
618        assert_eq!(hit.len(), 100); // Same data, not the 999-element one
619        assert_eq!(cache.hits(), 1);
620    }
621
622    /// The byte budget is a hard bound, not a target. Admission, eviction and
623    /// the `current_bytes` update all happen under one write lock, so the
624    /// invariant must hold after every insert — including concurrent ones.
625    #[tokio::test]
626    async fn never_exceeds_byte_budget() {
627        let entry_size = make_array(64).get_array_memory_size();
628        let max_bytes = entry_size * 4;
629        let cache = SegmentCache::new(max_bytes);
630
631        for i in 0..200u64 {
632            let arr = make_array(64);
633            cache
634                .get_or_load(make_key(i, 0, "v"), || Ok::<_, String>(arr))
635                .await
636                .unwrap();
637            assert!(
638                cache.size_bytes() as usize <= max_bytes,
639                "cache overshot its budget after insert {i}: {} > {max_bytes}",
640                cache.size_bytes()
641            );
642        }
643    }
644
645    /// Concurrent loads must not let the accounting drift past the budget.
646    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
647    async fn concurrent_loads_respect_byte_budget() {
648        let entry_size = make_array(64).get_array_memory_size();
649        let max_bytes = entry_size * 8;
650        let cache = Arc::new(SegmentCache::new(max_bytes));
651
652        let mut handles = Vec::new();
653        for t in 0..8u64 {
654            let cache = Arc::clone(&cache);
655            handles.push(tokio::spawn(async move {
656                for i in 0..50u64 {
657                    let arr = make_array(64);
658                    let _ = cache
659                        .get_or_load(make_key(t * 1000 + i, 0, "v"), || Ok::<_, String>(arr))
660                        .await;
661                }
662            }));
663        }
664        for h in handles {
665            h.await.unwrap();
666        }
667
668        assert!(
669            cache.size_bytes() as usize <= max_bytes,
670            "cache overshot its budget under concurrency: {} > {max_bytes}",
671            cache.size_bytes()
672        );
673    }
674
675    #[tokio::test]
676    async fn eviction_removes_lru_entry() {
677        // Small cache: fits ~2 small arrays
678        let small_arr_size = make_array(10).get_array_memory_size();
679        let cache = SegmentCache::new(small_arr_size * 2 + 10);
680
681        cache
682            .get_or_load(make_key(1, 0, "ts"), || Ok::<_, String>(make_array(10)))
683            .await
684            .unwrap();
685        cache
686            .get_or_load(make_key(2, 0, "ts"), || Ok::<_, String>(make_array(10)))
687            .await
688            .unwrap();
689        assert_eq!(cache.entry_count(), 2);
690
691        // Adding a third should evict the LRU (key 1)
692        cache
693            .get_or_load(make_key(3, 0, "ts"), || Ok::<_, String>(make_array(10)))
694            .await
695            .unwrap();
696        assert!(cache.entry_count() <= 2);
697    }
698
699    #[tokio::test]
700    async fn invalidate_segment_clears_matching() {
701        let cache = SegmentCache::new(1024 * 1024);
702
703        cache
704            .get_or_load(make_key(1, 0, "ts"), || Ok::<_, String>(make_array(10)))
705            .await
706            .unwrap();
707        cache
708            .get_or_load(make_key(1, 1, "ts"), || Ok::<_, String>(make_array(10)))
709            .await
710            .unwrap();
711        cache
712            .get_or_load(make_key(2, 0, "ts"), || Ok::<_, String>(make_array(10)))
713            .await
714            .unwrap();
715        assert_eq!(cache.entry_count(), 3);
716
717        cache.invalidate_segment(SegmentId(1));
718        assert_eq!(cache.entry_count(), 1);
719    }
720
721    #[tokio::test]
722    async fn clear_empties_cache() {
723        let cache = SegmentCache::new(1024 * 1024);
724        cache
725            .get_or_load(make_key(1, 0, "ts"), || Ok::<_, String>(make_array(10)))
726            .await
727            .unwrap();
728        cache.clear();
729        assert_eq!(cache.entry_count(), 0);
730        assert_eq!(cache.size_bytes(), 0);
731    }
732
733    #[tokio::test]
734    async fn lru_order_evicts_oldest() {
735        // Cache fits exactly 2 entries
736        let arr_size = make_array(10).get_array_memory_size();
737        let cache = SegmentCache::new(arr_size * 2 + 10);
738
739        // Insert keys 1, 2
740        cache
741            .get_or_load(make_key(1, 0, "ts"), || Ok::<_, String>(make_array(10)))
742            .await
743            .unwrap();
744        cache
745            .get_or_load(make_key(2, 0, "ts"), || Ok::<_, String>(make_array(10)))
746            .await
747            .unwrap();
748
749        // Access key 1 to promote it (now LRU order: 2 is tail)
750        cache
751            .get_or_load(make_key(1, 0, "ts"), || Ok::<_, String>(make_array(999)))
752            .await
753            .unwrap();
754        assert_eq!(cache.hits(), 1); // key 1 was a hit
755
756        // Insert key 3 — should evict key 2 (LRU tail), not key 1
757        cache
758            .get_or_load(make_key(3, 0, "ts"), || Ok::<_, String>(make_array(10)))
759            .await
760            .unwrap();
761
762        // Key 1 should still be cached
763        let r = cache
764            .get_or_load(make_key(1, 0, "ts"), || Ok::<_, String>(make_array(999)))
765            .await
766            .unwrap();
767        assert_eq!(cache.hits(), 2);
768        assert_eq!(r.len(), 10); // Original data, not the 999-element reload
769
770        // Key 2 should have been evicted
771        let r2 = cache
772            .get_or_load(make_key(2, 0, "ts"), || Ok::<_, String>(make_array(5)))
773            .await
774            .unwrap();
775        assert_eq!(r2.len(), 5); // Reloaded
776        assert_eq!(cache.misses(), 4); // keys 1,2,3 initial + key 2 re-miss
777    }
778
779    #[tokio::test]
780    async fn singleflight_dedup() {
781        use std::sync::atomic::AtomicUsize;
782
783        let cache = Arc::new(SegmentCache::new(1024 * 1024));
784        let load_count = Arc::new(AtomicUsize::new(0));
785
786        let mut handles = Vec::new();
787        for _ in 0..10 {
788            let cache = cache.clone();
789            let load_count = load_count.clone();
790            handles.push(tokio::spawn(async move {
791                cache
792                    .get_or_load(make_key(42, 0, "val"), || {
793                        load_count.fetch_add(1, Ordering::SeqCst);
794                        // Simulate slow load
795                        std::thread::sleep(std::time::Duration::from_millis(50));
796                        Ok::<_, String>(make_array(100))
797                    })
798                    .await
799                    .unwrap()
800            }));
801        }
802
803        for h in handles {
804            let arr = h.await.unwrap();
805            assert_eq!(arr.len(), 100);
806        }
807
808        // Only one task should have called the loader
809        assert_eq!(load_count.load(Ordering::SeqCst), 1);
810        assert_eq!(cache.entry_count(), 1);
811    }
812
813    #[tokio::test]
814    async fn tinylfu_rejects_cold_scan() {
815        // Cache fits exactly 2 entries. Pre-heat keys A and B, then
816        // scan through many cold keys. TinyLFU should reject the cold
817        // newcomers because A and B have higher frequency.
818        let arr_size = make_array(10).get_array_memory_size();
819        let cache = SegmentCache::new(arr_size * 2 + 10);
820
821        let key_a = make_key(1, 0, "ts");
822        let key_b = make_key(2, 0, "ts");
823
824        // Insert and access A and B multiple times to build frequency
825        for _ in 0..5 {
826            cache
827                .get_or_load(key_a.clone(), || Ok::<_, String>(make_array(10)))
828                .await
829                .unwrap();
830            cache
831                .get_or_load(key_b.clone(), || Ok::<_, String>(make_array(10)))
832                .await
833                .unwrap();
834        }
835
836        // Now scan through 20 cold keys (each accessed only once)
837        for i in 100..120 {
838            cache
839                .get_or_load(make_key(i, 0, "ts"), || Ok::<_, String>(make_array(10)))
840                .await
841                .unwrap();
842        }
843
844        // Hot keys A and B should still be in cache — TinyLFU admission
845        // gate should have rejected the cold scan keys.
846        let r_a = cache
847            .get_or_load(key_a.clone(), || Ok::<_, String>(make_array(999)))
848            .await
849            .unwrap();
850        assert_eq!(r_a.len(), 10, "hot key A should still be cached");
851
852        let r_b = cache
853            .get_or_load(key_b.clone(), || Ok::<_, String>(make_array(999)))
854            .await
855            .unwrap();
856        assert_eq!(r_b.len(), 10, "hot key B should still be cached");
857    }
858
859    #[tokio::test]
860    async fn tinylfu_admits_hot_newcomer() {
861        // TinyLFU should admit a newcomer that is hotter than the victim.
862        let arr_size = make_array(10).get_array_memory_size();
863        let cache = SegmentCache::new(arr_size * 2 + 10);
864
865        // Fill cache with cold keys (accessed once each)
866        cache
867            .get_or_load(make_key(1, 0, "ts"), || Ok::<_, String>(make_array(10)))
868            .await
869            .unwrap();
870        cache
871            .get_or_load(make_key(2, 0, "ts"), || Ok::<_, String>(make_array(10)))
872            .await
873            .unwrap();
874        assert_eq!(cache.entry_count(), 2);
875
876        // Build frequency for a new key by accessing it several times
877        // (it will miss each time, load, get rejected, but sketch still
878        // records the frequency).
879        let hot_key = make_key(99, 0, "ts");
880        for _ in 0..5 {
881            cache
882                .get_or_load(hot_key.clone(), || Ok::<_, String>(make_array(10)))
883                .await
884                .unwrap();
885        }
886
887        // The hot key should now be cached (admitted after building freq)
888        let inner = cache.inner.read();
889        assert!(
890            inner.entries.contains_key(&hot_key),
891            "hot newcomer should have been admitted"
892        );
893    }
894
895    #[tokio::test]
896    async fn sketch_reset_prevents_count_bloat() {
897        let mut sketch = FrequencySketch::new();
898        let reset_interval = sketch.reset_interval;
899
900        // Increment the same key many times — should trigger periodic reset
901        let key_hash = 42u64;
902        for _ in 0..(reset_interval * 3) {
903            sketch.increment(key_hash);
904        }
905
906        // After multiple resets, the estimate should be bounded (not
907        // accumulated to SKETCH_RESET_INTERVAL * 3).
908        let est = sketch.estimate(key_hash);
909        assert!(
910            est < 25000,
911            "sketch estimate should be bounded after resets, got {est}"
912        );
913    }
914}